-
Notifications
You must be signed in to change notification settings - Fork 0
feat(longitudinal): keep unit means out of within-unit change #78
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
21b567b
5c304c3
b266232
b64c679
605daf4
93cc133
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| [package] | ||
| name = "longitudinal_core" | ||
| description = "Within/between decomposition gates and component RMSE." | ||
| version.workspace = true | ||
| edition.workspace = true | ||
| rust-version.workspace = true | ||
| license.workspace = true | ||
| authors.workspace = true | ||
| repository.workspace = true | ||
| homepage.workspace = true | ||
| readme.workspace = true | ||
| keywords.workspace = true | ||
| categories.workspace = true | ||
| publish = false | ||
|
|
||
| [lints] | ||
| workspace = true |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,185 @@ | ||
| //! Known-truth RMSE for within/between components. | ||
|
|
||
| use crate::{ComponentLevel, LongitudinalError}; | ||
|
|
||
| /// One unit-specific within or between component. | ||
| #[derive(Clone, Copy, Debug, PartialEq)] | ||
| pub struct ComponentValue { | ||
| unit_index: u32, | ||
| occasion_index: u32, | ||
| level: ComponentLevel, | ||
| value: f64, | ||
| } | ||
|
|
||
| impl ComponentValue { | ||
| /// Construct a component record from its identity fields and raw value. | ||
| /// | ||
| /// The value is stored exactly as given, including non-finite values; | ||
| /// this constructor performs no validation. | ||
| #[must_use] | ||
| pub const fn new( | ||
| unit_index: u32, | ||
| occasion_index: u32, | ||
| level: ComponentLevel, | ||
| value: f64, | ||
| ) -> Self { | ||
| Self { | ||
| unit_index, | ||
| occasion_index, | ||
| level, | ||
| value, | ||
| } | ||
| } | ||
|
|
||
| /// Return the unit index. | ||
| #[must_use] | ||
| pub const fn unit_index(self) -> u32 { | ||
| self.unit_index | ||
| } | ||
|
|
||
| /// Return the occasion index. | ||
| #[must_use] | ||
| pub const fn occasion_index(self) -> u32 { | ||
| self.occasion_index | ||
| } | ||
|
|
||
| /// Return the component level. | ||
| #[must_use] | ||
| pub const fn level(self) -> ComponentLevel { | ||
| self.level | ||
| } | ||
|
|
||
| /// Return the component value. | ||
| #[must_use] | ||
| pub const fn value(self) -> f64 { | ||
| self.value | ||
| } | ||
| } | ||
|
|
||
| /// RMSE of recovered components against known-truth components. | ||
| /// | ||
| /// The sum of squared residuals is accumulated with max-magnitude scaling so | ||
| /// large finite residuals cannot overflow to infinity. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`LongitudinalError::InvalidComponentPayload`] when either slice is | ||
| /// empty, the lengths differ, a unit/occasion/level identity mismatches, a | ||
| /// value or a computed residual is non-finite. | ||
| pub fn component_root_mean_square_error( | ||
| truth: &[ComponentValue], | ||
| decided: &[ComponentValue], | ||
| ) -> Result<f64, LongitudinalError> { | ||
| if truth.is_empty() || truth.len() != decided.len() { | ||
| return Err(LongitudinalError::InvalidComponentPayload); | ||
| } | ||
| let mut scale = 0.0_f64; | ||
| let mut scaled_sum_squares = 0.0_f64; | ||
| for (truth_row, decided_row) in truth.iter().zip(decided) { | ||
| if truth_row.unit_index() != decided_row.unit_index() | ||
| || truth_row.occasion_index() != decided_row.occasion_index() | ||
| || truth_row.level() != decided_row.level() | ||
| || !truth_row.value().is_finite() | ||
| || !decided_row.value().is_finite() | ||
| { | ||
| return Err(LongitudinalError::InvalidComponentPayload); | ||
| } | ||
| let residual = decided_row.value() - truth_row.value(); | ||
| if !residual.is_finite() { | ||
| return Err(LongitudinalError::InvalidComponentPayload); | ||
| } | ||
| let magnitude = residual.abs(); | ||
| if magnitude > scale { | ||
| let ratio = scale / magnitude; | ||
| scaled_sum_squares = 1.0 + scaled_sum_squares * ratio * ratio; | ||
| scale = magnitude; | ||
| } else if scale > 0.0 { | ||
| let ratio = magnitude / scale; | ||
| scaled_sum_squares += ratio * ratio; | ||
| } | ||
| } | ||
| Ok(scale * (scaled_sum_squares / truth.len() as f64).sqrt()) | ||
|
Comment on lines
+76
to
+101
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: RMSE scaled accumulator is overflow-safe The max-magnitude rescaling in Was this helpful? React with 👍 or 👎 to provide feedback.
Comment on lines
+69
to
+101
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: RMSE mixes between and within components in one denominator
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::{ComponentValue, component_root_mean_square_error}; | ||
| use crate::{ComponentLevel, LongitudinalError}; | ||
|
|
||
| #[test] | ||
| fn maximal_residuals_do_not_overflow() { | ||
| let truth = [ | ||
| ComponentValue::new(0, 0, ComponentLevel::Between, 0.0), | ||
| ComponentValue::new(1, 0, ComponentLevel::Between, 0.0), | ||
| ]; | ||
| let maxed = [ | ||
| ComponentValue::new(0, 0, ComponentLevel::Between, f64::MAX), | ||
| ComponentValue::new(1, 0, ComponentLevel::Between, f64::MAX), | ||
| ]; | ||
| assert_eq!( | ||
| component_root_mean_square_error(&truth, &maxed), | ||
| Ok(f64::MAX) | ||
| ); | ||
| let partial_extreme = [ | ||
| ComponentValue::new(0, 0, ComponentLevel::Between, f64::MAX), | ||
| ComponentValue::new(1, 0, ComponentLevel::Between, 0.0), | ||
| ]; | ||
| let expected = f64::MAX / f64::sqrt(2.0); | ||
| let got = component_root_mean_square_error(&truth, &partial_extreme).expect("scaled rmse"); | ||
| assert!((got - expected).abs() <= expected * 4.0 * f64::EPSILON); | ||
| } | ||
|
|
||
| #[test] | ||
| fn overflowing_residual_fails_closed() { | ||
| let truth = [ComponentValue::new(0, 0, ComponentLevel::Within, -f64::MAX)]; | ||
| let decided = [ComponentValue::new(0, 0, ComponentLevel::Within, f64::MAX)]; | ||
| assert_eq!( | ||
| component_root_mean_square_error(&truth, &decided), | ||
| Err(LongitudinalError::InvalidComponentPayload) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn mismatched_identity_and_nan_fail_closed() { | ||
| let truth = [ComponentValue::new(0, 0, ComponentLevel::Between, 0.5)]; | ||
| let other_unit = [ComponentValue::new(1, 0, ComponentLevel::Between, 0.5)]; | ||
| assert_eq!( | ||
| component_root_mean_square_error(&truth, &other_unit), | ||
| Err(LongitudinalError::InvalidComponentPayload) | ||
| ); | ||
| let other_level = [ComponentValue::new(0, 0, ComponentLevel::Within, 0.5)]; | ||
| assert_eq!( | ||
| component_root_mean_square_error(&truth, &other_level), | ||
| Err(LongitudinalError::InvalidComponentPayload) | ||
| ); | ||
| let other_occasion = [ComponentValue::new(0, 1, ComponentLevel::Between, 0.5)]; | ||
| assert_eq!( | ||
| component_root_mean_square_error(&truth, &other_occasion), | ||
| Err(LongitudinalError::InvalidComponentPayload) | ||
| ); | ||
| let nan = [ComponentValue::new(0, 0, ComponentLevel::Between, f64::NAN)]; | ||
| assert_eq!( | ||
| component_root_mean_square_error(&truth, &nan), | ||
| Err(LongitudinalError::InvalidComponentPayload) | ||
| ); | ||
| let nan_truth = [ComponentValue::new(0, 0, ComponentLevel::Between, f64::NAN)]; | ||
| assert_eq!( | ||
| component_root_mean_square_error(&nan_truth, &truth), | ||
| Err(LongitudinalError::InvalidComponentPayload) | ||
| ); | ||
| assert_eq!( | ||
| component_root_mean_square_error(&truth, &[]), | ||
| Err(LongitudinalError::InvalidComponentPayload) | ||
| ); | ||
| let valid = [ComponentValue::new(0, 0, ComponentLevel::Between, 0.5)]; | ||
| assert_eq!(component_root_mean_square_error(&truth, &valid), Ok(0.0)); | ||
| assert_eq!( | ||
| component_root_mean_square_error(&[], &valid), | ||
| Err(LongitudinalError::InvalidComponentPayload) | ||
| ); | ||
| assert_eq!( | ||
| ComponentValue::new(2, 3, ComponentLevel::Within, 0.1).occasion_index(), | ||
| 3 | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Duplicated, contradictory implementation-state paragraph
The new implementation-state paragraph is added while the old one is kept directly below it, so the section now claims both that
longitudinal_coreexposes production APIs and that every crate exposes none. Both paragraphs say "eleven bounded crates", but the list below enumerates 16.(Refers to this code)
Was this helpful? React with 👍 or 👎 to provide feedback.