kestrel_chartkit/
stats.rs1pub fn rolling_sum(slice: &[f64]) -> f64 {
5 slice.iter().copied().filter(|v| v.is_finite()).sum()
6}
7
8pub fn rolling_mean(slice: &[f64]) -> f64 {
10 let finite: Vec<f64> = slice.iter().copied().filter(|v| v.is_finite()).collect();
11 if finite.is_empty() {
12 0.0
13 } else {
14 finite.iter().sum::<f64>() / finite.len() as f64
15 }
16}
17
18pub fn rolling_variance(slice: &[f64]) -> f64 {
20 let finite: Vec<f64> = slice.iter().copied().filter(|v| v.is_finite()).collect();
21 if finite.len() < 2 {
22 return 0.0;
23 }
24 let mean = finite.iter().sum::<f64>() / finite.len() as f64;
25 let var_sum: f64 = finite.iter().map(|v| (v - mean).powi(2)).sum();
26 var_sum / finite.len() as f64
27}
28
29pub fn rolling_stddev(slice: &[f64]) -> f64 {
31 rolling_variance(slice).sqrt()
32}
33
34pub fn rolling_median(slice: &[f64]) -> f64 {
36 rolling_quantile(slice, 0.5)
37}
38
39pub fn rolling_quantile(slice: &[f64], quantile: f64) -> f64 {
41 let mut finite: Vec<f64> = slice.iter().copied().filter(|v| v.is_finite()).collect();
42 if finite.is_empty() {
43 return 0.0;
44 }
45 finite.sort_by(f64::total_cmp);
46
47 let q = quantile.clamp(0.0, 1.0);
48 let max_idx = finite.len().saturating_sub(1);
49 let idx_f = q * max_idx as f64;
50 let raw_lower = idx_f.floor();
51 let raw_upper = idx_f.ceil();
52 let idx_lower = if raw_lower.is_finite() && raw_lower >= 0.0 {
53 (raw_lower as usize).min(max_idx)
54 } else {
55 0
56 };
57 let idx_upper = if raw_upper.is_finite() && raw_upper >= 0.0 {
58 (raw_upper as usize).min(max_idx)
59 } else {
60 0
61 };
62
63 let v_lower = finite.get(idx_lower).copied().unwrap_or(0.0);
64 let v_upper = finite.get(idx_upper).copied().unwrap_or(0.0);
65
66 if idx_lower == idx_upper {
67 v_lower
68 } else {
69 let weight = idx_f - idx_lower as f64;
70 v_lower * (1.0 - weight) + v_upper * weight
71 }
72}
73
74pub fn percent_rank(slice: &[f64], val: f64) -> f64 {
76 let finite: Vec<f64> = slice.iter().copied().filter(|v| v.is_finite()).collect();
77 if finite.is_empty() || !val.is_finite() {
78 return 0.0;
79 }
80 let count_below = finite.iter().filter(|&&v| v <= val).count();
81 (count_below as f64 / finite.len() as f64) * 100.0
82}
83
84pub fn correlation(left: &[f64], right: &[f64]) -> Option<f64> {
86 let pairs: Vec<(f64, f64)> = left
87 .iter()
88 .copied()
89 .zip(right.iter().copied())
90 .filter(|(x, y)| x.is_finite() && y.is_finite())
91 .collect();
92 if pairs.len() < 2 {
93 return None;
94 }
95 let count = pairs.len() as f64;
96 let mean_x = pairs.iter().map(|(x, _)| x).sum::<f64>() / count;
97 let mean_y = pairs.iter().map(|(_, y)| y).sum::<f64>() / count;
98 let covariance = pairs
99 .iter()
100 .map(|(x, y)| (x - mean_x) * (y - mean_y))
101 .sum::<f64>();
102 let variance_x = pairs.iter().map(|(x, _)| (x - mean_x).powi(2)).sum::<f64>();
103 let variance_y = pairs.iter().map(|(_, y)| (y - mean_y).powi(2)).sum::<f64>();
104 let denominator = (variance_x * variance_y).sqrt();
105 (denominator > f64::EPSILON).then_some(covariance / denominator)
106}
107
108#[derive(Debug, Clone, Copy, PartialEq)]
110pub struct LinearRegressionResult {
111 pub slope: f64,
112 pub intercept: f64,
113 pub r2: f64,
114}
115
116pub fn linear_regression(slice: &[f64]) -> Option<LinearRegressionResult> {
118 let n = slice.len();
119 if n < 2 {
120 return None;
121 }
122
123 let mut sum_x = 0.0f64;
124 let mut sum_y = 0.0f64;
125 let mut sum_xy = 0.0f64;
126 let mut sum_xx = 0.0f64;
127 let mut valid_n = 0;
128
129 for (i, &y) in slice.iter().enumerate() {
130 if y.is_finite() {
131 let x = i as f64;
132 sum_x += x;
133 sum_y += y;
134 sum_xy += x * y;
135 sum_xx += x * x;
136 valid_n += 1;
137 }
138 }
139
140 if valid_n < 2 {
141 return None;
142 }
143
144 let fn_val = valid_n as f64;
145 let denom = fn_val * sum_xx - sum_x * sum_x;
146 if denom.abs() < 1e-12 {
147 return None;
148 }
149
150 let slope = (fn_val * sum_xy - sum_x * sum_y) / denom;
151 let intercept = (sum_y - slope * sum_x) / fn_val;
152
153 let y_mean = sum_y / fn_val;
154 let ss_tot: f64 = slice
155 .iter()
156 .filter(|v| v.is_finite())
157 .map(|&y| (y - y_mean).powi(2))
158 .sum();
159 let ss_res: f64 = slice
160 .iter()
161 .enumerate()
162 .filter(|(_, v)| v.is_finite())
163 .map(|(i, &y)| (y - (slope * i as f64 + intercept)).powi(2))
164 .sum();
165
166 let r2 = if ss_tot > 0.0 {
167 (1.0 - (ss_res / ss_tot)).clamp(0.0, 1.0)
168 } else {
169 1.0
170 };
171
172 Some(LinearRegressionResult {
173 slope,
174 intercept,
175 r2,
176 })
177}
178
179#[derive(Debug, Clone, Copy, PartialEq)]
181pub struct ProportionInterval {
182 pub estimate: f64,
184 pub lower: f64,
185 pub upper: f64,
186}
187
188pub fn wilson_interval(successes: usize, trials: usize, z: f64) -> Option<ProportionInterval> {
206 if trials == 0 || successes > trials || !z.is_finite() || z <= 0.0 {
207 return None;
208 }
209 let n = trials as f64;
210 let p = successes as f64 / n;
211 let z2 = z * z;
212 let denominator = 1.0 + z2 / n;
213 let centre = (p + z2 / (2.0 * n)) / denominator;
214 let half_width = z * (p * (1.0 - p) / n + z2 / (4.0 * n * n)).sqrt() / denominator;
215 Some(ProportionInterval {
216 estimate: p,
217 lower: (centre - half_width).clamp(0.0, 1.0),
218 upper: (centre + half_width).clamp(0.0, 1.0),
219 })
220}
221
222pub fn longest_run<T>(values: &[T], mut predicate: impl FnMut(&T) -> bool) -> usize {
228 let mut longest = 0;
229 let mut current = 0;
230 for value in values {
231 if predicate(value) {
232 current += 1;
233 longest = longest.max(current);
234 } else {
235 current = 0;
236 }
237 }
238 longest
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn test_rolling_stats() {
247 let data = vec![10.0, 20.0, 30.0, 40.0, 50.0];
248 assert_eq!(rolling_sum(&data), 150.0);
249 assert_eq!(rolling_mean(&data), 30.0);
250 assert_eq!(rolling_median(&data), 30.0);
251 assert_eq!(percent_rank(&data, 30.0), 60.0);
252 }
253
254 #[test]
255 fn test_linear_regression() {
256 let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
257 let res = linear_regression(&data).unwrap();
258 assert!((res.slope - 1.0).abs() < 1e-6);
259 assert!((res.intercept - 1.0).abs() < 1e-6);
260 assert!((res.r2 - 1.0).abs() < 1e-6);
261 }
262
263 #[test]
264 fn test_correlation() {
265 assert_eq!(correlation(&[1.0, 2.0, 3.0], &[2.0, 4.0, 6.0]), Some(1.0));
266 assert_eq!(correlation(&[1.0, 1.0], &[1.0, 2.0]), None);
267 }
268}