plotters-statistical 0.1.0

Statistical chart primitives (box, violin, ROC, PR, regularization-path, residual) as native plotters series
Documentation
//! Regularization-path series: one line per model coefficient across a sweep of
//! regularization strengths, with automatic color cycling and optional
//! zero-crossing markers (the visual payoff of L1/ElasticNet sparsification).
//!
//! **Axis convention:** this crate plots coefficient value (y) against
//! regularization strength (x) in the order given. Regularization sweeps are
//! conventionally shown on a *log* x-axis; that is the chart's job — build the
//! context with `build_cartesian_2d((lo..hi).log_scale(), y_range)` and the
//! `(f64, f64)` points here map straight onto it.

use plotters::element::{Drawable, PointCollection};
use plotters::style::RGBColor;
use plotters_backend::{BackendCoord, DrawingBackend, DrawingErrorKind};

use crate::stats::StatsError;
use crate::style::{fill_style, palette_color, stroke_style};

/// One coefficient's trajectory across the strength axis.
#[derive(Debug, Clone)]
pub struct RegLine {
    // [line points (n_line)] then, if `has_zero`, one zero-crossing marker point.
    points: Vec<(f64, f64)>,
    n_line: usize,
    has_zero: bool,
    color: RGBColor,
    stroke_width: u32,
    marker_radius: u32,
    show_marker: bool,
    name: Option<String>,
}

impl RegLine {
    /// This line's legend label (its feature name, if any).
    pub fn name(&self) -> Option<&str> {
        self.name.as_deref()
    }

    /// This line's color, e.g. to build a matching legend key.
    pub fn color(&self) -> RGBColor {
        self.color
    }
}

impl<'a> PointCollection<'a, (f64, f64)> for &'a RegLine {
    type Point = &'a (f64, f64);
    type IntoIter = &'a [(f64, f64)];
    fn point_iter(self) -> &'a [(f64, f64)] {
        &self.points
    }
}

impl<DB: DrawingBackend> Drawable<DB> for RegLine {
    fn draw<I: Iterator<Item = BackendCoord>>(
        &self,
        points: I,
        backend: &mut DB,
        _parent_dim: (u32, u32),
    ) -> Result<(), DrawingErrorKind<DB::ErrorType>> {
        let pix: Vec<BackendCoord> = points.collect();
        if pix.len() < self.n_line {
            return Ok(());
        }
        backend.draw_path(
            pix[..self.n_line].iter().copied(),
            &stroke_style(&self.color, self.stroke_width),
        )?;
        if self.show_marker && self.has_zero && pix.len() > self.n_line {
            let marker = pix[self.n_line];
            backend.draw_circle(marker, self.marker_radius, &fill_style(&self.color), true)?;
        }
        Ok(())
    }
}

/// A full regularization path — one [`RegLine`] per coefficient.
///
/// Implements [`IntoIterator`], so `chart.draw_series(path)` draws every line at
/// once. For a per-feature legend, iterate [`RegularizationPath::lines`] and
/// call `draw_series` once per line with its `name()`/`color()`.
#[derive(Debug, Clone)]
pub struct RegularizationPath {
    lines: Vec<RegLine>,
}

impl RegularizationPath {
    /// Build from `strengths` (x positions) and a `coefficients` matrix indexed
    /// `[strength_row][feature_col]` — i.e. `coefficients[i][j]` is feature `j`'s
    /// value at strength `strengths[i]`.
    ///
    /// Zero-crossing markers are on by default. Errors on empty input or a
    /// ragged matrix (rows of differing width, or a row count that does not
    /// match `strengths`).
    pub fn new(strengths: &[f64], coefficients: &[Vec<f64>]) -> Result<Self, StatsError> {
        if strengths.is_empty() || coefficients.is_empty() {
            return Err(StatsError::EmptyInput);
        }
        if coefficients.len() != strengths.len() {
            return Err(StatsError::LengthMismatch {
                scores: strengths.len(),
                labels: coefficients.len(),
            });
        }
        let n_features = coefficients[0].len();
        if coefficients.iter().any(|r| r.len() != n_features) {
            return Err(StatsError::LengthMismatch {
                scores: n_features,
                labels: coefficients.iter().map(|r| r.len()).max().unwrap_or(0),
            });
        }

        let mut lines = Vec::with_capacity(n_features);
        for j in 0..n_features {
            let series: Vec<(f64, f64)> = strengths
                .iter()
                .zip(coefficients.iter())
                .map(|(&s, row)| (s, row[j]))
                .collect();
            let zero = first_zero_crossing(&series);
            let n_line = series.len();
            let mut points = series;
            let has_zero = zero.is_some();
            if let Some(z) = zero {
                points.push(z);
            }
            lines.push(RegLine {
                points,
                n_line,
                has_zero,
                color: palette_color(j),
                stroke_width: 2,
                marker_radius: 4,
                show_marker: true,
                name: None,
            });
        }
        Ok(Self { lines })
    }

    /// Attach feature names (used as per-line legend labels). Extra names are
    /// ignored; missing ones leave that line unnamed.
    pub fn feature_names<S: Into<String>, I: IntoIterator<Item = S>>(mut self, names: I) -> Self {
        for (line, name) in self.lines.iter_mut().zip(names) {
            line.name = Some(name.into());
        }
        self
    }

    /// Enable/disable zero-crossing markers on every line.
    pub fn zero_markers(mut self, show: bool) -> Self {
        for line in &mut self.lines {
            line.show_marker = show;
        }
        self
    }

    /// Set a common stroke width for every line.
    pub fn stroke_width(mut self, width: u32) -> Self {
        for line in &mut self.lines {
            line.stroke_width = width;
        }
        self
    }

    /// The per-coefficient lines, cloned — iterate these to draw each with its
    /// own legend entry.
    pub fn lines(&self) -> Vec<RegLine> {
        self.lines.clone()
    }
}

impl IntoIterator for RegularizationPath {
    type Item = RegLine;
    type IntoIter = std::vec::IntoIter<RegLine>;
    fn into_iter(self) -> Self::IntoIter {
        self.lines.into_iter()
    }
}

/// First point (in path order) where a coefficient reaches or crosses zero,
/// linearly interpolating the strength at the crossing. `None` if it never does.
fn first_zero_crossing(series: &[(f64, f64)]) -> Option<(f64, f64)> {
    for w in series.windows(2) {
        let (s0, c0) = w[0];
        let (s1, c1) = w[1];
        if c0 == 0.0 {
            return Some((s0, 0.0));
        }
        if c1 == 0.0 {
            return Some((s1, 0.0));
        }
        if c0 * c1 < 0.0 {
            let t = c0 / (c0 - c1); // fraction along the segment to the zero
            return Some((s0 + t * (s1 - s0), 0.0));
        }
    }
    // A coefficient that is exactly zero at the very first point.
    match series.first() {
        Some(&(s, 0.0)) => Some((s, 0.0)),
        _ => None,
    }
}