1use std::{
2 collections::HashSet,
3 fmt::Debug,
4 sync::{
5 Arc,
6 atomic::{AtomicU64, Ordering},
7 },
8};
9
10use crate::{LikelihoodError, LikelihoodResult};
11use laddu_compile::{CompiledModel, ReductionPlan};
12use laddu_data::data::Dataset;
13#[cfg(test)]
14use laddu_expr::parameters::ParamError;
15use laddu_expr::parameters::{ParamId, ParamLayout, ParamRegistry, ParamValues};
16#[cfg(test)]
17use laddu_runtime::NormalizationMode;
18use laddu_runtime::{
19 Execution, PreparedDataset, PreparedModel, PreparedNormalization,
20 PreparedNormalizationDiagnostics, RuntimeError,
21};
22
23#[derive(Copy, Clone, Debug, PartialEq, Eq)]
25pub enum DatasetRole {
26 Observed,
28 AcceptedMc,
30}
31
32#[derive(Clone, Debug, PartialEq)]
34pub struct DatasetDiagnostics {
35 term: String,
36 role: DatasetRole,
37 stats: laddu_runtime::PreparedDatasetStats,
38 quadratic_normalization: bool,
39 normalization: Option<PreparedNormalizationDiagnostics>,
40 source_traversals: u64,
41}
42
43impl DatasetDiagnostics {
44 pub fn term(&self) -> &str {
46 &self.term
47 }
48
49 pub fn role(&self) -> DatasetRole {
51 self.role
52 }
53
54 pub fn stats(&self) -> &laddu_runtime::PreparedDatasetStats {
56 &self.stats
57 }
58
59 pub fn uses_quadratic_normalization(&self) -> bool {
63 self.quadratic_normalization
64 }
65
66 pub fn normalization(&self) -> Option<&PreparedNormalizationDiagnostics> {
68 self.normalization.as_ref()
69 }
70
71 pub fn source_traversals(&self) -> u64 {
73 self.source_traversals
74 }
75}
76
77#[derive(Clone, Debug, PartialEq)]
79pub struct LikelihoodDiagnostics {
80 datasets: Vec<DatasetDiagnostics>,
81 objective_evaluations: u64,
82 gradient_evaluations: u64,
83 memory_decisions: Vec<laddu_runtime::MemoryDecision>,
84}
85
86impl LikelihoodDiagnostics {
87 pub fn datasets(&self) -> &[DatasetDiagnostics] {
89 &self.datasets
90 }
91
92 pub fn objective_evaluations(&self) -> u64 {
94 self.objective_evaluations
95 }
96
97 pub fn gradient_evaluations(&self) -> u64 {
99 self.gradient_evaluations
100 }
101
102 pub fn memory_decisions(&self) -> &[laddu_runtime::MemoryDecision] {
104 &self.memory_decisions
105 }
106}
107
108#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct LikelihoodName(String);
111
112impl LikelihoodName {
113 pub fn new(name: impl Into<String>) -> Self {
115 Self(name.into())
116 }
117
118 pub fn as_str(&self) -> &str {
120 &self.0
121 }
122}
123
124#[derive(Clone, Debug, PartialEq)]
126pub struct LikelihoodEvaluation {
127 value: f64,
128 gradient: Vec<f64>,
129}
130
131impl LikelihoodEvaluation {
132 pub fn new(value: f64, gradient: Vec<f64>) -> Self {
134 Self { value, gradient }
135 }
136
137 pub fn value(&self) -> f64 {
139 self.value
140 }
141
142 pub fn gradient(&self) -> &[f64] {
144 &self.gradient
145 }
146
147 pub fn into_parts(self) -> (f64, Vec<f64>) {
149 (self.value, self.gradient)
150 }
151}
152
153pub trait Objective: Debug + Send + Sync {
159 fn parameter_layout(&self) -> &ParamLayout;
161 fn value(&self, free_parameters: &[f64]) -> LikelihoodResult<f64>;
168 fn value_gradient(&self, free_parameters: &[f64]) -> LikelihoodResult<LikelihoodEvaluation>;
175}
176
177pub trait StochasticObjective: Objective {
182 fn stochastic_value_gradient(
189 &self,
190 free_parameters: &[f64],
191 fraction: f64,
192 seed: u64,
193 ) -> LikelihoodResult<LikelihoodEvaluation>;
194}
195
196pub trait LikelihoodTerm: Debug + Send + Sync {
198 fn name(&self) -> &str;
200
201 fn append_diagnostics(&self, _diagnostics: &mut Vec<DatasetDiagnostics>) {}
203
204 fn bootstrap_clone_is_prepared(&self) -> bool {
206 false
207 }
208
209 fn bootstrap_clone(&self, _seed: u64) -> LikelihoodResult<Box<dyn LikelihoodTerm>> {
219 Err(LikelihoodError::Runtime(RuntimeError::InvalidShape {
220 index: 0,
221 message: format!("term `{}` does not support bootstrap cloning", self.name()),
222 }))
223 }
224
225 fn register_params(&self, _registry: &mut ParamRegistry) -> LikelihoodResult<()> {
232 Ok(())
233 }
234
235 fn resolve(
242 &mut self,
243 global_params: Arc<ParamLayout>,
244 execution: &Execution,
245 ) -> LikelihoodResult<()>;
246
247 fn nll(&self, params: &ParamValues, execution: &Execution) -> LikelihoodResult<f64>;
254
255 fn nll_with_gradient(
262 &self,
263 params: &ParamValues,
264 gradient: &mut [f64],
265 execution: &Execution,
266 ) -> LikelihoodResult<f64> {
267 let layout = params.layout();
268 if gradient.len() != layout.n_free() {
269 return Err(LikelihoodError::GradientLengthMismatch {
270 expected: layout.n_free(),
271 actual: gradient.len(),
272 });
273 }
274
275 let value = self.nll(params, execution)?;
276 for (free_index, id) in layout.free_params().iter().copied().enumerate() {
277 let parameter = layout.spec(id)?;
278 let free_id = layout
279 .free_id(id)?
280 .ok_or(LikelihoodError::ParameterLayoutMismatch)?;
281 let center = params.get(id)?;
282 let scale = center.abs().max(1.0);
283 let base_step = f64::EPSILON.cbrt() * scale;
284 let bounds = parameter.bounds_spec();
285 let left_room = bounds
286 .min
287 .map_or(f64::INFINITY, |min| (center - min).max(0.0));
288 let right_room = bounds
289 .max
290 .map_or(f64::INFINITY, |max| (max - center).max(0.0));
291
292 let derivative = if left_room > 0.0 && right_room > 0.0 {
293 let step = base_step.min(left_room).min(right_room);
294 let mut plus = params.clone();
295 let mut minus = params.clone();
296 plus.set_free(free_id, center + step)?;
297 minus.set_free(free_id, center - step)?;
298 (self.nll(&plus, execution)? - self.nll(&minus, execution)?) / (2.0 * step)
299 } else if right_room > 0.0 {
300 let step = base_step.min(right_room);
301 let mut plus = params.clone();
302 plus.set_free(free_id, center + step)?;
303 (self.nll(&plus, execution)? - value) / step
304 } else if left_room > 0.0 {
305 let step = base_step.min(left_room);
306 let mut minus = params.clone();
307 minus.set_free(free_id, center - step)?;
308 (value - self.nll(&minus, execution)?) / step
309 } else {
310 0.0
311 };
312 gradient[free_index] += derivative;
313 }
314 Ok(value)
315 }
316
317 fn stochastic_nll_with_gradient(
325 &self,
326 params: &ParamValues,
327 gradient: &mut [f64],
328 execution: &Execution,
329 _fraction: f64,
330 _seed: u64,
331 ) -> LikelihoodResult<f64> {
332 self.nll_with_gradient(params, gradient, execution)
333 }
334
335 fn as_intensity(&self) -> Option<&NllTerm> {
337 None
338 }
339
340 fn has_absolute_rate(&self) -> bool {
346 false
347 }
348
349 fn boxed(self) -> Box<dyn LikelihoodTerm>
351 where
352 Self: Sized + 'static,
353 {
354 Box::new(self)
355 }
356}
357
358pub enum Parameters<'a> {
360 Slice(&'a [f64]),
362 ParamValues(&'a ParamValues),
364}
365
366impl<'a> From<&'a [f64]> for Parameters<'a> {
367 fn from(val: &'a [f64]) -> Self {
368 Self::Slice(val)
369 }
370}
371
372impl<'a, const N: usize> From<&'a [f64; N]> for Parameters<'a> {
373 fn from(val: &'a [f64; N]) -> Self {
374 Self::Slice(val.as_slice())
375 }
376}
377
378impl<'a> From<&'a Vec<f64>> for Parameters<'a> {
379 fn from(value: &'a Vec<f64>) -> Self {
380 Self::Slice(value.as_slice())
381 }
382}
383
384impl<'a> From<&'a ParamValues> for Parameters<'a> {
385 fn from(val: &'a ParamValues) -> Self {
386 Self::ParamValues(val)
387 }
388}
389
390#[derive(Debug)]
392pub struct Likelihood {
393 params: Arc<ParamLayout>,
394 terms: Vec<Box<dyn LikelihoodTerm>>,
395 execution: Execution,
396 objective_evaluations: AtomicU64,
397 gradient_evaluations: AtomicU64,
398}
399
400impl Likelihood {
401 pub fn new<T>(terms: impl IntoIterator<Item = T>) -> LikelihoodResult<Self>
411 where
412 T: LikelihoodTerm + 'static,
413 {
414 Self::with_execution(terms, &Execution::default())
415 }
416
417 pub fn new_boxed(
424 terms: impl IntoIterator<Item = Box<dyn LikelihoodTerm>>,
425 ) -> LikelihoodResult<Self> {
426 Self::with_execution_boxed(terms, &Execution::default())
427 }
428
429 pub fn with_execution<T>(
436 terms: impl IntoIterator<Item = T>,
437 execution: &Execution,
438 ) -> LikelihoodResult<Self>
439 where
440 T: LikelihoodTerm + 'static,
441 {
442 Self::with_execution_boxed(
443 terms
444 .into_iter()
445 .map(|term| Box::new(term) as Box<dyn LikelihoodTerm>),
446 execution,
447 )
448 }
449
450 pub fn with_execution_boxed(
457 terms: impl IntoIterator<Item = Box<dyn LikelihoodTerm>>,
458 execution: &Execution,
459 ) -> LikelihoodResult<Self> {
460 let mut terms: Vec<_> = terms.into_iter().collect();
461 let mut names = HashSet::new();
462 let mut registry = ParamRegistry::new();
463
464 for term in &terms {
465 if !names.insert(term.name().to_owned()) {
466 return Err(LikelihoodError::DuplicateTermName(term.name().to_owned()));
467 }
468 term.register_params(&mut registry)?;
469 }
470
471 let params = Arc::new(registry.layout()?);
472 for term in &mut terms {
473 term.resolve(Arc::clone(¶ms), execution)?;
474 }
475
476 Ok(Self {
477 params,
478 terms,
479 execution: execution.clone(),
480 objective_evaluations: AtomicU64::new(0),
481 gradient_evaluations: AtomicU64::new(0),
482 })
483 }
484
485 pub fn params(&self) -> &ParamLayout {
487 &self.params
488 }
489
490 pub fn default_params(&self) -> Vec<f64> {
492 self.params.initial_free_values()
493 }
494
495 pub fn params_with(
497 &self,
498 value: impl FnMut(&laddu_expr::parameters::Parameter) -> f64,
499 ) -> Vec<f64> {
500 self.params.free_values_with(value)
501 }
502
503 pub fn sample_initial(&self, seed: u64) -> Vec<f64> {
505 self.params.sample_initial(seed)
506 }
507
508 pub fn bootstrap(&self, seed: u64) -> LikelihoodResult<Self> {
515 let clones_are_prepared = self
516 .terms
517 .iter()
518 .all(|term| term.bootstrap_clone_is_prepared());
519 let terms = self
520 .terms
521 .iter()
522 .enumerate()
523 .map(|(index, term)| {
524 term.bootstrap_clone(
525 seed.wrapping_add((index as u64).wrapping_mul(0x9E3779B97F4A7C15)),
526 )
527 })
528 .collect::<LikelihoodResult<Vec<_>>>()?;
529 if clones_are_prepared {
530 Ok(Self {
531 params: Arc::clone(&self.params),
532 terms,
533 execution: self.execution.clone(),
534 objective_evaluations: AtomicU64::new(0),
535 gradient_evaluations: AtomicU64::new(0),
536 })
537 } else {
538 Self::with_execution_boxed(terms, &self.execution)
539 }
540 }
541
542 pub fn terms(&self) -> &[Box<dyn LikelihoodTerm>] {
544 &self.terms
545 }
546
547 pub fn execution(&self) -> &Execution {
549 &self.execution
550 }
551
552 pub fn diagnostics(&self) -> LikelihoodDiagnostics {
554 let mut datasets = Vec::new();
555 for term in &self.terms {
556 term.append_diagnostics(&mut datasets);
557 }
558 LikelihoodDiagnostics {
559 datasets,
560 objective_evaluations: self.objective_evaluations.load(Ordering::Relaxed),
561 gradient_evaluations: self.gradient_evaluations.load(Ordering::Relaxed),
562 memory_decisions: self.execution.memory_decisions(),
563 }
564 }
565
566 pub fn nll<'a>(&self, parameters: impl Into<Parameters<'a>>) -> LikelihoodResult<f64> {
573 let params = match parameters.into() {
574 Parameters::Slice(free) => &self.params.values(free)?,
575 Parameters::ParamValues(param_values) => param_values,
576 };
577 self.nll_values(params)
578 }
579
580 fn nll_values(&self, params: &ParamValues) -> LikelihoodResult<f64> {
581 self.objective_evaluations.fetch_add(1, Ordering::Relaxed);
582 check_params(&self.params, params)?;
583 self.terms.iter().try_fold(
584 0.0,
585 |sum, term| Ok(sum + term.nll(params, &self.execution)?),
586 )
587 }
588
589 pub fn nll_with_gradient<'a>(
596 &self,
597 parameters: impl Into<Parameters<'a>>,
598 ) -> LikelihoodResult<LikelihoodEvaluation> {
599 let params = match parameters.into() {
600 Parameters::Slice(free) => &self.params.values(free)?,
601 Parameters::ParamValues(param_values) => param_values,
602 };
603 self.nll_with_gradient_values(params)
604 }
605
606 fn nll_with_gradient_values(
607 &self,
608 params: &ParamValues,
609 ) -> LikelihoodResult<LikelihoodEvaluation> {
610 self.gradient_evaluations.fetch_add(1, Ordering::Relaxed);
611 check_params(&self.params, params)?;
612 let mut gradient = vec![0.0; self.params.n_free()];
613 let value = self.terms.iter().try_fold(0.0, |sum, term| {
614 Ok::<_, LikelihoodError>(
615 sum + term.nll_with_gradient(params, &mut gradient, &self.execution)?,
616 )
617 })?;
618 Ok(LikelihoodEvaluation { value, gradient })
619 }
620
621 pub fn stochastic_nll_with_gradient(
628 &self,
629 free_parameters: &[f64],
630 fraction: f64,
631 seed: u64,
632 ) -> LikelihoodResult<LikelihoodEvaluation> {
633 self.gradient_evaluations.fetch_add(1, Ordering::Relaxed);
634 if !(fraction > 0.0 && fraction <= 1.0) {
635 return Err(LikelihoodError::InvalidBatchFraction(fraction));
636 }
637 let params = self.params.values(free_parameters)?;
638 let mut gradient = vec![0.0; self.params.n_free()];
639 let value = self
640 .terms
641 .iter()
642 .enumerate()
643 .try_fold(0.0, |sum, (term_index, term)| {
644 let term_seed =
645 seed.wrapping_add((term_index as u64).wrapping_mul(0x9E3779B97F4A7C15));
646 Ok::<_, LikelihoodError>(
647 sum + term.stochastic_nll_with_gradient(
648 ¶ms,
649 &mut gradient,
650 &self.execution,
651 fraction,
652 term_seed,
653 )?,
654 )
655 })?;
656 Ok(LikelihoodEvaluation::new(value, gradient))
657 }
658
659 pub fn cross_section_integrals(
666 &self,
667 term_name: &str,
668 generated_mc: &Dataset,
669 ) -> LikelihoodResult<CrossSectionIntegrals> {
670 let Some(term) = self.terms.iter().find(|term| term.name() == term_name) else {
671 return Err(LikelihoodError::MissingTerm(term_name.to_owned()));
672 };
673 let has_absolute_rate = term.has_absolute_rate();
674 let Some(term) = term.as_intensity() else {
675 return Err(LikelihoodError::NotIntensityTerm(term_name.to_owned()));
676 };
677 term.cross_section_integrals(generated_mc, &self.execution, has_absolute_rate)
678 }
679
680 pub fn cross_section_integrals_with_tags<'a>(
691 &self,
692 term_name: &str,
693 generated_mc: &Dataset,
694 tags: impl IntoIterator<Item = &'a str>,
695 ) -> LikelihoodResult<CrossSectionIntegrals> {
696 let Some(term) = self.terms.iter().find(|term| term.name() == term_name) else {
697 return Err(LikelihoodError::MissingTerm(term_name.to_owned()));
698 };
699 let has_absolute_rate = term.has_absolute_rate();
700 let Some(term) = term.as_intensity() else {
701 return Err(LikelihoodError::NotIntensityTerm(term_name.to_owned()));
702 };
703 term.cross_section_integrals_with_tags(
704 generated_mc,
705 tags,
706 &self.execution,
707 has_absolute_rate,
708 )
709 }
710
711 pub fn intensity_datasets(&self, term_name: &str) -> LikelihoodResult<(&Dataset, &Dataset)> {
718 let Some(term) = self.terms.iter().find(|term| term.name() == term_name) else {
719 return Err(LikelihoodError::MissingTerm(term_name.to_owned()));
720 };
721 let Some(term) = term.as_intensity() else {
722 return Err(LikelihoodError::NotIntensityTerm(term_name.to_owned()));
723 };
724 Ok((&term.data_source, &term.accepted_mc_source))
725 }
726
727 pub fn projection<'a>(
734 &self,
735 term_name: &str,
736 generated_mc: &Dataset,
737 tags: impl IntoIterator<Item = &'a str>,
738 ) -> LikelihoodResult<LikelihoodProjection> {
739 let Some(term) = self.terms.iter().find(|term| term.name() == term_name) else {
740 return Err(LikelihoodError::MissingTerm(term_name.to_owned()));
741 };
742 let has_absolute_rate = term.has_absolute_rate();
743 let Some(term) = term.as_intensity() else {
744 return Err(LikelihoodError::NotIntensityTerm(term_name.to_owned()));
745 };
746 term.projection(generated_mc, tags, &self.execution, has_absolute_rate)
747 }
748}
749
750impl Objective for Likelihood {
751 fn parameter_layout(&self) -> &ParamLayout {
752 self.params()
753 }
754
755 fn value(&self, free_parameters: &[f64]) -> LikelihoodResult<f64> {
756 self.nll(free_parameters)
757 }
758
759 fn value_gradient(&self, free_parameters: &[f64]) -> LikelihoodResult<LikelihoodEvaluation> {
760 self.nll_with_gradient(free_parameters)
761 }
762}
763
764impl StochasticObjective for Likelihood {
765 fn stochastic_value_gradient(
766 &self,
767 free_parameters: &[f64],
768 fraction: f64,
769 seed: u64,
770 ) -> LikelihoodResult<LikelihoodEvaluation> {
771 self.stochastic_nll_with_gradient(free_parameters, fraction, seed)
772 }
773}
774
775#[derive(Clone)]
776pub struct NllTerm {
778 name: LikelihoodName,
779 model: CompiledModel,
780 local_params: Arc<ParamLayout>,
781 data_source: Dataset,
782 accepted_mc_source: Dataset,
783 state: NllState,
784}
785
786#[derive(Clone)]
787enum NllState {
788 Unresolved,
789 Prepared(Box<PreparedNll>),
790}
791
792#[derive(Clone)]
793struct PreparedNll {
794 plan: PreparedModel,
795 projection: ParamProjection,
796 data: PreparedDataset,
797 normalization: NormalizationData,
798 data_weight_sum: f64,
799 execution: Execution,
800}
801
802#[derive(Clone)]
803enum NormalizationData {
804 PreparedDataset(Box<PreparedDataset>),
805 CompilerNative(Arc<PreparedNormalization>),
806}
807
808impl std::fmt::Debug for NllTerm {
809 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
810 formatter
811 .debug_struct("NllTerm")
812 .field("name", &self.name)
813 .field("prepared", &matches!(self.state, NllState::Prepared(_)))
814 .finish_non_exhaustive()
815 }
816}
817
818impl NllTerm {
819 fn bootstrap_term(&self, seed: u64) -> LikelihoodResult<Self> {
820 let data_source = self.data_source.clone().bootstrap(seed);
821 let state = match &self.state {
822 NllState::Unresolved => NllState::Unresolved,
823 NllState::Prepared(prepared) => {
824 let data = prepared
825 .plan
826 .prepare_dataset(&prepared.execution, &data_source)?;
827 NllState::Prepared(Box::new(PreparedNll {
828 plan: prepared.plan.clone(),
829 projection: prepared.projection.clone(),
830 data_weight_sum: data.stats().sum_weights(),
831 data,
832 normalization: prepared.normalization.clone(),
833 execution: prepared.execution.clone(),
834 }))
835 }
836 };
837 Ok(Self {
838 name: self.name.clone(),
839 model: self.model.clone(),
840 local_params: Arc::clone(&self.local_params),
841 data_source,
842 accepted_mc_source: self.accepted_mc_source.clone(),
843 state,
844 })
845 }
846 fn projection<'a>(
847 &self,
848 generated_mc: &Dataset,
849 tags: impl IntoIterator<Item = &'a str>,
850 execution: &Execution,
851 has_absolute_rate: bool,
852 ) -> LikelihoodResult<LikelihoodProjection> {
853 let projected_model =
854 self.model
855 .project_tags(tags)
856 .map_err(|error| RuntimeError::InvalidShape {
857 index: 0,
858 message: error.to_string(),
859 })?;
860 let projected_plan = PreparedModel::prepare(&projected_model, execution)?;
861 let projected_normalization = PreparedNormalization::prepare(
862 &projected_model,
863 &projected_plan,
864 &self.accepted_mc_source,
865 execution,
866 )?;
867 let projected_params = ParamProjection::new(
868 Arc::clone(&self.resolved_projection()?.global_layout),
869 projected_model.params(),
870 self.name(),
871 )?;
872 Ok(LikelihoodProjection {
873 name: self.name.clone(),
874 full_plan: self.plan()?.clone(),
875 full_projection: self.resolved_projection()?.clone(),
876 full_accepted_mc: self.accepted_mc_for_analysis(execution)?,
877 full_normalization: self.compiler_native_normalization()?,
878 projected_accepted_mc: projected_plan
879 .prepare_dataset(execution, &self.accepted_mc_source)?,
880 projected_normalization,
881 projected_generated_mc: projected_plan.prepare_dataset(execution, generated_mc)?,
882 accepted_mc_source: self.accepted_mc_source.clone(),
883 generated_mc_source: generated_mc.clone(),
884 projected_plan,
885 projected_params,
886 data_weight_sum: self.data_weight_sum()?,
887 has_absolute_rate,
888 execution: execution.clone(),
889 })
890 }
891
892 pub fn new(
899 name: impl Into<String>,
900 model: &CompiledModel,
901 data: &Dataset,
902 accepted_mc: &Dataset,
903 ) -> LikelihoodResult<Self> {
904 Ok(Self {
905 name: LikelihoodName::new(name),
906 model: model.clone(),
907 local_params: Arc::new(model.params().clone()),
908 data_source: data.clone(),
909 accepted_mc_source: accepted_mc.clone(),
910 state: NllState::Unresolved,
911 })
912 }
913
914 pub fn data(&self) -> LikelihoodResult<&PreparedDataset> {
921 match &self.state {
922 NllState::Prepared(prepared) => Ok(&prepared.data),
923 NllState::Unresolved => Err(LikelihoodError::UnresolvedTerm(self.name().to_owned())),
924 }
925 }
926
927 fn plan(&self) -> LikelihoodResult<&PreparedModel> {
928 match &self.state {
929 NllState::Prepared(prepared) => Ok(&prepared.plan),
930 NllState::Unresolved => Err(LikelihoodError::UnresolvedTerm(self.name().to_owned())),
931 }
932 }
933
934 pub fn accepted_mc(&self) -> LikelihoodResult<&PreparedDataset> {
941 match &self.state {
942 NllState::Prepared(prepared) => match &prepared.normalization {
943 NormalizationData::PreparedDataset(accepted_mc) => Ok(accepted_mc),
944 NormalizationData::CompilerNative(_) => {
945 Err(LikelihoodError::UnresolvedTerm(self.name().to_owned()))
946 }
947 },
948 NllState::Unresolved => Err(LikelihoodError::UnresolvedTerm(self.name().to_owned())),
949 }
950 }
951
952 fn accepted_mc_for_analysis(&self, execution: &Execution) -> LikelihoodResult<PreparedDataset> {
953 match &self.state {
954 NllState::Prepared(prepared) => match &prepared.normalization {
955 NormalizationData::PreparedDataset(accepted_mc) => Ok((**accepted_mc).clone()),
956 NormalizationData::CompilerNative(_) => Ok(prepared
957 .plan
958 .prepare_dataset(execution, &self.accepted_mc_source)?),
959 },
960 NllState::Unresolved => Err(LikelihoodError::UnresolvedTerm(self.name().to_owned())),
961 }
962 }
963
964 pub fn data_weight_sum(&self) -> LikelihoodResult<f64> {
971 match &self.state {
972 NllState::Prepared(prepared) => Ok(prepared.data_weight_sum),
973 NllState::Unresolved => Err(LikelihoodError::UnresolvedTerm(self.name().to_owned())),
974 }
975 }
976
977 pub fn data_log_intensity_sum(&self, free: &[f64]) -> LikelihoodResult<f64> {
984 let params = self.global_values(free)?;
985 let local_params = self.local_values(¶ms)?;
986 self.reduce(
987 &local_params,
988 self.data()?,
989 ReductionPlan::weighted_log_positive_real(),
990 "data",
991 )
992 }
993
994 pub fn accepted_normalization(&self, free: &[f64]) -> LikelihoodResult<f64> {
1001 let params = self.global_values(free)?;
1002 let local_params = self.local_values(¶ms)?;
1003 self.normalization_value(&local_params, self.resolved_execution()?)
1004 }
1005
1006 fn cross_section_integrals(
1007 &self,
1008 generated_mc: &Dataset,
1009 execution: &Execution,
1010 has_absolute_rate: bool,
1011 ) -> LikelihoodResult<CrossSectionIntegrals> {
1012 let plan = self.plan()?.clone();
1013 let accepted_mc = self.accepted_mc_for_analysis(execution)?;
1014 Ok(CrossSectionIntegrals {
1015 name: self.name.clone(),
1016 full_plan: plan.clone(),
1017 full_projection: self.resolved_projection()?.clone(),
1018 full_accepted_mc: accepted_mc.clone(),
1019 full_normalization: self.compiler_native_normalization()?,
1020 accepted_mc_source: self.accepted_mc_source.clone(),
1021 generated_mc_source: generated_mc.clone(),
1022 plan: plan.clone(),
1023 projection: self.resolved_projection()?.clone(),
1024 accepted_mc,
1025 normalization: self.compiler_native_normalization()?,
1026 generated_mc: plan.prepare_dataset(execution, generated_mc)?,
1027 data_weight_sum: self.data_weight_sum()?,
1028 has_absolute_rate,
1029 execution: execution.clone(),
1030 })
1031 }
1032
1033 fn cross_section_integrals_with_tags<'a>(
1034 &self,
1035 generated_mc: &Dataset,
1036 tags: impl IntoIterator<Item = &'a str>,
1037 execution: &Execution,
1038 has_absolute_rate: bool,
1039 ) -> LikelihoodResult<CrossSectionIntegrals> {
1040 let projection = self.projection(generated_mc, tags, execution, has_absolute_rate)?;
1041 Ok(CrossSectionIntegrals {
1042 name: projection.name,
1043 full_plan: projection.full_plan,
1044 full_projection: projection.full_projection,
1045 full_accepted_mc: projection.full_accepted_mc,
1046 full_normalization: projection.full_normalization,
1047 accepted_mc_source: projection.accepted_mc_source,
1048 generated_mc_source: projection.generated_mc_source,
1049 plan: projection.projected_plan,
1050 projection: projection.projected_params,
1051 accepted_mc: projection.projected_accepted_mc,
1052 normalization: projection.projected_normalization,
1053 generated_mc: projection.projected_generated_mc,
1054 data_weight_sum: projection.data_weight_sum,
1055 has_absolute_rate: projection.has_absolute_rate,
1056 execution: projection.execution,
1057 })
1058 }
1059
1060 fn normalization_value(
1061 &self,
1062 params: &ParamValues,
1063 execution: &Execution,
1064 ) -> LikelihoodResult<f64> {
1065 match &self.state {
1066 NllState::Prepared(prepared) => {
1067 if let NormalizationData::CompilerNative(normalization) = &prepared.normalization {
1068 return normalization
1069 .value(params, execution)
1070 .map_err(LikelihoodError::from);
1071 }
1072 }
1073 NllState::Unresolved => {
1074 return Err(LikelihoodError::UnresolvedTerm(self.name().to_owned()));
1075 }
1076 }
1077 self.plan()?
1078 .reduce(
1079 execution,
1080 params,
1081 self.accepted_mc()?,
1082 ReductionPlan::weighted_positive_real(),
1083 )
1084 .map_err(|error| map_reduction_error("accepted MC", error))
1085 }
1086
1087 fn normalization_with_gradient(
1088 &self,
1089 params: &ParamValues,
1090 execution: &Execution,
1091 ) -> LikelihoodResult<(f64, Vec<f64>)> {
1092 match &self.state {
1093 NllState::Prepared(prepared) => {
1094 if let NormalizationData::CompilerNative(normalization) = &prepared.normalization {
1095 return normalization
1096 .value_gradient(params, execution)
1097 .map_err(LikelihoodError::from);
1098 }
1099 }
1100 NllState::Unresolved => {
1101 return Err(LikelihoodError::UnresolvedTerm(self.name().to_owned()));
1102 }
1103 }
1104 Ok(self
1105 .plan()?
1106 .reduce_with_gradient(
1107 execution,
1108 params,
1109 self.accepted_mc()?,
1110 ReductionPlan::weighted_positive_real(),
1111 )
1112 .map_err(|error| map_reduction_error("accepted MC", error))?
1113 .into_parts())
1114 }
1115
1116 fn reduce(
1117 &self,
1118 params: &ParamValues,
1119 dataset: &PreparedDataset,
1120 reduction: ReductionPlan,
1121 name: &'static str,
1122 ) -> LikelihoodResult<f64> {
1123 self.plan()?
1124 .reduce(self.resolved_execution()?, params, dataset, reduction)
1125 .map_err(|error| map_reduction_error(name, error))
1126 }
1127
1128 fn stochastic_data_evaluation(
1129 &self,
1130 params: &ParamValues,
1131 execution: &Execution,
1132 fraction: f64,
1133 seed: u64,
1134 ) -> LikelihoodResult<(f64, Vec<f64>)> {
1135 let selected = self.data_source.clone().subsample(fraction, seed)?;
1136 let prepared = self.plan()?.prepare_dataset(execution, &selected)?;
1137 let evaluation = self
1138 .plan()?
1139 .reduce_with_gradient(
1140 execution,
1141 params,
1142 &prepared,
1143 ReductionPlan::weighted_log_positive_real(),
1144 )
1145 .map_err(|error| map_reduction_error("data batch", error))?;
1146 let (value, gradient) = evaluation.into_parts();
1147 Ok((
1148 value / fraction,
1149 gradient.into_iter().map(|value| value / fraction).collect(),
1150 ))
1151 }
1152
1153 fn local_values(&self, params: &ParamValues) -> LikelihoodResult<ParamValues> {
1154 self.resolved_projection()?.project(params)
1155 }
1156
1157 fn global_values(&self, free: &[f64]) -> LikelihoodResult<ParamValues> {
1158 Ok(self.resolved_projection()?.global_layout.values(free)?)
1159 }
1160
1161 fn resolved_projection(&self) -> LikelihoodResult<&ParamProjection> {
1162 match &self.state {
1163 NllState::Prepared(prepared) => Ok(&prepared.projection),
1164 NllState::Unresolved => Err(LikelihoodError::UnresolvedTerm(self.name().to_owned())),
1165 }
1166 }
1167
1168 fn resolved_execution(&self) -> LikelihoodResult<&Execution> {
1169 match &self.state {
1170 NllState::Prepared(prepared) => Ok(&prepared.execution),
1171 NllState::Unresolved => Err(LikelihoodError::UnresolvedTerm(self.name().to_owned())),
1172 }
1173 }
1174
1175 fn compiler_native_normalization(
1176 &self,
1177 ) -> LikelihoodResult<Option<Arc<PreparedNormalization>>> {
1178 match &self.state {
1179 NllState::Prepared(prepared) => match &prepared.normalization {
1180 NormalizationData::CompilerNative(normalization) => {
1181 Ok(Some(Arc::clone(normalization)))
1182 }
1183 NormalizationData::PreparedDataset(_) => Ok(None),
1184 },
1185 NllState::Unresolved => Err(LikelihoodError::UnresolvedTerm(self.name().to_owned())),
1186 }
1187 }
1188}
1189
1190impl LikelihoodTerm for NllTerm {
1191 fn name(&self) -> &str {
1192 self.name.as_str()
1193 }
1194
1195 fn append_diagnostics(&self, diagnostics: &mut Vec<DatasetDiagnostics>) {
1196 let NllState::Prepared(prepared) = &self.state else {
1197 return;
1198 };
1199 diagnostics.push(DatasetDiagnostics {
1200 term: self.name.as_str().to_owned(),
1201 role: DatasetRole::Observed,
1202 stats: *prepared.data.stats(),
1203 quadratic_normalization: false,
1204 normalization: None,
1205 source_traversals: self.data_source.source_traversals(),
1206 });
1207 match &prepared.normalization {
1208 NormalizationData::CompilerNative(normalization) => {
1209 diagnostics.push(DatasetDiagnostics {
1210 term: self.name.as_str().to_owned(),
1211 role: DatasetRole::AcceptedMc,
1212 stats: *normalization.stats(),
1213 quadratic_normalization: true,
1214 normalization: Some(normalization.diagnostics()),
1215 source_traversals: self.accepted_mc_source.source_traversals(),
1216 });
1217 }
1218 NormalizationData::PreparedDataset(accepted_mc) => {
1219 diagnostics.push(DatasetDiagnostics {
1220 term: self.name.as_str().to_owned(),
1221 role: DatasetRole::AcceptedMc,
1222 stats: *accepted_mc.stats(),
1223 quadratic_normalization: false,
1224 normalization: Some(PreparedNormalizationDiagnostics::general(
1225 self.model.normalization_diagnostics().clone(),
1226 )),
1227 source_traversals: self.accepted_mc_source.source_traversals(),
1228 });
1229 }
1230 }
1231 }
1232
1233 fn bootstrap_clone_is_prepared(&self) -> bool {
1234 matches!(self.state, NllState::Prepared(_))
1235 }
1236
1237 fn bootstrap_clone(&self, seed: u64) -> LikelihoodResult<Box<dyn LikelihoodTerm>> {
1238 Ok(Box::new(self.bootstrap_term(seed)?))
1239 }
1240
1241 fn register_params(&self, registry: &mut ParamRegistry) -> LikelihoodResult<()> {
1242 for spec in self.local_params.specs() {
1243 registry.register(spec.clone())?;
1244 }
1245 Ok(())
1246 }
1247
1248 fn resolve(
1249 &mut self,
1250 global_params: Arc<ParamLayout>,
1251 execution: &Execution,
1252 ) -> LikelihoodResult<()> {
1253 let projection = ParamProjection::new(global_params, &self.local_params, self.name())?;
1254 let plan = PreparedModel::prepare(&self.model, execution)?;
1255 let data = plan.prepare_dataset(execution, &self.data_source)?;
1256 let normalization = PreparedNormalization::prepare(
1257 &self.model,
1258 &plan,
1259 &self.accepted_mc_source,
1260 execution,
1261 )?;
1262 let normalization = if let Some(normalization) = normalization {
1263 NormalizationData::CompilerNative(normalization)
1264 } else {
1265 NormalizationData::PreparedDataset(Box::new(
1266 plan.prepare_dataset(execution, &self.accepted_mc_source)?,
1267 ))
1268 };
1269 let data_weight_sum = data.stats().sum_weights();
1270 self.state = NllState::Prepared(Box::new(PreparedNll {
1271 plan,
1272 projection,
1273 data,
1274 normalization,
1275 data_weight_sum,
1276 execution: execution.clone(),
1277 }));
1278 Ok(())
1279 }
1280
1281 fn nll(&self, params: &ParamValues, execution: &Execution) -> LikelihoodResult<f64> {
1282 let local_params = self.local_values(params)?;
1283 let normalization = positive_integral(
1284 "accepted MC",
1285 self.normalization_value(&local_params, execution)?,
1286 )?;
1287 let data_log_sum = self
1288 .plan()?
1289 .reduce(
1290 execution,
1291 &local_params,
1292 self.data()?,
1293 ReductionPlan::weighted_log_positive_real(),
1294 )
1295 .map_err(|error| map_reduction_error("data", error))?;
1296 Ok(self.data_weight_sum()? * normalization.ln() - data_log_sum)
1297 }
1298
1299 fn nll_with_gradient(
1300 &self,
1301 params: &ParamValues,
1302 gradient: &mut [f64],
1303 execution: &Execution,
1304 ) -> LikelihoodResult<f64> {
1305 let local_params = self.local_values(params)?;
1306 let (normalization, normalization_gradient) =
1307 self.normalization_with_gradient(&local_params, execution)?;
1308 let normalization = positive_integral("accepted MC", normalization)?;
1309 let data_evaluation = self
1310 .plan()?
1311 .reduce_with_gradient(
1312 execution,
1313 &local_params,
1314 self.data()?,
1315 ReductionPlan::weighted_log_positive_real(),
1316 )
1317 .map_err(|error| map_reduction_error("data", error))?;
1318 let (data_log_sum, data_log_gradient) = data_evaluation.into_parts();
1319 let data_weight_sum = self.data_weight_sum()?;
1320 let local_gradient = normalization_gradient
1321 .into_iter()
1322 .zip(data_log_gradient)
1323 .map(|(normalization_derivative, data_derivative)| {
1324 data_weight_sum * normalization_derivative / normalization - data_derivative
1325 })
1326 .collect::<Vec<_>>();
1327 self.resolved_projection()?
1328 .scatter_gradient(&local_gradient, gradient)?;
1329 Ok(data_weight_sum * normalization.ln() - data_log_sum)
1330 }
1331
1332 fn stochastic_nll_with_gradient(
1333 &self,
1334 params: &ParamValues,
1335 gradient: &mut [f64],
1336 execution: &Execution,
1337 fraction: f64,
1338 seed: u64,
1339 ) -> LikelihoodResult<f64> {
1340 let local_params = self.local_values(params)?;
1341 let (normalization, normalization_gradient) =
1342 self.normalization_with_gradient(&local_params, execution)?;
1343 let normalization = positive_integral("accepted MC", normalization)?;
1344 let (data_log_sum, data_log_gradient) =
1345 self.stochastic_data_evaluation(&local_params, execution, fraction, seed)?;
1346 let data_weight_sum = self.data_weight_sum()?;
1347 let local_gradient = normalization_gradient
1348 .into_iter()
1349 .zip(data_log_gradient)
1350 .map(|(normalization_derivative, data_derivative)| {
1351 data_weight_sum * normalization_derivative / normalization - data_derivative
1352 })
1353 .collect::<Vec<_>>();
1354 self.resolved_projection()?
1355 .scatter_gradient(&local_gradient, gradient)?;
1356 Ok(data_weight_sum * normalization.ln() - data_log_sum)
1357 }
1358
1359 fn as_intensity(&self) -> Option<&NllTerm> {
1360 Some(self)
1361 }
1362}
1363
1364#[derive(Clone)]
1369pub struct ExtendedNllTerm {
1370 inner: NllTerm,
1371}
1372
1373impl std::fmt::Debug for ExtendedNllTerm {
1374 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1375 formatter
1376 .debug_struct("ExtendedNllTerm")
1377 .field("name", &self.inner.name)
1378 .field("prepared", &self.inner.data().is_ok())
1379 .finish_non_exhaustive()
1380 }
1381}
1382
1383impl ExtendedNllTerm {
1384 pub fn new(
1391 name: impl Into<String>,
1392 model: &CompiledModel,
1393 data: &Dataset,
1394 accepted_mc: &Dataset,
1395 ) -> LikelihoodResult<Self> {
1396 Ok(Self {
1397 inner: NllTerm::new(name, model, data, accepted_mc)?,
1398 })
1399 }
1400
1401 pub fn data(&self) -> LikelihoodResult<&PreparedDataset> {
1408 self.inner.data()
1409 }
1410
1411 pub fn accepted_mc(&self) -> LikelihoodResult<&PreparedDataset> {
1418 self.inner.accepted_mc()
1419 }
1420
1421 pub fn data_weight_sum(&self) -> LikelihoodResult<f64> {
1428 self.inner.data_weight_sum()
1429 }
1430
1431 pub fn data_log_intensity_sum(&self, free: &[f64]) -> LikelihoodResult<f64> {
1438 self.inner.data_log_intensity_sum(free)
1439 }
1440
1441 pub fn accepted_normalization(&self, free: &[f64]) -> LikelihoodResult<f64> {
1448 self.inner.accepted_normalization(free)
1449 }
1450}
1451
1452impl LikelihoodTerm for ExtendedNllTerm {
1453 fn name(&self) -> &str {
1454 self.inner.name()
1455 }
1456
1457 fn append_diagnostics(&self, diagnostics: &mut Vec<DatasetDiagnostics>) {
1458 self.inner.append_diagnostics(diagnostics);
1459 }
1460
1461 fn bootstrap_clone_is_prepared(&self) -> bool {
1462 self.inner.bootstrap_clone_is_prepared()
1463 }
1464
1465 fn bootstrap_clone(&self, seed: u64) -> LikelihoodResult<Box<dyn LikelihoodTerm>> {
1466 Ok(Box::new(Self {
1467 inner: self.inner.bootstrap_term(seed)?,
1468 }))
1469 }
1470
1471 fn register_params(&self, registry: &mut ParamRegistry) -> LikelihoodResult<()> {
1472 self.inner.register_params(registry)
1473 }
1474
1475 fn resolve(
1476 &mut self,
1477 global_params: Arc<ParamLayout>,
1478 execution: &Execution,
1479 ) -> LikelihoodResult<()> {
1480 self.inner.resolve(global_params, execution)
1481 }
1482
1483 fn nll(&self, params: &ParamValues, execution: &Execution) -> LikelihoodResult<f64> {
1484 let local_params = self.inner.local_values(params)?;
1485 let normalization = positive_integral(
1486 "accepted MC",
1487 self.inner.normalization_value(&local_params, execution)?,
1488 )?;
1489 let data_log_sum = self
1490 .inner
1491 .plan()?
1492 .reduce(
1493 execution,
1494 &local_params,
1495 self.inner.data()?,
1496 ReductionPlan::weighted_log_positive_real(),
1497 )
1498 .map_err(|error| map_reduction_error("data", error))?;
1499 Ok(normalization - data_log_sum)
1500 }
1501
1502 fn nll_with_gradient(
1503 &self,
1504 params: &ParamValues,
1505 gradient: &mut [f64],
1506 execution: &Execution,
1507 ) -> LikelihoodResult<f64> {
1508 let local_params = self.inner.local_values(params)?;
1509 let (normalization, normalization_gradient) = self
1510 .inner
1511 .normalization_with_gradient(&local_params, execution)?;
1512 let normalization = positive_integral("accepted MC", normalization)?;
1513 let data_evaluation = self
1514 .inner
1515 .plan()?
1516 .reduce_with_gradient(
1517 execution,
1518 &local_params,
1519 self.inner.data()?,
1520 ReductionPlan::weighted_log_positive_real(),
1521 )
1522 .map_err(|error| map_reduction_error("data", error))?;
1523 let (data_log_sum, data_log_gradient) = data_evaluation.into_parts();
1524 let local_gradient = normalization_gradient
1525 .into_iter()
1526 .zip(data_log_gradient)
1527 .map(|(normalization_derivative, data_derivative)| {
1528 normalization_derivative - data_derivative
1529 })
1530 .collect::<Vec<_>>();
1531 self.inner
1532 .resolved_projection()?
1533 .scatter_gradient(&local_gradient, gradient)?;
1534 Ok(normalization - data_log_sum)
1535 }
1536
1537 fn stochastic_nll_with_gradient(
1538 &self,
1539 params: &ParamValues,
1540 gradient: &mut [f64],
1541 execution: &Execution,
1542 fraction: f64,
1543 seed: u64,
1544 ) -> LikelihoodResult<f64> {
1545 let local_params = self.inner.local_values(params)?;
1546 let (normalization, normalization_gradient) = self
1547 .inner
1548 .normalization_with_gradient(&local_params, execution)?;
1549 let normalization = positive_integral("accepted MC", normalization)?;
1550 let (data_log_sum, data_log_gradient) =
1551 self.inner
1552 .stochastic_data_evaluation(&local_params, execution, fraction, seed)?;
1553 let local_gradient = normalization_gradient
1554 .into_iter()
1555 .zip(data_log_gradient)
1556 .map(|(normalization_derivative, data_derivative)| {
1557 normalization_derivative - data_derivative
1558 })
1559 .collect::<Vec<_>>();
1560 self.inner
1561 .resolved_projection()?
1562 .scatter_gradient(&local_gradient, gradient)?;
1563 Ok(normalization - data_log_sum)
1564 }
1565
1566 fn as_intensity(&self) -> Option<&NllTerm> {
1567 Some(&self.inner)
1568 }
1569
1570 fn has_absolute_rate(&self) -> bool {
1571 true
1572 }
1573}
1574
1575#[derive(Clone, Debug)]
1577pub struct RidgePenalty {
1578 inner: CpuParameterPenalty,
1579}
1580
1581impl RidgePenalty {
1582 pub fn new(
1589 name: impl Into<String>,
1590 parameter_names: impl IntoIterator<Item = impl Into<String>>,
1591 lambda: f64,
1592 ) -> LikelihoodResult<Self> {
1593 Ok(Self {
1594 inner: CpuParameterPenalty::new(name, parameter_names, lambda, PenaltyKind::Ridge)?,
1595 })
1596 }
1597}
1598
1599impl LikelihoodTerm for RidgePenalty {
1600 fn name(&self) -> &str {
1601 self.inner.name()
1602 }
1603
1604 fn bootstrap_clone(&self, _seed: u64) -> LikelihoodResult<Box<dyn LikelihoodTerm>> {
1605 Ok(Box::new(self.clone()))
1606 }
1607
1608 fn bootstrap_clone_is_prepared(&self) -> bool {
1609 self.inner.global_params.is_some()
1610 }
1611
1612 fn resolve(
1613 &mut self,
1614 global_params: Arc<ParamLayout>,
1615 _execution: &Execution,
1616 ) -> LikelihoodResult<()> {
1617 self.inner.resolve(global_params)
1618 }
1619
1620 fn nll(&self, params: &ParamValues, _execution: &Execution) -> LikelihoodResult<f64> {
1621 self.inner.nll(params)
1622 }
1623
1624 fn nll_with_gradient(
1625 &self,
1626 params: &ParamValues,
1627 gradient: &mut [f64],
1628 _execution: &Execution,
1629 ) -> LikelihoodResult<f64> {
1630 self.inner.nll_with_gradient(params, gradient)
1631 }
1632}
1633
1634#[derive(Clone, Debug)]
1636pub struct LassoPenalty {
1637 inner: CpuParameterPenalty,
1638}
1639
1640impl LassoPenalty {
1641 pub fn new(
1648 name: impl Into<String>,
1649 parameter_names: impl IntoIterator<Item = impl Into<String>>,
1650 lambda: f64,
1651 ) -> LikelihoodResult<Self> {
1652 Ok(Self {
1653 inner: CpuParameterPenalty::new(name, parameter_names, lambda, PenaltyKind::Lasso)?,
1654 })
1655 }
1656}
1657
1658impl LikelihoodTerm for LassoPenalty {
1659 fn name(&self) -> &str {
1660 self.inner.name()
1661 }
1662
1663 fn bootstrap_clone(&self, _seed: u64) -> LikelihoodResult<Box<dyn LikelihoodTerm>> {
1664 Ok(Box::new(self.clone()))
1665 }
1666
1667 fn bootstrap_clone_is_prepared(&self) -> bool {
1668 self.inner.global_params.is_some()
1669 }
1670
1671 fn resolve(
1672 &mut self,
1673 global_params: Arc<ParamLayout>,
1674 _execution: &Execution,
1675 ) -> LikelihoodResult<()> {
1676 self.inner.resolve(global_params)
1677 }
1678
1679 fn nll(&self, params: &ParamValues, _execution: &Execution) -> LikelihoodResult<f64> {
1680 self.inner.nll(params)
1681 }
1682
1683 fn nll_with_gradient(
1684 &self,
1685 params: &ParamValues,
1686 gradient: &mut [f64],
1687 _execution: &Execution,
1688 ) -> LikelihoodResult<f64> {
1689 self.inner.nll_with_gradient(params, gradient)
1690 }
1691}
1692
1693#[derive(Clone, Debug)]
1694struct CpuParameterPenalty {
1695 name: LikelihoodName,
1696 parameter_names: Vec<String>,
1697 parameter_ids: Vec<ParamId>,
1698 global_params: Option<Arc<ParamLayout>>,
1699 lambda: f64,
1700 kind: PenaltyKind,
1701}
1702
1703impl CpuParameterPenalty {
1704 fn new(
1705 name: impl Into<String>,
1706 parameter_names: impl IntoIterator<Item = impl Into<String>>,
1707 lambda: f64,
1708 kind: PenaltyKind,
1709 ) -> LikelihoodResult<Self> {
1710 let name = LikelihoodName::new(name);
1711 if !lambda.is_finite() || lambda < 0.0 {
1712 return Err(LikelihoodError::InvalidPenaltyWeight {
1713 term: name.as_str().to_owned(),
1714 lambda,
1715 });
1716 }
1717 Ok(Self {
1718 name,
1719 parameter_names: parameter_names.into_iter().map(Into::into).collect(),
1720 parameter_ids: Vec::new(),
1721 global_params: None,
1722 lambda,
1723 kind,
1724 })
1725 }
1726
1727 fn name(&self) -> &str {
1728 self.name.as_str()
1729 }
1730
1731 fn resolve(&mut self, global_params: Arc<ParamLayout>) -> LikelihoodResult<()> {
1732 self.parameter_ids = self
1733 .parameter_names
1734 .iter()
1735 .map(|parameter| {
1736 global_params
1737 .id(parameter)
1738 .ok_or_else(|| LikelihoodError::MissingParameter {
1739 term: self.name().to_owned(),
1740 parameter: parameter.clone(),
1741 })
1742 })
1743 .collect::<LikelihoodResult<_>>()?;
1744 self.global_params = Some(global_params);
1745 Ok(())
1746 }
1747
1748 fn nll(&self, params: &ParamValues) -> LikelihoodResult<f64> {
1749 let global_params = self
1750 .global_params
1751 .as_ref()
1752 .ok_or(LikelihoodError::ParameterLayoutMismatch)?;
1753 check_params(global_params, params)?;
1754 let mut sum = 0.0;
1755 for id in &self.parameter_ids {
1756 let value = params.get(*id)?;
1757 sum += match self.kind {
1758 PenaltyKind::Ridge => value * value,
1759 PenaltyKind::Lasso => value.abs(),
1760 };
1761 }
1762 Ok(self.lambda * sum)
1763 }
1764
1765 fn nll_with_gradient(
1766 &self,
1767 params: &ParamValues,
1768 gradient: &mut [f64],
1769 ) -> LikelihoodResult<f64> {
1770 let global_params = self
1771 .global_params
1772 .as_ref()
1773 .ok_or(LikelihoodError::ParameterLayoutMismatch)?;
1774 check_params(global_params, params)?;
1775 if gradient.len() != global_params.n_free() {
1776 return Err(LikelihoodError::GradientLengthMismatch {
1777 expected: global_params.n_free(),
1778 actual: gradient.len(),
1779 });
1780 }
1781 let mut sum = 0.0;
1782 for id in &self.parameter_ids {
1783 let value = params.get(*id)?;
1784 let (penalty, derivative) = match self.kind {
1785 PenaltyKind::Ridge => (value * value, 2.0 * value),
1786 PenaltyKind::Lasso => {
1787 (value.abs(), if value == 0.0 { 0.0 } else { value.signum() })
1788 }
1789 };
1790 sum += penalty;
1791 if let Some(free) = global_params.free_id(*id)? {
1792 gradient[free.index()] += self.lambda * derivative;
1793 }
1794 }
1795 Ok(self.lambda * sum)
1796 }
1797}
1798
1799#[derive(Copy, Clone, Debug)]
1800enum PenaltyKind {
1801 Ridge,
1802 Lasso,
1803}
1804
1805#[derive(Clone)]
1807pub struct CrossSectionIntegrals {
1808 name: LikelihoodName,
1809 full_plan: PreparedModel,
1810 full_projection: ParamProjection,
1811 full_accepted_mc: PreparedDataset,
1812 full_normalization: Option<Arc<PreparedNormalization>>,
1813 accepted_mc_source: Dataset,
1814 generated_mc_source: Dataset,
1815 plan: PreparedModel,
1816 projection: ParamProjection,
1817 accepted_mc: PreparedDataset,
1818 normalization: Option<Arc<PreparedNormalization>>,
1819 generated_mc: PreparedDataset,
1820 data_weight_sum: f64,
1821 has_absolute_rate: bool,
1822 execution: Execution,
1823}
1824
1825impl std::fmt::Debug for CrossSectionIntegrals {
1826 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1827 formatter
1828 .debug_struct("CrossSectionIntegrals")
1829 .field("name", &self.name)
1830 .field("data_weight_sum", &self.data_weight_sum)
1831 .finish_non_exhaustive()
1832 }
1833}
1834
1835#[derive(Clone)]
1837pub struct LikelihoodProjection {
1838 name: LikelihoodName,
1839 full_plan: PreparedModel,
1840 full_projection: ParamProjection,
1841 full_accepted_mc: PreparedDataset,
1842 full_normalization: Option<Arc<PreparedNormalization>>,
1843 projected_plan: PreparedModel,
1844 projected_params: ParamProjection,
1845 projected_accepted_mc: PreparedDataset,
1846 projected_normalization: Option<Arc<PreparedNormalization>>,
1847 projected_generated_mc: PreparedDataset,
1848 accepted_mc_source: Dataset,
1849 generated_mc_source: Dataset,
1850 data_weight_sum: f64,
1851 has_absolute_rate: bool,
1852 execution: Execution,
1853}
1854
1855impl LikelihoodProjection {
1856 pub fn name(&self) -> &str {
1858 self.name.as_str()
1859 }
1860
1861 pub fn accepted_integral(&self, free: &[f64]) -> LikelihoodResult<f64> {
1868 if let Some(normalization) = &self.projected_normalization {
1869 let global = self.projected_params.global_layout.values(free)?;
1870 let local = self.projected_params.project(&global)?;
1871 return normalization
1872 .value(&local, &self.execution)
1873 .map_err(LikelihoodError::from);
1874 }
1875 self.projected_integral(free, &self.projected_accepted_mc, "accepted MC")
1876 }
1877
1878 pub fn generated_integral(&self, free: &[f64]) -> LikelihoodResult<f64> {
1885 self.projected_integral(free, &self.projected_generated_mc, "generated MC")
1886 }
1887
1888 pub fn acceptance(&self, free: &[f64]) -> LikelihoodResult<f64> {
1895 let generated = positive_integral("generated MC", self.generated_integral(free)?)?;
1896 let accepted = positive_integral("accepted MC", self.accepted_integral(free)?)?;
1897 Ok(accepted / generated)
1898 }
1899
1900 pub fn full_accepted_integral(&self, free: &[f64]) -> LikelihoodResult<f64> {
1907 let global = self.full_projection.global_layout.values(free)?;
1908 let local = self.full_projection.project(&global)?;
1909 if let Some(normalization) = &self.full_normalization {
1910 return normalization
1911 .value(&local, &self.execution)
1912 .map_err(LikelihoodError::from);
1913 }
1914 self.full_plan
1915 .reduce(
1916 &self.execution,
1917 &local,
1918 &self.full_accepted_mc,
1919 ReductionPlan::weighted_positive_real(),
1920 )
1921 .map_err(|error| map_reduction_error("accepted MC", error))
1922 }
1923
1924 pub fn acceptance_corrected_yield(&self, free: &[f64]) -> LikelihoodResult<f64> {
1931 let accepted = positive_integral("accepted MC", self.full_accepted_integral(free)?)?;
1932 Ok(self.data_weight_sum * self.generated_integral(free)? / accepted)
1933 }
1934
1935 pub fn observed_cross_section(&self, free: &[f64], luminosity: f64) -> LikelihoodResult<f64> {
1942 if !luminosity.is_finite() || luminosity <= 0.0 {
1943 return Err(LikelihoodError::NonPositiveLuminosity(luminosity));
1944 }
1945 Ok(self.acceptance_corrected_yield(free)? / luminosity)
1946 }
1947
1948 pub fn fitted_cross_section(&self, free: &[f64], luminosity: f64) -> LikelihoodResult<f64> {
1956 if !self.has_absolute_rate {
1957 return Err(LikelihoodError::AbsoluteRateUnavailable(
1958 self.name.as_str().to_owned(),
1959 ));
1960 }
1961 if !luminosity.is_finite() || luminosity <= 0.0 {
1962 return Err(LikelihoodError::NonPositiveLuminosity(luminosity));
1963 }
1964 Ok(self.generated_integral(free)? / luminosity)
1965 }
1966
1967 pub fn cross_section(&self, free: &[f64], luminosity: f64) -> LikelihoodResult<f64> {
1973 self.observed_cross_section(free, luminosity)
1974 }
1975
1976 pub fn weights(&self, free: &[f64], acceptance_corrected: bool) -> LikelihoodResult<Vec<f64>> {
1983 let scale = if acceptance_corrected {
1984 self.data_weight_sum
1985 / positive_integral("accepted MC", self.full_accepted_integral(free)?)?
1986 } else {
1987 1.0
1988 };
1989 let intensities = self.intensities(free)?;
1990 let mut output = Vec::with_capacity(intensities.len());
1991 let mut offset = 0;
1992 for batch in self
1993 .generated_mc_source
1994 .batches()
1995 .map_err(|e| LikelihoodError::Runtime(RuntimeError::Data(e.to_string())))?
1996 {
1997 let batch =
1998 batch.map_err(|e| LikelihoodError::Runtime(RuntimeError::Data(e.to_string())))?;
1999 output.extend(
2000 (0..batch.len())
2001 .map(|row| batch.weights_at(row) * intensities[offset + row] * scale),
2002 );
2003 offset += batch.len();
2004 }
2005 Ok(output)
2006 }
2007
2008 pub fn intensities(&self, free: &[f64]) -> LikelihoodResult<Vec<f64>> {
2015 let global = self.projected_params.global_layout.values(free)?;
2016 let local = self.projected_params.project(&global)?;
2017 let mut output = Vec::new();
2018 for batch in self
2019 .generated_mc_source
2020 .batches()
2021 .map_err(|e| LikelihoodError::Runtime(RuntimeError::Data(e.to_string())))?
2022 {
2023 let batch =
2024 batch.map_err(|e| LikelihoodError::Runtime(RuntimeError::Data(e.to_string())))?;
2025 output.extend(
2026 self.projected_plan
2027 .evaluate_batch(&local, &batch)?
2028 .into_iter()
2029 .map(|value| value.re),
2030 );
2031 }
2032 Ok(output)
2033 }
2034
2035 fn projected_integral(
2036 &self,
2037 free: &[f64],
2038 dataset: &PreparedDataset,
2039 name: &'static str,
2040 ) -> LikelihoodResult<f64> {
2041 let global = self.projected_params.global_layout.values(free)?;
2042 let local = self.projected_params.project(&global)?;
2043 self.projected_plan
2044 .reduce(
2045 &self.execution,
2046 &local,
2047 dataset,
2048 ReductionPlan::weighted_positive_real(),
2049 )
2050 .map_err(|error| map_reduction_error(name, error))
2051 }
2052}
2053
2054impl CrossSectionIntegrals {
2055 pub fn resident_bytes(&self) -> usize {
2057 let dataset_bytes = self
2058 .full_accepted_mc
2059 .stats()
2060 .resident_bytes()
2061 .saturating_add(self.accepted_mc.stats().resident_bytes())
2062 .saturating_add(self.generated_mc.stats().resident_bytes());
2063 let full_statistics = self
2064 .full_normalization
2065 .as_ref()
2066 .map_or(0, |normalization| normalization.resident_bytes());
2067 let projected_statistics = self.normalization.as_ref().map_or(0, |normalization| {
2068 if self
2069 .full_normalization
2070 .as_ref()
2071 .is_some_and(|full| Arc::ptr_eq(full, normalization))
2072 {
2073 0
2074 } else {
2075 normalization.resident_bytes()
2076 }
2077 });
2078 dataset_bytes
2079 .saturating_add(full_statistics)
2080 .saturating_add(projected_statistics)
2081 }
2082
2083 pub fn name(&self) -> &str {
2085 self.name.as_str()
2086 }
2087
2088 pub fn accepted_mc(&self) -> &PreparedDataset {
2090 &self.accepted_mc
2091 }
2092
2093 pub fn generated_mc(&self) -> &PreparedDataset {
2095 &self.generated_mc
2096 }
2097
2098 pub fn accepted_mc_source(&self) -> &Dataset {
2100 &self.accepted_mc_source
2101 }
2102
2103 pub fn generated_mc_source(&self) -> &Dataset {
2105 &self.generated_mc_source
2106 }
2107
2108 pub fn data_weight_sum(&self) -> f64 {
2110 self.data_weight_sum
2111 }
2112
2113 pub fn accepted_integral(&self, free: &[f64]) -> LikelihoodResult<f64> {
2120 let params = self.projection.global_layout.values(free)?;
2121 let local_params = self.projection.project(¶ms)?;
2122 if let Some(normalization) = &self.normalization {
2123 return normalization
2124 .value(&local_params, &self.execution)
2125 .map_err(LikelihoodError::from);
2126 }
2127 self.weighted_intensity_sum(&local_params, &self.accepted_mc, "accepted MC")
2128 }
2129
2130 pub fn generated_integral(&self, free: &[f64]) -> LikelihoodResult<f64> {
2137 let params = self.projection.global_layout.values(free)?;
2138 let local_params = self.projection.project(¶ms)?;
2139 self.weighted_intensity_sum(&local_params, &self.generated_mc, "generated MC")
2140 }
2141
2142 pub fn accepted_intensities(&self, free: &[f64]) -> LikelihoodResult<Vec<f64>> {
2148 self.intensities(free, &self.accepted_mc_source)
2149 }
2150
2151 pub(crate) fn visit_accepted_prepared_intensities_many<F>(
2152 &self,
2153 free: &[&[f64]],
2154 parameter_contexts: &[String],
2155 consume: F,
2156 ) -> LikelihoodResult<Vec<f64>>
2157 where
2158 F: FnMut(usize, usize, &[f64]) + Send,
2159 {
2160 self.visit_prepared_intensities_many(
2161 free,
2162 parameter_contexts,
2163 &self.accepted_mc,
2164 &self.accepted_mc_source,
2165 Some(ReductionPlan::weighted_positive_real()),
2166 consume,
2167 )
2168 }
2169
2170 pub fn generated_intensities(&self, free: &[f64]) -> LikelihoodResult<Vec<f64>> {
2176 self.intensities(free, &self.generated_mc_source)
2177 }
2178
2179 pub(crate) fn visit_generated_prepared_intensities_many<F>(
2180 &self,
2181 free: &[&[f64]],
2182 parameter_contexts: &[String],
2183 consume: F,
2184 ) -> LikelihoodResult<()>
2185 where
2186 F: FnMut(usize, usize, &[f64]) + Send,
2187 {
2188 self.visit_prepared_intensities_many(
2189 free,
2190 parameter_contexts,
2191 &self.generated_mc,
2192 &self.generated_mc_source,
2193 None,
2194 consume,
2195 )?;
2196 Ok(())
2197 }
2198
2199 pub fn acceptance(&self, free: &[f64]) -> LikelihoodResult<f64> {
2206 let generated = positive_integral("generated MC", self.generated_integral(free)?)?;
2207 let accepted = positive_integral("accepted MC", self.accepted_integral(free)?)?;
2208 Ok(accepted / generated)
2209 }
2210
2211 pub fn acceptance_corrected_yield(
2218 &self,
2219 free: &[f64],
2220 accepted_yield: f64,
2221 ) -> LikelihoodResult<f64> {
2222 let accepted = self.accepted_integral(free)?;
2223 if accepted <= 0.0 {
2224 return Err(LikelihoodError::NonPositiveAcceptedIntegral(accepted));
2225 }
2226 Ok(accepted_yield * self.generated_integral(free)? / accepted)
2227 }
2228
2229 pub fn observed_cross_section(&self, free: &[f64], luminosity: f64) -> LikelihoodResult<f64> {
2236 if !luminosity.is_finite() || luminosity <= 0.0 {
2237 return Err(LikelihoodError::NonPositiveLuminosity(luminosity));
2238 }
2239 let full_accepted = positive_integral("accepted MC", self.full_accepted_integral(free)?)?;
2240 Ok(self.data_weight_sum * self.generated_integral(free)? / full_accepted / luminosity)
2241 }
2242
2243 pub fn fitted_cross_section(&self, free: &[f64], luminosity: f64) -> LikelihoodResult<f64> {
2254 if !self.has_absolute_rate {
2255 return Err(LikelihoodError::AbsoluteRateUnavailable(
2256 self.name.as_str().to_owned(),
2257 ));
2258 }
2259 if !luminosity.is_finite() || luminosity <= 0.0 {
2260 return Err(LikelihoodError::NonPositiveLuminosity(luminosity));
2261 }
2262 Ok(self.generated_integral(free)? / luminosity)
2263 }
2264
2265 pub fn cross_section(&self, free: &[f64], luminosity: f64) -> LikelihoodResult<f64> {
2271 self.observed_cross_section(free, luminosity)
2272 }
2273
2274 pub fn full_accepted_integral(&self, free: &[f64]) -> LikelihoodResult<f64> {
2284 let params = self.full_projection.global_layout.values(free)?;
2285 let local_params = self.full_projection.project(¶ms)?;
2286 if let Some(normalization) = &self.full_normalization {
2287 return normalization
2288 .value(&local_params, &self.execution)
2289 .map_err(LikelihoodError::from);
2290 }
2291 self.full_plan
2292 .reduce(
2293 &self.execution,
2294 &local_params,
2295 &self.full_accepted_mc,
2296 ReductionPlan::weighted_positive_real(),
2297 )
2298 .map_err(|error| map_reduction_error("accepted MC", error))
2299 }
2300
2301 fn weighted_intensity_sum(
2302 &self,
2303 params: &ParamValues,
2304 dataset: &PreparedDataset,
2305 name: &'static str,
2306 ) -> LikelihoodResult<f64> {
2307 self.plan
2308 .reduce(
2309 &self.execution,
2310 params,
2311 dataset,
2312 ReductionPlan::weighted_positive_real(),
2313 )
2314 .map_err(|error| map_reduction_error(name, error))
2315 }
2316
2317 fn intensities(&self, free: &[f64], dataset: &Dataset) -> LikelihoodResult<Vec<f64>> {
2318 let global = self.projection.global_layout.values(free)?;
2319 let local = self.projection.project(&global)?;
2320 let mut output = Vec::new();
2321 for batch in dataset
2322 .batches()
2323 .map_err(|error| LikelihoodError::Runtime(RuntimeError::Data(error.to_string())))?
2324 {
2325 let batch = batch
2326 .map_err(|error| LikelihoodError::Runtime(RuntimeError::Data(error.to_string())))?;
2327 output.extend(
2328 self.plan
2329 .evaluate_batch(&local, &batch)?
2330 .into_iter()
2331 .map(|value| value.re),
2332 );
2333 }
2334 Ok(output)
2335 }
2336
2337 fn visit_prepared_intensities_many<F>(
2338 &self,
2339 free: &[&[f64]],
2340 parameter_contexts: &[String],
2341 dataset: &PreparedDataset,
2342 source: &Dataset,
2343 reduction: Option<ReductionPlan>,
2344 mut consume: F,
2345 ) -> LikelihoodResult<Vec<f64>>
2346 where
2347 F: FnMut(usize, usize, &[f64]) + Send,
2348 {
2349 let local = self.project_many(free)?;
2350 let parameter_sets = local
2351 .iter()
2352 .zip(parameter_contexts)
2353 .map(|(parameters, context)| (parameters, context.as_str()))
2354 .collect::<Vec<_>>();
2355 if parameter_sets.len() != local.len() {
2356 return Err(LikelihoodError::Runtime(RuntimeError::InvalidShape {
2357 index: 0,
2358 message: "parameter values and evaluation contexts have different lengths".into(),
2359 }));
2360 }
2361 self.plan
2362 .visit_prepared_many_parallel(
2363 &self.execution,
2364 ¶meter_sets,
2365 dataset,
2366 source,
2367 reduction,
2368 |offset, parameter_index, values| {
2369 let real = values.iter().map(|value| value.re).collect::<Vec<f64>>();
2370 consume(offset, parameter_index, &real);
2371 Ok(())
2372 },
2373 )
2374 .map_err(LikelihoodError::from)
2375 }
2376
2377 fn project_many(&self, free: &[&[f64]]) -> LikelihoodResult<Vec<ParamValues>> {
2378 free.iter()
2379 .map(|free| {
2380 let global = self.projection.global_layout.values(free)?;
2381 self.projection.project(&global)
2382 })
2383 .collect()
2384 }
2385}
2386
2387#[derive(Clone, Debug)]
2388struct ParamProjection {
2389 global_layout: Arc<ParamLayout>,
2390 local_layout: Arc<ParamLayout>,
2391 global_ids: Vec<ParamId>,
2392 local_free_to_global_free: Vec<usize>,
2393}
2394
2395impl ParamProjection {
2396 fn new(
2397 global_layout: Arc<ParamLayout>,
2398 local_layout: &ParamLayout,
2399 term: &str,
2400 ) -> LikelihoodResult<Self> {
2401 let global_ids = local_layout
2402 .specs()
2403 .iter()
2404 .map(|spec| {
2405 global_layout
2406 .id(spec.name())
2407 .ok_or_else(|| LikelihoodError::MissingParameter {
2408 term: term.to_owned(),
2409 parameter: spec.name().to_owned(),
2410 })
2411 })
2412 .collect::<LikelihoodResult<_>>()?;
2413 let local_free_to_global_free = local_layout
2414 .free_params()
2415 .iter()
2416 .map(|local_id| {
2417 let name = local_layout.name(*local_id)?;
2418 let global_id =
2419 global_layout
2420 .id(name)
2421 .ok_or_else(|| LikelihoodError::MissingParameter {
2422 term: term.to_owned(),
2423 parameter: name.to_owned(),
2424 })?;
2425 global_layout
2426 .free_id(global_id)?
2427 .map(|id| id.index())
2428 .ok_or(LikelihoodError::ParameterLayoutMismatch)
2429 })
2430 .collect::<LikelihoodResult<Vec<_>>>()?;
2431 Ok(Self {
2432 global_layout,
2433 local_layout: Arc::new(local_layout.clone()),
2434 global_ids,
2435 local_free_to_global_free,
2436 })
2437 }
2438
2439 fn project(&self, params: &ParamValues) -> LikelihoodResult<ParamValues> {
2440 check_params(&self.global_layout, params)?;
2441 let free = self
2442 .local_layout
2443 .free_params()
2444 .iter()
2445 .map(|local_id| params.get(self.global_ids[local_id.index()]))
2446 .collect::<Result<Vec<_>, _>>()?;
2447 Ok(self.local_layout.values(&free)?)
2448 }
2449
2450 fn scatter_gradient(&self, local: &[f64], global: &mut [f64]) -> LikelihoodResult<()> {
2451 if local.len() != self.local_free_to_global_free.len() {
2452 return Err(LikelihoodError::GradientLengthMismatch {
2453 expected: self.local_free_to_global_free.len(),
2454 actual: local.len(),
2455 });
2456 }
2457 if global.len() != self.global_layout.n_free() {
2458 return Err(LikelihoodError::GradientLengthMismatch {
2459 expected: self.global_layout.n_free(),
2460 actual: global.len(),
2461 });
2462 }
2463 for (derivative, target) in local.iter().zip(&self.local_free_to_global_free) {
2464 global[*target] += derivative;
2465 }
2466 Ok(())
2467 }
2468}
2469
2470fn check_params(layout: &ParamLayout, params: &ParamValues) -> LikelihoodResult<()> {
2471 if params.layout().specs() == layout.specs() {
2472 Ok(())
2473 } else {
2474 Err(LikelihoodError::ParameterLayoutMismatch)
2475 }
2476}
2477
2478fn map_reduction_error(
2479 dataset: &'static str,
2480 error: laddu_runtime::RuntimeError,
2481) -> LikelihoodError {
2482 match error {
2483 laddu_runtime::RuntimeError::Reduction(
2484 laddu_compile::ReductionError::NonPositiveValue { value, .. },
2485 ) => LikelihoodError::NonPositiveIntensity { dataset, value },
2486 error => error.into(),
2487 }
2488}
2489
2490fn positive_integral(dataset: &'static str, value: f64) -> LikelihoodResult<f64> {
2491 if value > 0.0 {
2492 Ok(value)
2493 } else {
2494 Err(LikelihoodError::NonPositiveIntensity { dataset, value })
2495 }
2496}
2497
2498#[cfg(test)]
2499mod tests {
2500 use std::sync::Arc;
2501
2502 use approx::assert_relative_eq;
2503 #[cfg(feature = "wgpu")]
2504 use laddu_compile::CompileOptions;
2505 use laddu_compile::CompiledModel;
2506 use laddu_data::{
2507 data::{CacheStorage, Dataset, EventBatch, OwnedEvent},
2508 schema::Schema,
2509 };
2510 use laddu_expr::{
2511 Expr, complex, event_scalar, matrix, parameter, parameters::Parameter, solve, vector,
2512 };
2513 #[cfg(feature = "wgpu")]
2514 use laddu_expr::{dot, matvec};
2515 use laddu_runtime::{CpuOptions, Device, ExecutionOptions, JitPolicy, Precision, ThreadPolicy};
2516 #[cfg(feature = "wgpu")]
2517 use laddu_runtime::{GpuBackend, GpuOptions, MemoryBudget, MemoryPlan};
2518
2519 use super::*;
2520
2521 fn weighted_dataset(values: &[(f64, f64)]) -> Dataset {
2522 let schema = Arc::new(Schema::new(std::iter::empty::<&str>(), ["x"], true).unwrap());
2523 let batch = EventBatch::from_events(
2524 schema,
2525 values
2526 .iter()
2527 .map(|(x, weight)| OwnedEvent::weighted(vec![], vec![*x], *weight)),
2528 )
2529 .unwrap();
2530 Dataset::from_batches(vec![batch]).unwrap()
2531 }
2532
2533 fn weighted_dataset_batches(values: &[(f64, f64)], ends: &[usize]) -> Dataset {
2534 let schema = Arc::new(Schema::new(std::iter::empty::<&str>(), ["x"], true).unwrap());
2535 let mut start = 0;
2536 let batches = ends
2537 .iter()
2538 .map(|&end| {
2539 let batch = EventBatch::from_events(
2540 Arc::clone(&schema),
2541 values[start..end]
2542 .iter()
2543 .map(|(x, weight)| OwnedEvent::weighted(vec![], vec![*x], *weight)),
2544 )
2545 .unwrap();
2546 start = end;
2547 batch
2548 })
2549 .collect::<Vec<_>>();
2550 assert_eq!(start, values.len());
2551 Dataset::from_batches(batches)
2552 .unwrap()
2553 .chunked(values.len())
2554 .unwrap()
2555 }
2556
2557 fn single_term_likelihood(
2558 name: &str,
2559 model: &CompiledModel,
2560 data: &Dataset,
2561 accepted_mc: &Dataset,
2562 ) -> Likelihood {
2563 Likelihood::new([NllTerm::new(name, model, data, accepted_mc).unwrap()]).unwrap()
2564 }
2565
2566 fn single_term_likelihood_with_execution(
2567 name: &str,
2568 model: &CompiledModel,
2569 data: &Dataset,
2570 accepted_mc: &Dataset,
2571 execution: Execution,
2572 ) -> Likelihood {
2573 Likelihood::with_execution(
2574 [NllTerm::new(name, model, data, accepted_mc).unwrap()],
2575 &execution,
2576 )
2577 .unwrap()
2578 }
2579
2580 fn cpu_execution(precision: Precision, threads: ThreadPolicy, jit: JitPolicy) -> Execution {
2581 Execution::local(ExecutionOptions {
2582 device: Device::Cpu(CpuOptions { threads, jit }),
2583 precision,
2584 ..ExecutionOptions::default()
2585 })
2586 .unwrap()
2587 }
2588
2589 #[cfg(feature = "wgpu")]
2590 fn wgpu_execution(memory_budget: Option<usize>) -> Execution {
2591 Execution::local(ExecutionOptions {
2592 device: Device::Gpu(GpuOptions {
2593 backend: GpuBackend::Wgpu,
2594 ..GpuOptions::default()
2595 }),
2596 memory: MemoryPlan {
2597 host: MemoryBudget::Auto,
2598 device: memory_budget.map(|bytes| MemoryBudget::Bytes(bytes as u64)),
2599 },
2600 precision: Precision::F32,
2601 ..ExecutionOptions::default()
2602 })
2603 .unwrap()
2604 }
2605
2606 fn assert_evaluation_close(
2607 actual: &LikelihoodEvaluation,
2608 expected: &LikelihoodEvaluation,
2609 epsilon: f64,
2610 ) {
2611 assert_relative_eq!(actual.value(), expected.value(), epsilon = epsilon);
2612 assert_eq!(actual.gradient().len(), expected.gradient().len());
2613 for (actual, expected) in actual.gradient().iter().zip(expected.gradient()) {
2614 assert_relative_eq!(actual, expected, epsilon = epsilon);
2615 }
2616 }
2617
2618 fn finite_difference_nll(
2619 likelihood: &Likelihood,
2620 params: &[f64],
2621 free_parameter: usize,
2622 ) -> f64 {
2623 let center = params[free_parameter];
2624 let h = 1.0e-6;
2625 let mut plus = params.to_vec();
2626 let mut minus = params.to_vec();
2627 plus[free_parameter] = center + h;
2628 minus[free_parameter] = center - h;
2629 (likelihood.nll(&plus).unwrap() - likelihood.nll(&minus).unwrap()) / (2.0 * h)
2630 }
2631
2632 #[test]
2633 fn nll_uses_data_and_accepted_mc_reductions() {
2634 let expr = event_scalar("x") * parameter!("scale", initial: 0.5);
2635 let model = CompiledModel::from_expr(&expr).unwrap();
2636 let data = weighted_dataset(&[(2.0, 1.0), (3.0, 1.0)]);
2637 let accepted_mc = weighted_dataset(&[(4.0, 1.0)]);
2638 let likelihood = single_term_likelihood("data", &model, &data, &accepted_mc);
2639 let params = likelihood.default_params();
2640
2641 let expected = 2.0 * 2.0_f64.ln() - 1.0_f64.ln() - 1.5_f64.ln();
2642 assert_relative_eq!(likelihood.nll(¶ms).unwrap(), expected);
2643 }
2644
2645 #[test]
2646 fn extended_nll_uses_expected_yield_and_has_an_analytic_gradient() {
2647 let expr = event_scalar("x") * parameter!("scale", initial: 0.5);
2648 let model = CompiledModel::from_expr(&expr).unwrap();
2649 let data = weighted_dataset(&[(2.0, 1.0), (3.0, 1.0)]);
2650 let accepted_mc = weighted_dataset(&[(4.0, 1.0)]);
2651 let likelihood =
2652 Likelihood::new([
2653 ExtendedNllTerm::new("extended", &model, &data, &accepted_mc).unwrap(),
2654 ])
2655 .unwrap();
2656 let params = likelihood.default_params();
2657 let evaluation = likelihood.nll_with_gradient(¶ms).unwrap();
2658 let expected = 2.0 - 1.0_f64.ln() - 1.5_f64.ln();
2659
2660 assert_relative_eq!(evaluation.value(), expected);
2661 assert_relative_eq!(
2662 evaluation.gradient()[0],
2663 finite_difference_nll(&likelihood, ¶ms, 0),
2664 epsilon = 1.0e-8
2665 );
2666 }
2667
2668 #[test]
2669 fn extended_nll_exposes_observed_and_fitted_cross_sections() {
2670 let model =
2671 CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.25)))
2672 .unwrap();
2673 let data = weighted_dataset(&[(2.0, 1.0), (3.0, 1.0)]);
2674 let accepted_mc = weighted_dataset(&[(4.0, 1.0)]);
2675 let generated_mc = weighted_dataset(&[(6.0, 1.0)]);
2676 let likelihood =
2677 Likelihood::new([
2678 ExtendedNllTerm::new("extended", &model, &data, &accepted_mc).unwrap(),
2679 ])
2680 .unwrap();
2681 let params = likelihood.default_params();
2682 let integrals = likelihood
2683 .cross_section_integrals("extended", &generated_mc)
2684 .unwrap();
2685
2686 assert_relative_eq!(
2687 integrals.observed_cross_section(¶ms, 10.0).unwrap(),
2688 0.3
2689 );
2690 assert_relative_eq!(integrals.fitted_cross_section(¶ms, 10.0).unwrap(), 0.15);
2691 assert_relative_eq!(
2692 integrals.cross_section(¶ms, 10.0).unwrap(),
2693 integrals.observed_cross_section(¶ms, 10.0).unwrap()
2694 );
2695 assert_relative_eq!(
2696 likelihood
2697 .intensity_datasets("extended")
2698 .unwrap()
2699 .0
2700 .sum_weights()
2701 .unwrap(),
2702 data.sum_weights().unwrap()
2703 );
2704 }
2705
2706 #[test]
2707 fn fitted_cross_section_rejects_shape_only_nll() {
2708 let model = CompiledModel::from_expr(&event_scalar("x")).unwrap();
2709 let data = weighted_dataset(&[(2.0, 1.0)]);
2710 let accepted_mc = weighted_dataset(&[(4.0, 1.0)]);
2711 let generated_mc = weighted_dataset(&[(6.0, 1.0)]);
2712 let likelihood = single_term_likelihood("shape", &model, &data, &accepted_mc);
2713 let integrals = likelihood
2714 .cross_section_integrals("shape", &generated_mc)
2715 .unwrap();
2716
2717 assert!(matches!(
2718 integrals.fitted_cross_section(&[], 10.0),
2719 Err(LikelihoodError::AbsoluteRateUnavailable(name)) if name == "shape"
2720 ));
2721 }
2722
2723 #[test]
2724 fn likelihood_accepts_free_slices_and_generates_free_parameters() {
2725 let scale = laddu_expr::Expr::from(Parameter::free("scale").with_initial((0.25, 0.75)));
2726 let offset = laddu_expr::Expr::from(Parameter::fixed("offset", 1.0));
2727 let model = CompiledModel::from_expr(&(event_scalar("x") * scale + offset)).unwrap();
2728 let data = weighted_dataset(&[(1.0, 1.0), (2.0, 1.0)]);
2729 let accepted = weighted_dataset(&[(1.5, 1.0), (2.5, 1.0)]);
2730 let likelihood = single_term_likelihood("slice", &model, &data, &accepted);
2731
2732 assert_eq!(likelihood.default_params(), vec![0.5]);
2733 assert_eq!(likelihood.sample_initial(0), vec![0.5513138035955086]);
2734 assert_eq!(
2735 likelihood.params_with(|parameter| parameter.name().len() as f64),
2736 vec![5.0]
2737 );
2738 assert!(likelihood.nll(&[0.5f64]).unwrap().is_finite());
2739 assert!(matches!(
2740 likelihood.nll(&[]),
2741 Err(LikelihoodError::Params(ParamError::FreeLengthMismatch {
2742 expected: 1,
2743 actual: 0
2744 }))
2745 ));
2746 }
2747
2748 #[test]
2749 fn nll_gradient_matches_finite_difference() {
2750 let scale = laddu_expr::Expr::from(parameter!("scale", initial: 0.7));
2751 let expr = (event_scalar("x") + scale).powi(2);
2752 let model = CompiledModel::from_expr(&expr).unwrap();
2753 let data = weighted_dataset(&[(0.3, 1.0), (1.1, 2.0)]);
2754 let accepted_mc = weighted_dataset(&[(0.5, 1.5), (1.7, 0.8)]);
2755 let likelihood = single_term_likelihood("data", &model, &data, &accepted_mc);
2756 let params = likelihood.default_params();
2757 let evaluation = likelihood.nll_with_gradient(¶ms).unwrap();
2758
2759 assert_relative_eq!(evaluation.value(), likelihood.nll(¶ms).unwrap());
2760 assert_relative_eq!(
2761 evaluation.gradient()[0],
2762 finite_difference_nll(&likelihood, ¶ms, 0),
2763 epsilon = 1.0e-8
2764 );
2765 }
2766
2767 #[test]
2768 fn full_fraction_stochastic_evaluation_matches_exact_likelihood() {
2769 let scale = laddu_expr::Expr::from(parameter!("scale", initial: 0.7));
2770 let model = CompiledModel::from_expr(&(event_scalar("x") + scale).powi(2)).unwrap();
2771 let data = weighted_dataset(&[(0.3, 1.0), (1.1, 2.0), (1.8, 0.5)]);
2772 let accepted_mc = weighted_dataset(&[(0.5, 1.5), (1.7, 0.8)]);
2773 let likelihood = single_term_likelihood("data", &model, &data, &accepted_mc);
2774 let params = likelihood.default_params();
2775 let exact = likelihood.nll_with_gradient(¶ms).unwrap();
2776 let stochastic = likelihood
2777 .stochastic_nll_with_gradient(¶ms, 1.0, 42)
2778 .unwrap();
2779
2780 assert_evaluation_close(&stochastic, &exact, 1.0e-12);
2781 assert!(matches!(
2782 likelihood.stochastic_nll_with_gradient(¶ms, 0.0, 42),
2783 Err(LikelihoodError::InvalidBatchFraction(0.0))
2784 ));
2785 }
2786
2787 #[test]
2788 fn likelihood_is_invariant_under_dataset_batching() {
2789 let x = event_scalar("x");
2790 let coupling = laddu_expr::Expr::from(parameter!("coupling", initial: 0.35));
2791 let matrix = matrix([
2792 [x.clone() + 2.0, complex(coupling.clone(), 0.15)],
2793 [complex(-0.2, coupling), 3.5.into()],
2794 ]);
2795 let amplitude = solve(matrix, vector([x.sin() + 1.0, complex(x.cos(), 0.5)])).component(1);
2796 let model = CompiledModel::from_expr(&(amplitude.norm_sqr() + 0.25)).unwrap();
2797 let values = [
2798 (0.15, 0.7),
2799 (0.35, 1.2),
2800 (0.65, 0.5),
2801 (0.95, 1.8),
2802 (1.25, 0.9),
2803 (1.55, 1.1),
2804 (1.85, 0.6),
2805 ];
2806 let one_batch = weighted_dataset_batches(&values, &[values.len()]);
2807 let two_batches = weighted_dataset_batches(&values, &[3, values.len()]);
2808 let uneven_batches = weighted_dataset_batches(&values, &[1, 2, 6, values.len()]);
2809 let streaming = weighted_dataset_batches(&values, &[2, 5, values.len()]).streaming();
2810
2811 let reference = single_term_likelihood("reference", &model, &one_batch, &one_batch);
2812 let two = single_term_likelihood("two", &model, &two_batches, &two_batches);
2813 let uneven = single_term_likelihood("uneven", &model, &uneven_batches, &uneven_batches);
2814 let serial = single_term_likelihood_with_execution(
2815 "serial",
2816 &model,
2817 &streaming,
2818 &streaming,
2819 Execution::local(ExecutionOptions {
2820 device: Device::Cpu(CpuOptions {
2821 threads: ThreadPolicy::Serial,
2822 ..CpuOptions::default()
2823 }),
2824 ..ExecutionOptions::default()
2825 })
2826 .unwrap(),
2827 );
2828 let fixed = single_term_likelihood_with_execution(
2829 "fixed",
2830 &model,
2831 &two_batches,
2832 &streaming,
2833 Execution::local(ExecutionOptions {
2834 device: Device::Cpu(CpuOptions {
2835 threads: ThreadPolicy::Fixed(2),
2836 ..CpuOptions::default()
2837 }),
2838 ..ExecutionOptions::default()
2839 })
2840 .unwrap(),
2841 );
2842 let expected = reference
2843 .nll_with_gradient(&reference.default_params())
2844 .unwrap();
2845
2846 for actual in [
2847 two.nll_with_gradient(&two.default_params()).unwrap(),
2848 uneven.nll_with_gradient(&uneven.default_params()).unwrap(),
2849 serial.nll_with_gradient(&serial.default_params()).unwrap(),
2850 fixed.nll_with_gradient(&fixed.default_params()).unwrap(),
2851 ] {
2852 assert_relative_eq!(actual.value(), expected.value(), epsilon = 1.0e-12);
2853 assert_eq!(actual.gradient().len(), expected.gradient().len());
2854 for (actual, expected) in actual.gradient().iter().zip(expected.gradient()) {
2855 assert_relative_eq!(actual, expected, epsilon = 1.0e-11);
2856 }
2857 }
2858 assert_eq!(
2859 reference.terms()[0]
2860 .as_intensity()
2861 .unwrap()
2862 .data()
2863 .unwrap()
2864 .stats()
2865 .storage(),
2866 CacheStorage::Resident
2867 );
2868 let streaming_stats = serial.terms()[0]
2869 .as_intensity()
2870 .unwrap()
2871 .data()
2872 .unwrap()
2873 .stats();
2874 assert_eq!(streaming_stats.storage(), CacheStorage::Streaming);
2875 assert_eq!(streaming_stats.resident_bytes(), 0);
2876 assert_eq!(streaming_stats.local_batches(), 1);
2877 }
2878
2879 #[test]
2880 fn f32_cpu_likelihood_matches_across_resident_streaming_and_batches() {
2881 let x = event_scalar("x");
2882 let coupling = laddu_expr::Expr::from(parameter!("coupling", initial: 0.35));
2883 let matrix = matrix([
2884 [x.clone() + 2.0, complex(coupling.clone(), 0.15)],
2885 [complex(-0.2, coupling), 3.5.into()],
2886 ]);
2887 let amplitude = solve(matrix, vector([x.sin() + 1.0, complex(x.cos(), 0.5)])).component(1);
2888 let model = CompiledModel::from_expr(&(amplitude.norm_sqr() + 0.25)).unwrap();
2889 let values = [
2890 (0.15, 0.7),
2891 (0.35, 1.2),
2892 (0.65, 0.5),
2893 (0.95, 1.8),
2894 (1.25, 0.9),
2895 (1.55, 1.1),
2896 (1.85, 0.6),
2897 ];
2898 let one_batch = weighted_dataset_batches(&values, &[values.len()]);
2899 let two_batches = weighted_dataset_batches(&values, &[3, values.len()]);
2900 let streaming = weighted_dataset_batches(&values, &[2, 5, values.len()]).streaming();
2901 let interpreter = cpu_execution(Precision::F32, ThreadPolicy::Serial, JitPolicy::Disabled);
2902 let threaded = cpu_execution(Precision::F32, ThreadPolicy::Fixed(2), JitPolicy::Disabled);
2903
2904 let reference = single_term_likelihood_with_execution(
2905 "reference",
2906 &model,
2907 &one_batch,
2908 &one_batch,
2909 interpreter.clone(),
2910 );
2911 let expected = reference
2912 .nll_with_gradient(&reference.default_params())
2913 .unwrap();
2914
2915 let cases = [
2916 single_term_likelihood_with_execution(
2917 "resident",
2918 &model,
2919 &two_batches,
2920 &two_batches,
2921 interpreter.clone(),
2922 ),
2923 single_term_likelihood_with_execution(
2924 "streaming",
2925 &model,
2926 &streaming,
2927 &streaming,
2928 interpreter.clone(),
2929 ),
2930 single_term_likelihood_with_execution(
2931 "mixed",
2932 &model,
2933 &two_batches,
2934 &streaming,
2935 threaded,
2936 ),
2937 ];
2938
2939 for likelihood in cases {
2940 let actual = likelihood
2941 .nll_with_gradient(&likelihood.default_params())
2942 .unwrap();
2943 assert_evaluation_close(&actual, &expected, 5.0e-5);
2944 }
2945
2946 #[cfg(feature = "jit")]
2947 {
2948 let jit = cpu_execution(Precision::F32, ThreadPolicy::Fixed(2), JitPolicy::Enabled);
2949 for likelihood in [
2950 single_term_likelihood_with_execution(
2951 "jit-resident",
2952 &model,
2953 &two_batches,
2954 &two_batches,
2955 jit.clone(),
2956 ),
2957 single_term_likelihood_with_execution(
2958 "jit-streaming",
2959 &model,
2960 &streaming,
2961 &streaming,
2962 jit.clone(),
2963 ),
2964 single_term_likelihood_with_execution(
2965 "jit-mixed",
2966 &model,
2967 &two_batches,
2968 &streaming,
2969 jit,
2970 ),
2971 ] {
2972 let actual = likelihood
2973 .nll_with_gradient(&likelihood.default_params())
2974 .unwrap();
2975 assert_evaluation_close(&actual, &expected, 5.0e-5);
2976 }
2977 }
2978
2979 let streaming_likelihood = single_term_likelihood_with_execution(
2980 "streaming-stats",
2981 &model,
2982 &streaming,
2983 &streaming,
2984 interpreter,
2985 );
2986 let streaming_stats = streaming_likelihood.terms()[0]
2987 .as_intensity()
2988 .unwrap()
2989 .data()
2990 .unwrap()
2991 .stats();
2992 assert_eq!(streaming_stats.storage(), CacheStorage::Streaming);
2993 assert_eq!(streaming_stats.resident_bytes(), 0);
2994 assert_eq!(streaming_stats.local_batches(), 1);
2995 }
2996
2997 #[test]
2998 fn likelihood_nll_uses_configured_f32_scalar_execution() {
2999 let dataset = weighted_dataset(&[(1.0, 1.0), (2.0, 1.0)]);
3000 let scale = laddu_expr::Expr::from(parameter!("scale", initial: 1.0));
3001 let model = CompiledModel::from_expr(&(event_scalar("x") + scale)).unwrap();
3002 let likelihood = single_term_likelihood_with_execution(
3003 "f32",
3004 &model,
3005 &dataset,
3006 &dataset,
3007 cpu_execution(Precision::F32, ThreadPolicy::Serial, JitPolicy::Auto),
3008 );
3009
3010 let expected = 2.0 * 5.0_f64.ln() - 2.0_f32.ln() as f64 - 3.0_f32.ln() as f64;
3011 assert_eq!(
3012 likelihood.nll(&likelihood.default_params()).unwrap(),
3013 expected
3014 );
3015 }
3016
3017 #[cfg(feature = "mpi")]
3018 #[mpi_test::mpi_test(np = [2, 3, 4])]
3019 fn mpi_likelihood_matches_local_reference_without_multiplying_penalties() {
3020 use mpi::traits::Communicator;
3021
3022 let universe = mpi::initialize().unwrap();
3023 let world = universe.world();
3024 let values = [(0.4, 1.5), (1.2, 0.75)];
3025 let resident = weighted_dataset_batches(&values, &[1, values.len()]);
3026 let streaming = weighted_dataset_batches(&values, &[1, values.len()]).streaming();
3027 let scale = laddu_expr::Expr::from(parameter!("scale", initial: 0.6));
3028 let model = CompiledModel::from_expr(&(event_scalar("x") + scale).powi(2)).unwrap();
3029
3030 let reference = Likelihood::new_boxed([
3031 NllTerm::new("data", &model, &resident, &streaming)
3032 .unwrap()
3033 .boxed(),
3034 RidgePenalty::new("ridge", ["scale"], 0.3).unwrap().boxed(),
3035 ])
3036 .unwrap();
3037 let distributed = Likelihood::with_execution_boxed(
3038 [
3039 NllTerm::new("data", &model, &resident, &streaming)
3040 .unwrap()
3041 .boxed(),
3042 RidgePenalty::new("ridge", ["scale"], 0.3).unwrap().boxed(),
3043 ],
3044 &Execution::distributed(
3045 ExecutionOptions {
3046 device: Device::Cpu(CpuOptions {
3047 threads: ThreadPolicy::Serial,
3048 ..CpuOptions::default()
3049 }),
3050 partitioning: laddu_data::io::Partitioning::Contiguous,
3051 ..ExecutionOptions::default()
3052 },
3053 &world,
3054 )
3055 .unwrap(),
3056 )
3057 .unwrap();
3058
3059 let expected = reference
3060 .nll_with_gradient(&reference.default_params())
3061 .unwrap();
3062 let actual = distributed
3063 .nll_with_gradient(&distributed.default_params())
3064 .unwrap();
3065 assert_relative_eq!(actual.value(), expected.value(), epsilon = 1.0e-12);
3066 assert_relative_eq!(
3067 actual.gradient()[0],
3068 expected.gradient()[0],
3069 epsilon = 1.0e-11
3070 );
3071 let expected_term = reference.terms()[0].as_intensity().unwrap();
3072 let actual_term = distributed.terms()[0].as_intensity().unwrap();
3073 assert_relative_eq!(
3074 actual_term
3075 .accepted_normalization(&distributed.default_params())
3076 .unwrap(),
3077 expected_term
3078 .accepted_normalization(&reference.default_params())
3079 .unwrap(),
3080 epsilon = 1.0e-12
3081 );
3082 assert_relative_eq!(
3083 actual_term
3084 .data_log_intensity_sum(&distributed.default_params())
3085 .unwrap(),
3086 expected_term
3087 .data_log_intensity_sum(&reference.default_params())
3088 .unwrap(),
3089 epsilon = 1.0e-12
3090 );
3091
3092 let stats = distributed.terms()[0]
3093 .as_intensity()
3094 .unwrap()
3095 .data()
3096 .unwrap()
3097 .stats();
3098 assert_eq!(stats.global_events(), values.len());
3099 assert!(stats.local_events() <= 1 || world.size() <= 2);
3100 }
3101
3102 #[cfg(all(feature = "mpi", feature = "wgpu"))]
3103 #[mpi_test::mpi_test(np = [2, 3])]
3104 fn mpi_wgpu_likelihood_matches_local_wgpu_reference_across_storage_modes() {
3105 use mpi::traits::Communicator;
3106
3107 let universe = mpi::initialize().unwrap();
3108 let world = universe.world();
3109 let values = [
3110 (0.15, 0.7),
3111 (0.35, 1.2),
3112 (0.65, 0.5),
3113 (0.95, 1.8),
3114 (1.25, 0.9),
3115 (1.55, 1.1),
3116 (1.85, 0.6),
3117 ];
3118 let accepted_values = [
3119 (0.25, 0.5),
3120 (0.55, 1.0),
3121 (0.85, 1.5),
3122 (1.15, 0.75),
3123 (1.45, 1.25),
3124 ];
3125 let data = weighted_dataset_batches(&values, &[2, 5, values.len()]);
3126 let accepted = weighted_dataset_batches(&accepted_values, &[1, 3, accepted_values.len()]);
3127 let streaming_data = data.clone().streaming();
3128 let streaming_accepted = accepted.clone().streaming();
3129 let x = event_scalar("x");
3130 let scale = laddu_expr::Expr::from(parameter!("scale", initial: 0.5));
3131 let offset = laddu_expr::Expr::from(parameter!("offset", initial: 1.25));
3132 let model = CompiledModel::from_expr(&((x * scale + offset + 2.0).powi(2) + 0.1)).unwrap();
3133
3134 let local = single_term_likelihood_with_execution(
3135 "local",
3136 &model,
3137 &data,
3138 &accepted,
3139 wgpu_execution(Some(256)),
3140 );
3141 let expected = local.nll_with_gradient(&local.default_params()).unwrap();
3142
3143 let make_distributed = |name, data: &Dataset, accepted: &Dataset| {
3144 single_term_likelihood_with_execution(
3145 name,
3146 &model,
3147 data,
3148 accepted,
3149 Execution::distributed(
3150 ExecutionOptions {
3151 device: Device::Gpu(GpuOptions {
3152 backend: GpuBackend::Wgpu,
3153 ..GpuOptions::default()
3154 }),
3155 memory: MemoryPlan::host_device(
3156 MemoryBudget::Auto,
3157 MemoryBudget::Bytes(256),
3158 ),
3159 precision: Precision::F32,
3160 partitioning: laddu_data::io::Partitioning::Contiguous,
3161 ..ExecutionOptions::default()
3162 },
3163 &world,
3164 )
3165 .unwrap(),
3166 )
3167 };
3168 let resident = make_distributed("resident", &data, &accepted);
3169 let streaming = make_distributed("streaming", &streaming_data, &streaming_accepted);
3170 let mixed = make_distributed("mixed", &data, &streaming_accepted);
3171
3172 for likelihood in [&resident, &streaming, &mixed] {
3173 let actual = likelihood
3174 .nll_with_gradient(&likelihood.default_params())
3175 .unwrap();
3176 assert_evaluation_close(&actual, &expected, 5.0e-4);
3177 }
3178
3179 let stats = resident.terms()[0]
3180 .as_intensity()
3181 .unwrap()
3182 .data()
3183 .unwrap()
3184 .stats();
3185 assert_eq!(stats.global_events(), values.len());
3186 assert!(stats.local_events() <= values.len().div_ceil(world.size() as usize));
3187 assert_eq!(stats.storage(), CacheStorage::Resident);
3188
3189 let streaming_stats = streaming.terms()[0]
3190 .as_intensity()
3191 .unwrap()
3192 .data()
3193 .unwrap()
3194 .stats();
3195 assert_eq!(streaming_stats.global_events(), values.len());
3196 assert_eq!(streaming_stats.storage(), CacheStorage::Streaming);
3197 assert_eq!(streaming_stats.resident_bytes(), 0);
3198 }
3199
3200 #[cfg(feature = "mpi")]
3201 #[mpi_test::mpi_test(np = [2, 3])]
3202 fn mpi_likelihood_propagates_a_rank_local_error_without_deadlocking() {
3203 let universe = mpi::initialize().unwrap();
3204 let world = universe.world();
3205 let data = weighted_dataset_batches(&[(-1.0, 1.0), (2.0, 1.0)], &[2]);
3206 let accepted_mc = weighted_dataset_batches(&[(1.0, 1.0), (2.0, 1.0)], &[2]);
3207 let model = CompiledModel::from_expr(&event_scalar("x")).unwrap();
3208 let likelihood = Likelihood::with_execution(
3209 [NllTerm::new("data", &model, &data, &accepted_mc).unwrap()],
3210 &Execution::distributed(ExecutionOptions::default(), &world).unwrap(),
3211 )
3212 .unwrap();
3213
3214 assert!(likelihood.nll(&likelihood.default_params()).is_err());
3215 }
3216
3217 #[test]
3218 fn shared_parameters_are_merged_across_independent_models() {
3219 let model_a =
3220 CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.5)))
3221 .unwrap();
3222 let model_b =
3223 CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.5)))
3224 .unwrap();
3225 let data_a = weighted_dataset(&[(2.0, 1.0), (3.0, 1.0)]);
3226 let accepted_a = weighted_dataset(&[(4.0, 1.0)]);
3227 let data_b = weighted_dataset(&[(5.0, 2.0)]);
3228 let accepted_b = weighted_dataset(&[(6.0, 3.0)]);
3229 let likelihood = Likelihood::new([
3230 NllTerm::new("KsKs", &model_a, &data_a, &accepted_a).unwrap(),
3231 NllTerm::new("eta_pi", &model_b, &data_b, &accepted_b).unwrap(),
3232 ])
3233 .unwrap();
3234
3235 assert_eq!(likelihood.params().len(), 1);
3236 assert_eq!(likelihood.params().specs()[0].name(), "scale");
3237
3238 let params = likelihood.default_params();
3239 let term_a = 2.0 * 2.0_f64.ln() - 1.0_f64.ln() - 1.5_f64.ln();
3240 let term_b = 2.0 * 9.0_f64.ln() - 2.0 * 2.5_f64.ln();
3241 assert_relative_eq!(likelihood.nll(¶ms).unwrap(), term_a + term_b);
3242 }
3243
3244 #[test]
3245 fn changing_shared_parameter_affects_all_terms() {
3246 let model_a =
3247 CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.5)))
3248 .unwrap();
3249 let model_b =
3250 CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.5)))
3251 .unwrap();
3252 let data = weighted_dataset(&[(2.0, 1.0)]);
3253 let accepted = weighted_dataset(&[(4.0, 1.0)]);
3254 let likelihood = Likelihood::new([
3255 NllTerm::new("a", &model_a, &data, &accepted).unwrap(),
3256 NllTerm::new("b", &model_b, &data, &accepted).unwrap(),
3257 ])
3258 .unwrap();
3259 let mut params = likelihood.default_params();
3260 params[0] = 1.0;
3261
3262 let expected_term = 1.0 * 4.0_f64.ln() - 2.0_f64.ln();
3263 assert_relative_eq!(likelihood.nll(¶ms).unwrap(), 2.0 * expected_term);
3264 }
3265
3266 #[test]
3267 fn shared_and_channel_specific_gradients_scatter_into_global_layout() {
3268 let shared = laddu_expr::Expr::from(parameter!("shared", initial: 0.4));
3269 let model_a = CompiledModel::from_expr(
3270 &(event_scalar("x")
3271 + shared.clone()
3272 + laddu_expr::Expr::from(parameter!("only_a", initial: 0.2)))
3273 .powi(2),
3274 )
3275 .unwrap();
3276 let model_b = CompiledModel::from_expr(
3277 &(event_scalar("x")
3278 + shared
3279 + laddu_expr::Expr::from(parameter!("only_b", initial: -0.1)))
3280 .powi(2),
3281 )
3282 .unwrap();
3283 let data = weighted_dataset(&[(0.5, 1.0), (1.2, 0.7)]);
3284 let accepted = weighted_dataset(&[(0.8, 1.3), (1.5, 0.9)]);
3285 let likelihood = Likelihood::new([
3286 NllTerm::new("a", &model_a, &data, &accepted).unwrap(),
3287 NllTerm::new("b", &model_b, &data, &accepted).unwrap(),
3288 ])
3289 .unwrap();
3290 let params = likelihood.default_params();
3291 let evaluation = likelihood.nll_with_gradient(¶ms).unwrap();
3292
3293 for (parameter, derivative) in evaluation.gradient().iter().enumerate() {
3294 assert_relative_eq!(
3295 *derivative,
3296 finite_difference_nll(&likelihood, ¶ms, parameter),
3297 epsilon = 1.0e-8
3298 );
3299 }
3300 }
3301
3302 #[test]
3303 fn incompatible_shared_parameter_specs_are_rejected() {
3304 let model_a =
3305 CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.5)))
3306 .unwrap();
3307 let model_b =
3308 CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 1.0)))
3309 .unwrap();
3310 let data = weighted_dataset(&[(2.0, 1.0)]);
3311 let accepted = weighted_dataset(&[(4.0, 1.0)]);
3312 let err = Likelihood::new([
3313 NllTerm::new("a", &model_a, &data, &accepted).unwrap(),
3314 NllTerm::new("b", &model_b, &data, &accepted).unwrap(),
3315 ])
3316 .unwrap_err();
3317
3318 assert!(matches!(
3319 err,
3320 LikelihoodError::Params(ParamError::ParameterConflict { ref name, .. })
3321 if name == "scale"
3322 ));
3323 }
3324
3325 #[test]
3326 fn unique_channel_parameters_remain_separate() {
3327 let model_a =
3328 CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale_ksks", initial: 0.5)))
3329 .unwrap();
3330 let model_b = CompiledModel::from_expr(
3331 &(event_scalar("x") * parameter!("scale_eta_pi", initial: 0.5)),
3332 )
3333 .unwrap();
3334 let data = weighted_dataset(&[(2.0, 1.0)]);
3335 let accepted = weighted_dataset(&[(4.0, 1.0)]);
3336 let likelihood = Likelihood::new([
3337 NllTerm::new("KsKs", &model_a, &data, &accepted).unwrap(),
3338 NllTerm::new("eta_pi", &model_b, &data, &accepted).unwrap(),
3339 ])
3340 .unwrap();
3341
3342 assert_eq!(likelihood.params().len(), 2);
3343 assert!(likelihood.params().id("scale_ksks").is_some());
3344 assert!(likelihood.params().id("scale_eta_pi").is_some());
3345 }
3346
3347 #[test]
3348 fn ridge_and_lasso_terms_add_penalties() {
3349 let model =
3350 CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.5)))
3351 .unwrap();
3352 let data = weighted_dataset(&[(2.0, 1.0), (3.0, 1.0)]);
3353 let accepted = weighted_dataset(&[(4.0, 1.0)]);
3354 let likelihood = Likelihood::new_boxed([
3355 NllTerm::new("data", &model, &data, &accepted)
3356 .unwrap()
3357 .boxed(),
3358 RidgePenalty::new("ridge", ["scale"], 2.0).unwrap().boxed(),
3359 LassoPenalty::new("lasso", ["scale"], 3.0).unwrap().boxed(),
3360 ])
3361 .unwrap();
3362 let params = likelihood.default_params();
3363
3364 let nll = 2.0 * 2.0_f64.ln() - 1.0_f64.ln() - 1.5_f64.ln();
3365 let penalty = 2.0 * 0.5_f64.powi(2) + 3.0 * 0.5_f64.abs();
3366 let result = likelihood.nll_with_gradient(¶ms).unwrap();
3367 assert_relative_eq!(result.value(), nll + penalty);
3368 assert_relative_eq!(result.gradient()[0], 5.0);
3369 }
3370
3371 #[test]
3372 fn penalty_terms_reject_missing_parameters() {
3373 let err =
3374 Likelihood::new([RidgePenalty::new("ridge", ["missing"], 1.0).unwrap()]).unwrap_err();
3375
3376 assert!(matches!(
3377 err,
3378 LikelihoodError::MissingParameter { ref term, ref parameter }
3379 if term == "ridge" && parameter == "missing"
3380 ));
3381 }
3382
3383 #[derive(Debug)]
3384 struct ConstantTerm {
3385 name: String,
3386 value: f64,
3387 }
3388
3389 #[derive(Debug)]
3390 struct BoundedQuadraticTerm {
3391 parameter: Parameter,
3392 id: Option<ParamId>,
3393 }
3394
3395 impl LikelihoodTerm for BoundedQuadraticTerm {
3396 fn name(&self) -> &str {
3397 "bounded-quadratic"
3398 }
3399
3400 fn register_params(&self, registry: &mut ParamRegistry) -> LikelihoodResult<()> {
3401 registry.register(self.parameter.clone())?;
3402 Ok(())
3403 }
3404
3405 fn resolve(
3406 &mut self,
3407 global_params: Arc<ParamLayout>,
3408 _execution: &Execution,
3409 ) -> LikelihoodResult<()> {
3410 self.id = global_params.id(self.parameter.name());
3411 Ok(())
3412 }
3413
3414 fn nll(&self, params: &ParamValues, _execution: &Execution) -> LikelihoodResult<f64> {
3415 let value = params.get(self.id.ok_or(LikelihoodError::ParameterLayoutMismatch)?)?;
3416 Ok((value - 2.0).powi(2))
3417 }
3418 }
3419
3420 impl LikelihoodTerm for ConstantTerm {
3421 fn name(&self) -> &str {
3422 &self.name
3423 }
3424
3425 fn resolve(
3426 &mut self,
3427 _global_params: Arc<ParamLayout>,
3428 _execution: &Execution,
3429 ) -> LikelihoodResult<()> {
3430 Ok(())
3431 }
3432
3433 fn nll(&self, _params: &ParamValues, _execution: &Execution) -> LikelihoodResult<f64> {
3434 Ok(self.value)
3435 }
3436 }
3437
3438 #[test]
3439 fn custom_likelihood_term_can_be_user_defined() {
3440 let model =
3441 CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.5)))
3442 .unwrap();
3443 let data = weighted_dataset(&[(2.0, 1.0)]);
3444 let accepted = weighted_dataset(&[(4.0, 1.0)]);
3445 let likelihood = Likelihood::new_boxed([
3446 NllTerm::new("data", &model, &data, &accepted)
3447 .unwrap()
3448 .boxed(),
3449 ConstantTerm {
3450 name: "constant".into(),
3451 value: 12.5,
3452 }
3453 .boxed(),
3454 ])
3455 .unwrap();
3456 let params = likelihood.default_params();
3457
3458 let expected = 1.0 * 2.0_f64.ln() - 1.0_f64.ln() + 12.5;
3459 assert_relative_eq!(likelihood.nll(¶ms).unwrap(), expected);
3460 }
3461
3462 #[test]
3463 fn custom_term_gradient_uses_bounded_finite_difference_fallback() {
3464 let likelihood = Likelihood::new([BoundedQuadraticTerm {
3465 parameter: Parameter::free("x")
3466 .with_initial(0.0)
3467 .with_bounds(Some(0.0), None),
3468 id: None,
3469 }])
3470 .unwrap();
3471 let params = likelihood.default_params();
3472 let evaluation = likelihood.nll_with_gradient(¶ms).unwrap();
3473
3474 assert_relative_eq!(evaluation.value(), 4.0);
3475 assert_relative_eq!(evaluation.gradient()[0], -4.0, epsilon = 1.0e-5);
3476 }
3477
3478 #[test]
3479 fn cross_section_integrals_use_named_intensity_term_and_global_params() {
3480 let model =
3481 CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 2.0)))
3482 .unwrap();
3483 let data = weighted_dataset(&[(9.0, 4.0)]);
3484 let accepted_mc = weighted_dataset(&[(1.0, 2.0), (2.0, 3.0)]);
3485 let generated_mc = weighted_dataset(&[(4.0, 5.0), (5.0, 7.0)]);
3486 let likelihood = single_term_likelihood("KsKs", &model, &data, &accepted_mc);
3487 let params = likelihood.default_params();
3488 let integrals = likelihood
3489 .cross_section_integrals("KsKs", &generated_mc)
3490 .unwrap();
3491
3492 let accepted = 2.0 * 2.0 + 3.0 * 4.0;
3493 let generated = 5.0 * 8.0 + 7.0 * 10.0;
3494 assert_eq!(integrals.name(), "KsKs");
3495 assert_relative_eq!(integrals.accepted_integral(¶ms).unwrap(), accepted);
3496 assert_relative_eq!(integrals.generated_integral(¶ms).unwrap(), generated);
3497 assert_relative_eq!(integrals.acceptance(¶ms).unwrap(), accepted / generated);
3498 assert_relative_eq!(
3499 integrals.acceptance_corrected_yield(¶ms, 20.0).unwrap(),
3500 20.0 * generated / accepted
3501 );
3502 assert_relative_eq!(
3503 integrals.cross_section(¶ms, 5.0).unwrap(),
3504 data.sum_weights().unwrap() * generated / accepted / 5.0
3505 );
3506 assert_eq!(integrals.accepted_intensities(¶ms).unwrap().len(), 2);
3507 assert_eq!(integrals.generated_intensities(¶ms).unwrap().len(), 2);
3508 }
3509
3510 #[test]
3511 fn bootstrap_rebuilds_likelihood_with_deterministic_poisson_data_weights() {
3512 let model =
3513 CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 2.0)))
3514 .unwrap();
3515 let data = weighted_dataset(&[(1.0, 1.0), (2.0, 1.0), (3.0, 1.0)]);
3516 let accepted = weighted_dataset(&[(1.0, 1.0)]);
3517 let likelihood = single_term_likelihood("signal", &model, &data, &accepted);
3518 let first = likelihood.bootstrap(42).unwrap();
3519 let second = likelihood.bootstrap(42).unwrap();
3520 let first_sum = first
3521 .intensity_datasets("signal")
3522 .unwrap()
3523 .0
3524 .sum_weights()
3525 .unwrap();
3526 let second_sum = second
3527 .intensity_datasets("signal")
3528 .unwrap()
3529 .0
3530 .sum_weights()
3531 .unwrap();
3532
3533 assert_eq!(first_sum, second_sum);
3534 assert_eq!(first.params().n_free(), likelihood.params().n_free());
3535 }
3536
3537 #[test]
3538 fn unresolved_nll_accessors_report_unresolved_state() {
3539 let model =
3540 CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 2.0)))
3541 .unwrap();
3542 let data = weighted_dataset(&[(1.0, 1.0)]);
3543 let term = NllTerm::new("signal", &model, &data, &data).unwrap();
3544
3545 assert!(
3546 matches!(term.data(), Err(LikelihoodError::UnresolvedTerm(name)) if name == "signal")
3547 );
3548 assert!(
3549 matches!(term.accepted_mc(), Err(LikelihoodError::UnresolvedTerm(name)) if name == "signal")
3550 );
3551 assert!(
3552 matches!(term.data_weight_sum(), Err(LikelihoodError::UnresolvedTerm(name)) if name == "signal")
3553 );
3554 }
3555
3556 #[test]
3557 fn failed_nll_resolution_does_not_leave_partial_preparation() {
3558 let model =
3559 CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 2.0)))
3560 .unwrap();
3561 let data = weighted_dataset(&[(1.0, 1.0)]);
3562 let mut term = NllTerm::new("signal", &model, &data, &data).unwrap();
3563 let empty_layout = Arc::new(ParamRegistry::new().layout().unwrap());
3564
3565 assert!(matches!(
3566 term.resolve(empty_layout, &Execution::default()),
3567 Err(LikelihoodError::MissingParameter { .. })
3568 ));
3569 assert!(
3570 matches!(term.data(), Err(LikelihoodError::UnresolvedTerm(name)) if name == "signal")
3571 );
3572
3573 term.resolve(Arc::new(model.params().clone()), &Execution::default())
3574 .unwrap();
3575 assert!(term.data().is_ok());
3576 assert!(term.data_weight_sum().is_ok());
3577 }
3578
3579 #[test]
3580 fn failed_nll_reresolution_preserves_prepared_state() {
3581 let model =
3582 CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 2.0)))
3583 .unwrap();
3584 let data = weighted_dataset(&[(1.0, 1.0), (2.0, 2.0)]);
3585 let mut term = NllTerm::new("signal", &model, &data, &data).unwrap();
3586 let execution = Execution::default();
3587 term.resolve(Arc::new(model.params().clone()), &execution)
3588 .unwrap();
3589 let params = model.params().default_values();
3590 let value = term.nll(¶ms, &execution).unwrap();
3591 let stats = *term.data().unwrap().stats();
3592 let mut diagnostics = Vec::new();
3593 term.append_diagnostics(&mut diagnostics);
3594
3595 assert!(matches!(
3596 term.resolve(Arc::new(ParamRegistry::new().layout().unwrap()), &execution),
3597 Err(LikelihoodError::MissingParameter { .. })
3598 ));
3599 assert_eq!(term.nll(¶ms, &execution).unwrap(), value);
3600 assert_eq!(*term.data().unwrap().stats(), stats);
3601 let mut after_diagnostics = Vec::new();
3602 term.append_diagnostics(&mut after_diagnostics);
3603 assert_eq!(after_diagnostics, diagnostics);
3604 }
3605
3606 #[test]
3607 fn prepared_bootstrap_preserves_normalization_and_dataset_diagnostics() {
3608 let model =
3609 CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 2.0)))
3610 .unwrap();
3611 let data = weighted_dataset(&[(1.0, 1.0), (2.0, 1.0), (3.0, 1.0)]);
3612 let accepted = weighted_dataset(&[(1.0, 1.0), (2.0, 2.0)]);
3613 let executions = [
3614 Execution::default(),
3615 Execution::local(ExecutionOptions {
3616 normalization: NormalizationMode::General,
3617 ..ExecutionOptions::default()
3618 })
3619 .unwrap(),
3620 ];
3621
3622 for execution in executions {
3623 let likelihood = single_term_likelihood_with_execution(
3624 "signal", &model, &data, &accepted, execution,
3625 );
3626 let baseline = likelihood
3627 .diagnostics()
3628 .datasets()
3629 .iter()
3630 .find(|dataset| dataset.role() == DatasetRole::AcceptedMc)
3631 .unwrap()
3632 .clone();
3633 let first = likelihood.bootstrap(41).unwrap();
3634 let second = first.bootstrap(42).unwrap();
3635
3636 for replica in [&first, &second] {
3637 let diagnostics = replica.diagnostics();
3638 let accepted_diagnostics = diagnostics
3639 .datasets()
3640 .iter()
3641 .find(|dataset| dataset.role() == DatasetRole::AcceptedMc)
3642 .unwrap();
3643 assert_eq!(accepted_diagnostics, &baseline);
3644 assert_eq!(
3645 accepted_diagnostics.source_traversals(),
3646 baseline.source_traversals()
3647 );
3648 let term = replica.terms()[0].as_intensity().unwrap();
3649 if baseline.uses_quadratic_normalization() {
3650 assert!(term.accepted_mc().is_err());
3651 } else {
3652 assert_eq!(term.accepted_mc().unwrap().stats(), baseline.stats());
3653 }
3654 }
3655 }
3656 }
3657
3658 #[test]
3659 fn coherent_quadratic_normalization_matches_general_event_reduction() {
3660 let coefficient = complex(
3661 parameter!("coefficient_re", initial: 0.7),
3662 parameter!("coefficient_im", initial: -0.2),
3663 );
3664 let basis = complex(event_scalar("x"), 0.5);
3665 let model = CompiledModel::from_expr(&(coefficient * basis).norm_sqr()).unwrap();
3666 let sample = weighted_dataset(&[(0.5, 1.0), (1.5, 2.0), (2.5, 0.75)]);
3667 let likelihood = single_term_likelihood("quadratic", &model, &sample, &sample);
3668 let term = likelihood.terms()[0].as_intensity().unwrap();
3669 let free = vec![0.4, -0.6];
3670 let global = term.global_values(&free).unwrap();
3671 let local = term.local_values(&global).unwrap();
3672
3673 let optimized = term
3674 .normalization_with_gradient(&local, likelihood.execution())
3675 .unwrap();
3676 let general_dataset = term
3677 .plan()
3678 .unwrap()
3679 .prepare_dataset(likelihood.execution(), &term.accepted_mc_source)
3680 .unwrap();
3681 let general = term
3682 .plan()
3683 .unwrap()
3684 .reduce_with_gradient(
3685 likelihood.execution(),
3686 &local,
3687 &general_dataset,
3688 ReductionPlan::weighted_positive_real(),
3689 )
3690 .unwrap()
3691 .into_parts();
3692 assert!((optimized.0 - general.0).abs() < 1.0e-12);
3693 for (optimized, general) in optimized.1.iter().zip(general.1) {
3694 assert!((*optimized - general).abs() < 1.0e-12);
3695 }
3696 assert!(
3697 likelihood
3698 .diagnostics()
3699 .datasets()
3700 .iter()
3701 .any(DatasetDiagnostics::uses_quadratic_normalization)
3702 );
3703 }
3704
3705 #[test]
3706 fn packed_hermitian_normalization_matches_general_interference() {
3707 let first = complex(
3708 parameter!("first_re", initial: 0.7),
3709 parameter!("first_im", initial: -0.2),
3710 ) * complex(event_scalar("x"), 0.5);
3711 let second = complex(
3712 parameter!("second_re", initial: -0.3),
3713 parameter!("second_im", initial: 0.4),
3714 ) * complex(event_scalar("x").powi(2), -0.25);
3715 let model = CompiledModel::from_expr(&(first + second).norm_sqr()).unwrap();
3716 assert_eq!(
3717 model.normalization_diagnostics().strategy(),
3718 laddu_compile::NormalizationStrategy::Hermitian
3719 );
3720 assert_eq!(model.normalization_diagnostics().basis_count(), 3);
3721 let sample = weighted_dataset(&[(0.5, 1.0), (1.5, -0.2), (2.5, 0.75)]);
3722 let likelihood = single_term_likelihood("hermitian", &model, &sample, &sample);
3723 let term = likelihood.terms()[0].as_intensity().unwrap();
3724 let free = vec![0.4, -0.6, 0.2, 0.8];
3725 let global = term.global_values(&free).unwrap();
3726 let local = term.local_values(&global).unwrap();
3727 let optimized = term
3728 .normalization_with_gradient(&local, likelihood.execution())
3729 .unwrap();
3730 let general_dataset = term
3731 .plan()
3732 .unwrap()
3733 .prepare_dataset(likelihood.execution(), &term.accepted_mc_source)
3734 .unwrap();
3735 let general = term
3736 .plan()
3737 .unwrap()
3738 .reduce_with_gradient(
3739 likelihood.execution(),
3740 &local,
3741 &general_dataset,
3742 ReductionPlan::weighted_positive_real(),
3743 )
3744 .unwrap()
3745 .into_parts();
3746 assert_relative_eq!(optimized.0, general.0, epsilon = 1.0e-11);
3747 for (optimized, general) in optimized.1.iter().zip(general.1) {
3748 assert_relative_eq!(optimized, &general, epsilon = 1.0e-10);
3749 }
3750 }
3751
3752 #[test]
3753 fn normalization_mode_general_forces_event_reduction() {
3754 let scale = parameter!("scale", initial: 0.7);
3755 let model = CompiledModel::from_expr(&(scale * event_scalar("x")).powi(2)).unwrap();
3756 let sample = weighted_dataset(&[(0.5, 1.0), (1.5, 2.0)]);
3757 let execution = Execution::local(ExecutionOptions {
3758 normalization: NormalizationMode::General,
3759 ..ExecutionOptions::default()
3760 })
3761 .unwrap();
3762 let likelihood =
3763 single_term_likelihood_with_execution("general", &model, &sample, &sample, execution);
3764 let diagnostics = likelihood.diagnostics();
3765 let accepted = diagnostics
3766 .datasets()
3767 .iter()
3768 .find(|dataset| dataset.role() == DatasetRole::AcceptedMc)
3769 .unwrap();
3770 assert!(!accepted.uses_quadratic_normalization());
3771 assert_eq!(
3772 accepted.normalization().unwrap().strategy(),
3773 laddu_compile::NormalizationStrategy::General
3774 );
3775 }
3776
3777 #[test]
3778 fn verify_mode_checks_hybrid_values_and_gradients() {
3779 let scale = parameter!("scale", initial: 0.2);
3780 let mixed = (scale.clone() * event_scalar("x")).sin();
3781 let separable = scale * (event_scalar("x") + 2.0);
3782 let model = CompiledModel::from_expr(&(separable + mixed + 3.0)).unwrap();
3783 let sample = weighted_dataset(&[(0.2, 1.0), (0.8, -0.25), (1.4, 2.0)]);
3784 let execution = Execution::local(ExecutionOptions {
3785 normalization: NormalizationMode::Verify,
3786 ..ExecutionOptions::default()
3787 })
3788 .unwrap();
3789 let likelihood =
3790 single_term_likelihood_with_execution("hybrid", &model, &sample, &sample, execution);
3791 let evaluation = likelihood
3792 .nll_with_gradient(&likelihood.default_params())
3793 .unwrap();
3794 assert!(evaluation.value().is_finite());
3795 let diagnostics = likelihood.diagnostics();
3796 let normalization = diagnostics
3797 .datasets()
3798 .iter()
3799 .find_map(DatasetDiagnostics::normalization)
3800 .unwrap();
3801 assert_eq!(
3802 normalization.compiler().strategy(),
3803 laddu_compile::NormalizationStrategy::Hybrid
3804 );
3805 assert!(normalization.compiler().has_residual());
3806 }
3807
3808 #[test]
3809 fn normalization_preparation_cache_reuses_dataset_statistics() {
3810 let scale = parameter!("scale", initial: 0.4);
3811 let model = CompiledModel::from_expr(&(scale * event_scalar("x")).powi(2)).unwrap();
3812 let first_data = weighted_dataset(&[(0.5, 1.0)]);
3813 let second_data = weighted_dataset(&[(0.7, 1.0)]);
3814 let accepted = weighted_dataset(&[(0.5, 1.0), (1.5, 2.0), (2.5, 0.75)]);
3815 let execution = Execution::default();
3816 let likelihood = Likelihood::with_execution(
3817 [
3818 NllTerm::new("first", &model, &first_data, &accepted).unwrap(),
3819 NllTerm::new("second", &model, &second_data, &accepted).unwrap(),
3820 ],
3821 &execution,
3822 )
3823 .unwrap();
3824
3825 let diagnostics = likelihood.diagnostics();
3826 let accepted_diagnostics = diagnostics
3827 .datasets()
3828 .iter()
3829 .filter(|dataset| dataset.role() == DatasetRole::AcceptedMc)
3830 .collect::<Vec<_>>();
3831 assert_eq!(accepted_diagnostics.len(), 2);
3832 assert_eq!(accepted.source_traversals(), 1);
3833 assert!(accepted_diagnostics.iter().all(|dataset| {
3834 dataset
3835 .normalization()
3836 .is_some_and(PreparedNormalizationDiagnostics::cache_hit)
3837 }));
3838 }
3839
3840 #[test]
3841 fn cross_section_integrals_reject_non_intensity_terms() {
3842 let model =
3843 CompiledModel::from_expr(&(event_scalar("x") * parameter!("scale", initial: 0.5)))
3844 .unwrap();
3845 let data = weighted_dataset(&[(2.0, 1.0)]);
3846 let accepted = weighted_dataset(&[(4.0, 1.0)]);
3847 let generated = weighted_dataset(&[(5.0, 1.0)]);
3848 let likelihood = Likelihood::new_boxed([
3849 NllTerm::new("data", &model, &data, &accepted)
3850 .unwrap()
3851 .boxed(),
3852 RidgePenalty::new("ridge", ["scale"], 1.0).unwrap().boxed(),
3853 ])
3854 .unwrap();
3855 let err = likelihood
3856 .cross_section_integrals("ridge", &generated)
3857 .unwrap_err();
3858
3859 assert!(matches!(err, LikelihoodError::NotIntensityTerm(ref name) if name == "ridge"));
3860 }
3861
3862 #[cfg(feature = "wgpu")]
3863 #[test]
3864 fn wgpu_scalar_likelihood_matches_cpu_across_storage_modes() {
3865 let x = event_scalar("x");
3866 let scale = laddu_expr::Expr::from(parameter!("scale", initial: 0.5));
3867 let offset = laddu_expr::Expr::from(parameter!("offset", initial: 1.25));
3868 let model = CompiledModel::from_expr(&(x * scale + offset + 2.0)).unwrap();
3869 let data = weighted_dataset_batches(
3870 &(0..70)
3871 .map(|index| (index as f64 * 0.01, 1.0 + index as f64 * 0.001))
3872 .collect::<Vec<_>>(),
3873 &[31, 70],
3874 );
3875 let accepted = weighted_dataset_batches(&[(0.25, 0.5), (0.75, 1.5), (1.25, 2.0)], &[1, 3]);
3876 let streaming_data = data.clone().streaming();
3877 let streaming_accepted = accepted.clone().streaming();
3878 let cpu = single_term_likelihood_with_execution(
3879 "scalar",
3880 &model,
3881 &data,
3882 &accepted,
3883 cpu_execution(Precision::F32, ThreadPolicy::Auto, JitPolicy::Disabled),
3884 );
3885 let params = cpu.default_params();
3886 let expected = cpu.nll_with_gradient(¶ms).unwrap();
3887 let (first, second) = {
3888 let gpu = single_term_likelihood_with_execution(
3889 "resident",
3890 &model,
3891 &data,
3892 &accepted,
3893 wgpu_execution(Some(256)),
3894 );
3895 (
3896 gpu.nll_with_gradient(¶ms).unwrap(),
3897 gpu.nll_with_gradient(¶ms).unwrap(),
3898 )
3899 };
3900
3901 assert_evaluation_close(&first, &expected, 5.0e-4);
3902 assert_eq!(second, first);
3903
3904 let streaming_gradient = {
3905 let streaming_gpu = single_term_likelihood_with_execution(
3906 "streaming",
3907 &model,
3908 &streaming_data,
3909 &streaming_accepted,
3910 wgpu_execution(Some(256)),
3911 );
3912 let streaming_gradient = streaming_gpu.nll_with_gradient(¶ms).unwrap();
3913 let streaming_term = streaming_gpu.terms()[0].as_intensity().unwrap();
3914 let streaming_data_stats = streaming_term.data().unwrap().stats();
3915 let streaming_accepted_stats = streaming_term.accepted_mc().unwrap().stats();
3916 assert_eq!(streaming_data_stats.storage(), CacheStorage::Streaming);
3917 assert_eq!(streaming_data_stats.resident_bytes(), 0);
3918 assert_eq!(streaming_data_stats.local_batches(), 2);
3919 assert_eq!(streaming_accepted_stats.storage(), CacheStorage::Streaming);
3920 assert_eq!(streaming_accepted_stats.resident_bytes(), 0);
3921 assert_eq!(streaming_accepted_stats.local_batches(), 2);
3922 streaming_gradient
3923 };
3924
3925 assert_evaluation_close(&streaming_gradient, &expected, 5.0e-4);
3926
3927 let mixed_gradient = {
3928 let mixed_gpu = single_term_likelihood_with_execution(
3929 "mixed",
3930 &model,
3931 &data,
3932 &streaming_accepted,
3933 wgpu_execution(Some(256)),
3934 );
3935 mixed_gpu.nll_with_gradient(¶ms).unwrap()
3936 };
3937 assert_evaluation_close(&mixed_gradient, &expected, 5.0e-4);
3938 assert_evaluation_close(&streaming_gradient, &first, 2.0e-5);
3939 assert_evaluation_close(&mixed_gradient, &first, 2.0e-5);
3940
3941 let cpu_f64 = single_term_likelihood_with_execution(
3942 "scalar",
3943 &model,
3944 &data,
3945 &accepted,
3946 cpu_execution(Precision::F64, ThreadPolicy::Auto, JitPolicy::Disabled),
3947 );
3948 let expected_gradient = cpu_f64
3949 .nll_with_gradient(&cpu_f64.default_params())
3950 .unwrap();
3951 assert_evaluation_close(&first, &expected_gradient, 5.0e-4);
3952 }
3953
3954 #[cfg(feature = "wgpu")]
3955 #[test]
3956 #[ignore = "requires a WGPU-compatible hardware adapter"]
3957 fn wgpu_aggregate_likelihood_matches_cpu() {
3958 let expression = dot(
3959 matvec(
3960 matrix([[event_scalar("x"), event_scalar("x") + 1.0]]),
3961 vector([
3962 parameter!("a", initial: 0.5),
3963 parameter!("b", initial: 1.25),
3964 ]),
3965 ),
3966 vector([1.0]),
3967 )
3968 .norm_sqr();
3969 let model = CompiledModel::from_expr_with_options(
3970 &expression,
3971 &CompileOptions::without_optimizations(),
3972 )
3973 .unwrap();
3974 let data = weighted_dataset(&[(0.25, 0.5), (0.75, 1.5), (1.25, 2.0)]);
3975 let cpu = single_term_likelihood_with_execution(
3976 "aggregate",
3977 &model,
3978 &data,
3979 &data,
3980 Execution::local(ExecutionOptions {
3981 device: Device::Cpu(CpuOptions::default()),
3982 precision: Precision::F64,
3983 ..ExecutionOptions::default()
3984 })
3985 .unwrap(),
3986 );
3987 let gpu = single_term_likelihood_with_execution(
3988 "aggregate",
3989 &model,
3990 &data,
3991 &data,
3992 Execution::local(ExecutionOptions {
3993 device: Device::Gpu(GpuOptions {
3994 backend: GpuBackend::Wgpu,
3995 ..GpuOptions::default()
3996 }),
3997 memory: MemoryPlan::host_device(MemoryBudget::Auto, MemoryBudget::Bytes(256)),
3998 precision: Precision::F32,
3999 ..ExecutionOptions::default()
4000 })
4001 .unwrap(),
4002 );
4003 let params = cpu.default_params();
4004
4005 assert_relative_eq!(
4006 gpu.nll(¶ms).unwrap(),
4007 cpu.nll(¶ms).unwrap(),
4008 epsilon = 2.0e-5
4009 );
4010 }
4011
4012 #[test]
4013 fn tagged_projection_produces_partial_weights_and_cross_sections() {
4014 let x = event_scalar("x");
4015 let selected = (Expr::from(parameter!("a", initial: 2.0)) * x.clone()).tagged("selected");
4016 let removed = Expr::from(parameter!("b", initial: 1.0)).tagged("removed");
4017 let model = CompiledModel::from_expr(&(selected + removed).norm_sqr()).unwrap();
4018 let data = weighted_dataset(&[(1.0, 3.0)]);
4019 let accepted = weighted_dataset(&[(1.0, 1.0)]);
4020 let generated = weighted_dataset(&[(2.0, 1.0)]);
4021 let likelihood =
4022 Likelihood::new([NllTerm::new("waves", &model, &data, &accepted).unwrap()]).unwrap();
4023 let params = likelihood.default_params();
4024 let projection = likelihood
4025 .projection("waves", &generated, ["selected"])
4026 .unwrap();
4027 let integrals = likelihood
4028 .cross_section_integrals_with_tags("waves", &generated, ["selected"])
4029 .unwrap();
4030
4031 assert_relative_eq!(projection.full_accepted_integral(¶ms).unwrap(), 9.0);
4032 assert_relative_eq!(projection.generated_integral(¶ms).unwrap(), 16.0);
4033 assert_relative_eq!(projection.acceptance(¶ms).unwrap(), 0.25);
4034 assert_relative_eq!(projection.intensities(¶ms).unwrap()[0], 16.0);
4035 assert_relative_eq!(
4036 projection.acceptance_corrected_yield(¶ms).unwrap(),
4037 16.0 / 3.0
4038 );
4039 assert_relative_eq!(projection.weights(¶ms, false).unwrap()[0], 16.0);
4040 assert_relative_eq!(projection.weights(¶ms, true).unwrap()[0], 16.0 / 3.0);
4041 assert_relative_eq!(integrals.accepted_integral(¶ms).unwrap(), 4.0);
4042 assert_relative_eq!(integrals.generated_integral(¶ms).unwrap(), 16.0);
4043 assert_relative_eq!(integrals.acceptance(¶ms).unwrap(), 0.25);
4044 assert_relative_eq!(integrals.full_accepted_integral(¶ms).unwrap(), 9.0);
4045 assert_relative_eq!(
4046 integrals.acceptance_corrected_yield(¶ms, 12.0).unwrap(),
4047 48.0
4048 );
4049 assert_relative_eq!(integrals.cross_section(¶ms, 2.0).unwrap(), 8.0 / 3.0);
4050 }
4051
4052 #[cfg(feature = "wgpu")]
4053 #[test]
4054 #[ignore = "requires a WGPU-compatible hardware adapter"]
4055 fn explicit_wgpu_solve_likelihood_value_and_gradient_match_cpu() {
4056 let x = event_scalar("x");
4057 let amplitude = laddu_amplitudes::f_vector(
4058 x.clone() + 4.0,
4059 vector([1.0]),
4060 matrix([[x.clone() + 0.5, 0.25.into()], [0.1.into(), x + 1.0]]),
4061 vector([Expr::from(parameter!("scale", initial: 1.25)), 2.0.into()]),
4062 matrix([
4063 [complex(0.0, -0.2), 0.0.into()],
4064 [0.0.into(), complex(0.0, -0.1)],
4065 ]),
4066 )
4067 .unwrap()
4068 .component(0)
4069 .norm_sqr();
4070 let model = CompiledModel::from_expr(&litude).unwrap();
4071 let data = weighted_dataset(&[(0.5, 1.0), (1.0, 0.75), (1.5, 1.25)]);
4072 let accepted = weighted_dataset(&[(0.25, 0.5), (0.75, 1.0), (1.25, 1.5)]);
4073 let make = |device, precision| {
4074 single_term_likelihood_with_execution(
4075 "solve",
4076 &model,
4077 &data,
4078 &accepted,
4079 Execution::local(ExecutionOptions {
4080 device,
4081 precision,
4082 ..ExecutionOptions::default()
4083 })
4084 .unwrap(),
4085 )
4086 };
4087 let cpu = make(Device::Cpu(CpuOptions::default()), Precision::F64);
4088 let gpu = make(
4089 Device::Gpu(GpuOptions {
4090 backend: GpuBackend::Wgpu,
4091 ..GpuOptions::default()
4092 }),
4093 Precision::F32,
4094 );
4095 let params = cpu.default_params();
4096 let expected = cpu.nll_with_gradient(¶ms).unwrap();
4097 let actual = gpu.nll_with_gradient(¶ms).unwrap();
4098 assert_relative_eq!(actual.value(), expected.value(), epsilon = 2.0e-4);
4099 assert_relative_eq!(
4100 actual.gradient()[0],
4101 expected.gradient()[0],
4102 epsilon = 5.0e-4
4103 );
4104 }
4105}