use super::traits::{Recency, TrendComponent};
use crate::changepoint::{CostFunction, Pelt};
use crate::error::{ForecastError, Result};
#[derive(Debug, Clone)]
pub struct Segment {
pub start: usize,
pub end: usize,
pub slope: f64,
pub intercept: f64,
}
#[derive(Debug, Clone)]
pub struct PiecewiseLinearTrend {
min_segment_length: usize,
penalty: f64,
auto_penalty: bool,
recency: Recency,
segments: Option<Vec<Segment>>,
fitted: Vec<f64>,
n_train: usize,
}
impl PiecewiseLinearTrend {
pub fn new() -> Self {
Self {
min_segment_length: 5,
penalty: 10.0,
auto_penalty: false,
recency: Recency::Full,
segments: None,
fitted: Vec::new(),
n_train: 0,
}
}
pub fn with_penalty(mut self, penalty: f64) -> Self {
self.penalty = penalty;
self
}
pub fn with_auto_penalty(mut self) -> Self {
self.auto_penalty = true;
self
}
pub fn with_min_segment_length(mut self, len: usize) -> Self {
self.min_segment_length = len;
self
}
pub fn with_recency(mut self, recency: Recency) -> Self {
self.recency = recency;
self
}
pub fn segments(&self) -> Option<&[Segment]> {
self.segments.as_deref()
}
}
impl Default for PiecewiseLinearTrend {
fn default() -> Self {
Self::new()
}
}
fn fit_segment_ols(values: &[f64], start: usize, end: usize) -> Segment {
let n = end - start;
debug_assert!(n > 0);
if n == 1 {
return Segment {
start,
end,
slope: 0.0,
intercept: values[start],
};
}
let n_f = n as f64;
let sum_x = n_f * start as f64 + n_f * (n_f - 1.0) / 2.0;
let sum_x2 = {
let s = |k: usize| {
let k = k as f64;
k * (k - 1.0) * (2.0 * k - 1.0) / 6.0
};
s(end) - s(start)
};
let sum_y: f64 = values[start..end].iter().sum();
let sum_xy: f64 = values[start..end]
.iter()
.enumerate()
.map(|(j, &y)| (start + j) as f64 * y)
.sum();
let ss_xx = sum_x2 - sum_x * sum_x / n_f;
let ss_xy = sum_xy - sum_x * sum_y / n_f;
let slope = if ss_xx.abs() < 1e-12 {
0.0
} else {
ss_xy / ss_xx
};
let intercept = (sum_y - slope * sum_x) / n_f;
Segment {
start,
end,
slope,
intercept,
}
}
#[inline]
fn segment_value(seg: &Segment, idx: usize) -> f64 {
seg.intercept + seg.slope * idx as f64
}
impl TrendComponent for PiecewiseLinearTrend {
fn fit_trend(&mut self, values: &[f64]) -> Result<()> {
if values.is_empty() {
return Err(ForecastError::EmptyData);
}
let n = values.len();
if n < 2 {
self.segments = Some(vec![Segment {
start: 0,
end: 1,
slope: 0.0,
intercept: values[0],
}]);
self.fitted = vec![values[0]];
self.n_train = 1;
return Ok(());
}
let (rec_start, rec_end) = self.recency.resolve_with_data(values);
let window = &values[rec_start..rec_end];
let pelt = Pelt::new(CostFunction::LinearTrend).min_size(self.min_segment_length);
let result = if self.auto_penalty {
pelt.auto_detect(window).result
} else {
pelt.penalty(self.penalty).detect(window)
};
let segments: Vec<Segment> = result
.segments
.iter()
.map(|&(s, e)| fit_segment_ols(values, s + rec_start, e + rec_start))
.collect();
let mut fitted = vec![0.0; n];
for seg in &segments {
for i in seg.start..seg.end {
fitted[i] = segment_value(seg, i);
}
}
if rec_start > 0 {
if let Some(first_seg) = segments.first() {
for i in 0..rec_start {
fitted[i] = segment_value(first_seg, i);
}
}
}
self.segments = Some(segments);
self.fitted = fitted;
self.n_train = n;
Ok(())
}
fn fitted_trend(&self) -> &[f64] {
&self.fitted
}
fn predict_trend(&self, n_ahead: usize) -> Vec<f64> {
let segments = match &self.segments {
Some(s) => s,
None => return vec![f64::NAN; n_ahead],
};
if segments.is_empty() {
return vec![f64::NAN; n_ahead];
}
let last = &segments[segments.len() - 1];
(0..n_ahead)
.map(|i| segment_value(last, self.n_train + i))
.collect()
}
fn trend_features(&self) -> Vec<(&str, f64)> {
let segments = match &self.segments {
Some(s) => s,
None => return Vec::new(),
};
let n_segments = segments.len() as f64;
let mean_slope = if segments.is_empty() {
0.0
} else {
segments.iter().map(|s| s.slope).sum::<f64>() / n_segments
};
let max_slope_change = if segments.len() < 2 {
0.0
} else {
segments
.windows(2)
.map(|w| (w[1].slope - w[0].slope).abs())
.fold(0.0_f64, f64::max)
};
let r_squared = {
let n = self.fitted.len();
if n < 2 {
1.0
} else {
f64::NAN
}
};
let last_slope = segments.last().map(|s| s.slope).unwrap_or(0.0);
let mut features = vec![
("piecewise_n_segments", n_segments),
("piecewise_mean_slope", mean_slope),
("piecewise_max_slope_change", max_slope_change),
("piecewise_r_squared", r_squared),
("piecewise_last_slope", last_slope),
];
let _ = &mut features;
features
}
fn trend_name(&self) -> &str {
"piecewise_linear"
}
fn n_params(&self) -> usize {
self.segments.as_ref().map_or(0, |s| 2 * s.len())
}
}
fn compute_r_squared(values: &[f64], fitted: &[f64]) -> f64 {
let n = values.len();
if n < 2 {
return 1.0;
}
let mean = values.iter().sum::<f64>() / n as f64;
let ss_tot: f64 = values.iter().map(|&v| (v - mean).powi(2)).sum();
let ss_res: f64 = values
.iter()
.zip(fitted.iter())
.map(|(&v, &f)| (v - f).powi(2))
.sum();
if ss_tot < 1e-12 {
if ss_res < 1e-12 {
1.0
} else {
0.0
}
} else {
1.0 - ss_res / ss_tot
}
}
pub fn piecewise_trend_features(values: &[f64]) -> Vec<(&str, f64)> {
let mut trend = PiecewiseLinearTrend::new();
if trend.fit_trend(values).is_err() {
return Vec::new();
}
let r_squared = compute_r_squared(values, trend.fitted_trend());
let segments = match trend.segments() {
Some(s) => s,
None => return Vec::new(),
};
let n_segments = segments.len() as f64;
let mean_slope = if segments.is_empty() {
0.0
} else {
segments.iter().map(|s| s.slope).sum::<f64>() / n_segments
};
let max_slope_change = if segments.len() < 2 {
0.0
} else {
segments
.windows(2)
.map(|w| (w[1].slope - w[0].slope).abs())
.fold(0.0_f64, f64::max)
};
let last_slope = segments.last().map(|s| s.slope).unwrap_or(0.0);
vec![
("piecewise_n_segments", n_segments),
("piecewise_mean_slope", mean_slope),
("piecewise_max_slope_change", max_slope_change),
("piecewise_r_squared", r_squared),
("piecewise_last_slope", last_slope),
]
}
pub fn piecewise_n_segments(values: &[f64]) -> f64 {
let mut trend = PiecewiseLinearTrend::new();
if trend.fit_trend(values).is_err() {
return f64::NAN;
}
match trend.segments() {
Some(s) => s.len() as f64,
None => f64::NAN,
}
}
#[cfg(test)]
mod tests {
use super::*;
const TOL: f64 = 1e-6;
fn assert_approx(a: f64, b: f64, tol: f64) {
assert!(
(a - b).abs() < tol,
"expected {} ~ {}, diff = {}",
a,
b,
(a - b).abs()
);
}
#[test]
fn linear_data_one_segment() {
let values: Vec<f64> = (0..50).map(|i| 2.0 * i as f64 + 1.0).collect();
let mut trend = PiecewiseLinearTrend::new();
trend.fit_trend(&values).unwrap();
let segs = trend.segments().unwrap();
assert_eq!(segs.len(), 1, "pure linear data should yield 1 segment");
assert_approx(segs[0].slope, 2.0, 1e-8);
assert_approx(segs[0].intercept, 1.0, 1e-8);
}
#[test]
fn linear_data_fitted_values() {
let values: Vec<f64> = (0..30).map(|i| 3.0 * i as f64 - 5.0).collect();
let mut trend = PiecewiseLinearTrend::new();
trend.fit_trend(&values).unwrap();
let fitted = trend.fitted_trend();
assert_eq!(fitted.len(), 30);
for (i, (&f, &v)) in fitted.iter().zip(values.iter()).enumerate() {
assert_approx(f, v, 1e-8);
let _ = i;
}
}
#[test]
fn two_segments_detected() {
let mut values: Vec<f64> = (0..50).map(|i| i as f64).collect();
values.extend((0..50).map(|i| 49.0 - i as f64));
let mut trend = PiecewiseLinearTrend::new()
.with_penalty(5.0)
.with_min_segment_length(5);
trend.fit_trend(&values).unwrap();
let segs = trend.segments().unwrap();
assert!(
segs.len() >= 2,
"should detect at least 2 segments, got {}",
segs.len()
);
assert!(
segs[0].slope > 0.5,
"first segment slope should be positive"
);
assert!(
segs.last().unwrap().slope < -0.5,
"last segment slope should be negative"
);
}
#[test]
fn predict_extrapolates_from_last_segment() {
let values: Vec<f64> = (0..20).map(|i| 2.0 * i as f64 + 1.0).collect();
let mut trend = PiecewiseLinearTrend::new();
trend.fit_trend(&values).unwrap();
let forecast = trend.predict_trend(5);
assert_eq!(forecast.len(), 5);
for (j, &f) in forecast.iter().enumerate() {
let expected = 2.0 * (20 + j) as f64 + 1.0;
assert_approx(f, expected, 1e-6);
}
}
#[test]
fn predict_unfitted_returns_nan() {
let trend = PiecewiseLinearTrend::new();
let forecast = trend.predict_trend(5);
assert_eq!(forecast.len(), 5);
assert!(forecast[0].is_nan());
}
#[test]
fn features_linear_data() {
let values: Vec<f64> = (0..40).map(|i| 5.0 * i as f64).collect();
let features = piecewise_trend_features(&values);
let get = |name: &str| -> f64 {
features
.iter()
.find(|(n, _)| *n == name)
.map(|(_, v)| *v)
.unwrap_or_else(|| panic!("feature {} not found", name))
};
assert_approx(get("piecewise_n_segments"), 1.0, TOL);
assert_approx(get("piecewise_mean_slope"), 5.0, 1e-8);
assert_approx(get("piecewise_max_slope_change"), 0.0, TOL);
assert!(
get("piecewise_r_squared") > 0.999,
"R² should be ~1 for linear data, got {}",
get("piecewise_r_squared")
);
assert_approx(get("piecewise_last_slope"), 5.0, 1e-8);
}
#[test]
fn features_two_slopes() {
let mut values: Vec<f64> = (0..50).map(|i| i as f64).collect();
values.extend((0..50).map(|i| 49.0 - i as f64));
let features = piecewise_trend_features(&values);
assert!(!features.is_empty());
let get = |name: &str| -> f64 {
features
.iter()
.find(|(n, _)| *n == name)
.map(|(_, v)| *v)
.unwrap()
};
assert!(
get("piecewise_n_segments") >= 2.0,
"should have >= 2 segments"
);
assert!(
get("piecewise_max_slope_change") > 0.5,
"should detect slope change"
);
}
#[test]
fn standalone_n_segments_linear() {
let values: Vec<f64> = (0..30).map(|i| i as f64 * 3.0).collect();
let n = piecewise_n_segments(&values);
assert_approx(n, 1.0, TOL);
}
#[test]
fn standalone_n_segments_empty() {
let n = piecewise_n_segments(&[]);
assert!(n.is_nan());
}
#[test]
fn standalone_features_empty() {
let features = piecewise_trend_features(&[]);
assert!(features.is_empty());
}
#[test]
fn fit_empty_data_error() {
let mut trend = PiecewiseLinearTrend::new();
let result = trend.fit_trend(&[]);
assert!(matches!(result, Err(ForecastError::EmptyData)));
}
#[test]
fn fit_single_point() {
let mut trend = PiecewiseLinearTrend::new();
trend.fit_trend(&[42.0]).unwrap();
let segs = trend.segments().unwrap();
assert_eq!(segs.len(), 1);
assert_approx(segs[0].intercept, 42.0, TOL);
assert_approx(segs[0].slope, 0.0, TOL);
let fitted = trend.fitted_trend();
assert_eq!(fitted.len(), 1);
assert_approx(fitted[0], 42.0, TOL);
let forecast = trend.predict_trend(3);
assert_eq!(forecast.len(), 3);
for &f in &forecast {
assert_approx(f, 42.0, TOL);
}
}
#[test]
fn trend_name_is_correct() {
let trend = PiecewiseLinearTrend::new();
assert_eq!(trend.trend_name(), "piecewise_linear");
}
#[test]
fn trend_features_before_fit_empty() {
let trend = PiecewiseLinearTrend::new();
let features = trend.trend_features();
assert!(features.is_empty());
}
#[test]
fn builder_penalty() {
let trend = PiecewiseLinearTrend::new().with_penalty(25.0);
assert_approx(trend.penalty, 25.0, TOL);
}
#[test]
fn builder_min_segment_length() {
let trend = PiecewiseLinearTrend::new().with_min_segment_length(10);
assert_eq!(trend.min_segment_length, 10);
}
#[test]
fn r_squared_perfect_fit() {
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let fitted = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let r2 = compute_r_squared(&values, &fitted);
assert_approx(r2, 1.0, TOL);
}
#[test]
fn r_squared_constant_series() {
let values = vec![5.0, 5.0, 5.0, 5.0];
let fitted = vec![5.0, 5.0, 5.0, 5.0];
let r2 = compute_r_squared(&values, &fitted);
assert_approx(r2, 1.0, TOL);
}
#[test]
fn r_squared_poor_fit() {
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let fitted = vec![3.0, 3.0, 3.0, 3.0, 3.0]; let r2 = compute_r_squared(&values, &fitted);
assert_approx(r2, 0.0, TOL);
}
#[test]
fn default_same_as_new() {
let a = PiecewiseLinearTrend::new();
let b = PiecewiseLinearTrend::default();
assert_approx(a.penalty, b.penalty, TOL);
assert_eq!(a.min_segment_length, b.min_segment_length);
}
#[test]
fn predict_zero_ahead() {
let values: Vec<f64> = (0..20).map(|i| i as f64).collect();
let mut trend = PiecewiseLinearTrend::new();
trend.fit_trend(&values).unwrap();
let forecast = trend.predict_trend(0);
assert!(forecast.is_empty());
}
#[test]
fn auto_penalty_recovers_known_slope_change() {
let n_first = 40;
let slope_first = 0.5;
let n_total = 80;
let slope_second = 2.0;
let break_at = n_first;
let level_at_break = slope_first * n_first as f64;
let values: Vec<f64> = (0..n_total)
.map(|i| {
if i < n_first {
slope_first * i as f64
} else {
level_at_break + slope_second * (i - n_first) as f64
}
})
.collect();
let mut trend = PiecewiseLinearTrend::new().with_auto_penalty();
trend.fit_trend(&values).unwrap();
let segs = trend.segments().expect("segments after fit");
assert!(
segs.len() >= 2,
"auto_penalty should detect at least one knot on a clear slope change; got {} segments",
segs.len()
);
let knot_at = segs[1].start as isize;
assert!(
(knot_at - break_at as isize).abs() <= 3,
"knot landed at {}, expected near {}",
knot_at,
break_at,
);
let first = &segs[0];
let last = segs.last().unwrap();
assert!(
(first.slope - slope_first).abs() < 0.1,
"first slope {} should be near {}",
first.slope,
slope_first,
);
assert!(
(last.slope - slope_second).abs() < 0.1,
"last slope {} should be near {}",
last.slope,
slope_second,
);
}
#[test]
fn auto_penalty_default_is_off() {
let manual = PiecewiseLinearTrend::new();
let auto = PiecewiseLinearTrend::new().with_auto_penalty();
assert!(!manual.auto_penalty);
assert!(auto.auto_penalty);
}
#[test]
fn constant_data_single_segment() {
let values = vec![7.0; 30];
let mut trend = PiecewiseLinearTrend::new();
trend.fit_trend(&values).unwrap();
let segs = trend.segments().unwrap();
assert_eq!(segs.len(), 1);
assert_approx(segs[0].slope, 0.0, TOL);
assert_approx(segs[0].intercept, 7.0, TOL);
let forecast = trend.predict_trend(5);
for &f in &forecast {
assert_approx(f, 7.0, TOL);
}
}
}