Skip to main content

rill_ml/optim/
sgd.rs

1//! Stochastic gradient descent optimizer.
2
3use crate::error::{RillError, checked_increment, ensure_finite};
4
5/// Configuration for [`Sgd`].
6#[derive(Debug, Clone)]
7#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
8pub struct SgdConfig {
9    /// Learning rate. Must be finite and strictly positive.
10    pub learning_rate: f64,
11    /// L2 regularization strength. Must be finite and non-negative.
12    pub l2: f64,
13}
14
15impl Default for SgdConfig {
16    fn default() -> Self {
17        Self {
18            learning_rate: 0.01,
19            l2: 0.0,
20        }
21    }
22}
23
24/// SGD optimizer with optional L2 regularization.
25///
26/// The update rule for each weight `w_i` is:
27/// ```text
28/// w_i -= lr * (grad_i + l2 * w_i)
29/// ```
30/// The intercept is not regularized.
31#[derive(Debug, Clone)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33pub struct Sgd {
34    feature_count: usize,
35    config: SgdConfig,
36    samples_seen: u64,
37}
38
39impl Sgd {
40    /// Create a new SGD optimizer.
41    pub fn new(feature_count: usize, config: SgdConfig) -> Result<Self, RillError> {
42        if feature_count == 0 {
43            return Err(RillError::EmptyFeatures);
44        }
45        ensure_finite("learning_rate", config.learning_rate)?;
46        ensure_finite("l2", config.l2)?;
47        if config.learning_rate <= 0.0 {
48            return Err(RillError::InvalidLearningRate(config.learning_rate));
49        }
50        if config.l2 < 0.0 {
51            return Err(RillError::InvalidParameter {
52                name: "l2",
53                value: config.l2,
54            });
55        }
56        Ok(Self {
57            feature_count,
58            config,
59            samples_seen: 0,
60        })
61    }
62
63    /// The configured learning rate.
64    pub const fn learning_rate(&self) -> f64 {
65        self.config.learning_rate
66    }
67
68    /// The configured L2 regularization.
69    pub const fn l2(&self) -> f64 {
70        self.config.l2
71    }
72
73    /// Number of parameters (features + intercept).
74    pub const fn param_count(&self) -> usize {
75        self.feature_count + 1
76    }
77
78    /// Number of samples processed.
79    pub const fn samples_seen(&self) -> u64 {
80        self.samples_seen
81    }
82
83    /// Apply one gradient step.
84    pub fn step(
85        &mut self,
86        weights: &mut [f64],
87        intercept: &mut f64,
88        grad_weights: &[f64],
89        grad_intercept: f64,
90    ) -> Result<(), RillError> {
91        if weights.len() != self.feature_count {
92            return Err(RillError::DimensionMismatch {
93                expected: self.feature_count,
94                actual: weights.len(),
95            });
96        }
97        if grad_weights.len() != self.feature_count {
98            return Err(RillError::DimensionMismatch {
99                expected: self.feature_count,
100                actual: grad_weights.len(),
101            });
102        }
103        for &gradient in grad_weights {
104            ensure_finite("grad_weight", gradient)?;
105        }
106        ensure_finite("grad_intercept", grad_intercept)?;
107        let next_samples = checked_increment(self.samples_seen, "SGD sample")?;
108        let lr = self.config.learning_rate;
109        let l2 = self.config.l2;
110        let next_weights = weights
111            .iter()
112            .zip(grad_weights)
113            .map(|(&weight, &gradient)| {
114                let regularized_gradient = gradient + l2 * weight;
115                ensure_finite("regularized gradient", regularized_gradient)?;
116                let next_weight = weight - lr * regularized_gradient;
117                ensure_finite("weight", next_weight)?;
118                Ok(next_weight)
119            })
120            .collect::<Result<Vec<_>, RillError>>()?;
121        let next_intercept = *intercept - lr * grad_intercept;
122        ensure_finite("intercept", next_intercept)?;
123
124        weights.copy_from_slice(&next_weights);
125        *intercept = next_intercept;
126        self.samples_seen = next_samples;
127        Ok(())
128    }
129
130    /// Reset the sample counter.
131    pub fn reset(&mut self) {
132        self.samples_seen = 0;
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn sgd_updates_weights() {
142        let mut opt = Sgd::new(
143            2,
144            SgdConfig {
145                learning_rate: 0.1,
146                l2: 0.0,
147            },
148        )
149        .unwrap();
150        let mut w = vec![0.0, 0.0];
151        let mut b = 0.0;
152        opt.step(&mut w, &mut b, &[1.0, 2.0], 0.5).unwrap();
153        // w -= 0.1 * grad -> [-0.1, -0.2]
154        assert!((w[0] + 0.1).abs() < 1e-12);
155        assert!((w[1] + 0.2).abs() < 1e-12);
156        assert!((b + 0.05).abs() < 1e-12);
157    }
158
159    #[test]
160    fn sgd_l2_regularization() {
161        let mut opt = Sgd::new(
162            1,
163            SgdConfig {
164                learning_rate: 0.1,
165                l2: 1.0,
166            },
167        )
168        .unwrap();
169        let mut w = vec![10.0];
170        let mut b = 0.0;
171        opt.step(&mut w, &mut b, &[0.0], 0.0).unwrap();
172        // w -= 0.1 * (0 + 1*10) = -1.0 -> w = 9.0
173        assert!((w[0] - 9.0).abs() < 1e-12);
174        // intercept not regularized -> b unchanged
175        assert!((b - 0.0).abs() < 1e-12);
176    }
177
178    #[test]
179    fn invalid_learning_rate_rejected() {
180        assert!(
181            Sgd::new(
182                1,
183                SgdConfig {
184                    learning_rate: 0.0,
185                    l2: 0.0
186                }
187            )
188            .is_err()
189        );
190        assert!(
191            Sgd::new(
192                1,
193                SgdConfig {
194                    learning_rate: -1.0,
195                    l2: 0.0
196                }
197            )
198            .is_err()
199        );
200    }
201
202    #[test]
203    fn invalid_l2_rejected() {
204        assert!(
205            Sgd::new(
206                1,
207                SgdConfig {
208                    learning_rate: 0.1,
209                    l2: -1.0
210                }
211            )
212            .is_err()
213        );
214    }
215
216    #[test]
217    fn failed_step_is_atomic() {
218        let mut opt = Sgd::new(2, SgdConfig::default()).unwrap();
219        let mut weights = vec![1.0, 2.0];
220        let mut intercept = 3.0;
221        let result = opt.step(&mut weights, &mut intercept, &[1.0, f64::INFINITY], 1.0);
222        assert!(result.is_err());
223        assert_eq!(weights, vec![1.0, 2.0]);
224        assert_eq!(intercept, 3.0);
225        assert_eq!(opt.samples_seen(), 0);
226    }
227}