Skip to main content

plotters_statistical/series/
regularization_path.rs

1//! Regularization-path series: one line per model coefficient across a sweep of
2//! regularization strengths, with automatic color cycling and optional
3//! zero-crossing markers (the visual payoff of L1/ElasticNet sparsification).
4//!
5//! **Axis convention:** this crate plots coefficient value (y) against
6//! regularization strength (x) in the order given. Regularization sweeps are
7//! conventionally shown on a *log* x-axis; that is the chart's job — build the
8//! context with `build_cartesian_2d((lo..hi).log_scale(), y_range)` and the
9//! `(f64, f64)` points here map straight onto it.
10
11use plotters::element::{Drawable, PointCollection};
12use plotters::style::RGBColor;
13use plotters_backend::{BackendCoord, DrawingBackend, DrawingErrorKind};
14
15use crate::stats::StatsError;
16use crate::style::{fill_style, palette_color, stroke_style};
17
18/// One coefficient's trajectory across the strength axis.
19#[derive(Debug, Clone)]
20pub struct RegLine {
21    // [line points (n_line)] then, if `has_zero`, one zero-crossing marker point.
22    points: Vec<(f64, f64)>,
23    n_line: usize,
24    has_zero: bool,
25    color: RGBColor,
26    stroke_width: u32,
27    marker_radius: u32,
28    show_marker: bool,
29    name: Option<String>,
30}
31
32impl RegLine {
33    /// This line's legend label (its feature name, if any).
34    pub fn name(&self) -> Option<&str> {
35        self.name.as_deref()
36    }
37
38    /// This line's color, e.g. to build a matching legend key.
39    pub fn color(&self) -> RGBColor {
40        self.color
41    }
42}
43
44impl<'a> PointCollection<'a, (f64, f64)> for &'a RegLine {
45    type Point = &'a (f64, f64);
46    type IntoIter = &'a [(f64, f64)];
47    fn point_iter(self) -> &'a [(f64, f64)] {
48        &self.points
49    }
50}
51
52impl<DB: DrawingBackend> Drawable<DB> for RegLine {
53    fn draw<I: Iterator<Item = BackendCoord>>(
54        &self,
55        points: I,
56        backend: &mut DB,
57        _parent_dim: (u32, u32),
58    ) -> Result<(), DrawingErrorKind<DB::ErrorType>> {
59        let pix: Vec<BackendCoord> = points.collect();
60        if pix.len() < self.n_line {
61            return Ok(());
62        }
63        backend.draw_path(
64            pix[..self.n_line].iter().copied(),
65            &stroke_style(&self.color, self.stroke_width),
66        )?;
67        if self.show_marker && self.has_zero && pix.len() > self.n_line {
68            let marker = pix[self.n_line];
69            backend.draw_circle(marker, self.marker_radius, &fill_style(&self.color), true)?;
70        }
71        Ok(())
72    }
73}
74
75/// A full regularization path — one [`RegLine`] per coefficient.
76///
77/// Implements [`IntoIterator`], so `chart.draw_series(path)` draws every line at
78/// once. For a per-feature legend, iterate [`RegularizationPath::lines`] and
79/// call `draw_series` once per line with its `name()`/`color()`.
80#[derive(Debug, Clone)]
81pub struct RegularizationPath {
82    lines: Vec<RegLine>,
83}
84
85impl RegularizationPath {
86    /// Build from `strengths` (x positions) and a `coefficients` matrix indexed
87    /// `[strength_row][feature_col]` — i.e. `coefficients[i][j]` is feature `j`'s
88    /// value at strength `strengths[i]`.
89    ///
90    /// Zero-crossing markers are on by default. Errors on empty input or a
91    /// ragged matrix (rows of differing width, or a row count that does not
92    /// match `strengths`).
93    pub fn new(strengths: &[f64], coefficients: &[Vec<f64>]) -> Result<Self, StatsError> {
94        if strengths.is_empty() || coefficients.is_empty() {
95            return Err(StatsError::EmptyInput);
96        }
97        if coefficients.len() != strengths.len() {
98            return Err(StatsError::LengthMismatch {
99                scores: strengths.len(),
100                labels: coefficients.len(),
101            });
102        }
103        let n_features = coefficients[0].len();
104        if coefficients.iter().any(|r| r.len() != n_features) {
105            return Err(StatsError::LengthMismatch {
106                scores: n_features,
107                labels: coefficients.iter().map(|r| r.len()).max().unwrap_or(0),
108            });
109        }
110
111        let mut lines = Vec::with_capacity(n_features);
112        for j in 0..n_features {
113            let series: Vec<(f64, f64)> = strengths
114                .iter()
115                .zip(coefficients.iter())
116                .map(|(&s, row)| (s, row[j]))
117                .collect();
118            let zero = first_zero_crossing(&series);
119            let n_line = series.len();
120            let mut points = series;
121            let has_zero = zero.is_some();
122            if let Some(z) = zero {
123                points.push(z);
124            }
125            lines.push(RegLine {
126                points,
127                n_line,
128                has_zero,
129                color: palette_color(j),
130                stroke_width: 2,
131                marker_radius: 4,
132                show_marker: true,
133                name: None,
134            });
135        }
136        Ok(Self { lines })
137    }
138
139    /// Attach feature names (used as per-line legend labels). Extra names are
140    /// ignored; missing ones leave that line unnamed.
141    pub fn feature_names<S: Into<String>, I: IntoIterator<Item = S>>(mut self, names: I) -> Self {
142        for (line, name) in self.lines.iter_mut().zip(names) {
143            line.name = Some(name.into());
144        }
145        self
146    }
147
148    /// Enable/disable zero-crossing markers on every line.
149    pub fn zero_markers(mut self, show: bool) -> Self {
150        for line in &mut self.lines {
151            line.show_marker = show;
152        }
153        self
154    }
155
156    /// Set a common stroke width for every line.
157    pub fn stroke_width(mut self, width: u32) -> Self {
158        for line in &mut self.lines {
159            line.stroke_width = width;
160        }
161        self
162    }
163
164    /// The per-coefficient lines, cloned — iterate these to draw each with its
165    /// own legend entry.
166    pub fn lines(&self) -> Vec<RegLine> {
167        self.lines.clone()
168    }
169}
170
171impl IntoIterator for RegularizationPath {
172    type Item = RegLine;
173    type IntoIter = std::vec::IntoIter<RegLine>;
174    fn into_iter(self) -> Self::IntoIter {
175        self.lines.into_iter()
176    }
177}
178
179/// First point (in path order) where a coefficient reaches or crosses zero,
180/// linearly interpolating the strength at the crossing. `None` if it never does.
181fn first_zero_crossing(series: &[(f64, f64)]) -> Option<(f64, f64)> {
182    for w in series.windows(2) {
183        let (s0, c0) = w[0];
184        let (s1, c1) = w[1];
185        if c0 == 0.0 {
186            return Some((s0, 0.0));
187        }
188        if c1 == 0.0 {
189            return Some((s1, 0.0));
190        }
191        if c0 * c1 < 0.0 {
192            let t = c0 / (c0 - c1); // fraction along the segment to the zero
193            return Some((s0 + t * (s1 - s0), 0.0));
194        }
195    }
196    // A coefficient that is exactly zero at the very first point.
197    match series.first() {
198        Some(&(s, 0.0)) => Some((s, 0.0)),
199        _ => None,
200    }
201}