sklears_python/linear/elastic_net.rs
1//! Python bindings for ElasticNet Regression
2//!
3//! This module provides Python bindings for ElasticNet Regression,
4//! offering scikit-learn compatible interfaces with combined L1+L2 regularization
5//! using the sklears-linear crate.
6
7use super::common::*;
8use pyo3::types::PyDict;
9use pyo3::Bound;
10use sklears_core::traits::{Fit, Predict, Score, Trained};
11use sklears_linear::{LinearRegression, LinearRegressionConfig, Penalty};
12
13/// Python-specific configuration wrapper for ElasticNet
14#[derive(Debug, Clone)]
15pub struct PyElasticNetConfig {
16 pub alpha: f64,
17 pub l1_ratio: f64,
18 pub fit_intercept: bool,
19 pub copy_x: bool,
20 pub max_iter: usize,
21 pub tol: f64,
22 pub warm_start: bool,
23 pub positive: bool,
24 pub random_state: Option<i32>,
25 pub selection: String,
26}
27
28impl Default for PyElasticNetConfig {
29 fn default() -> Self {
30 Self {
31 alpha: 1.0,
32 l1_ratio: 0.5,
33 fit_intercept: true,
34 copy_x: true,
35 max_iter: 1000,
36 tol: 1e-4,
37 warm_start: false,
38 positive: false,
39 random_state: None,
40 selection: "cyclic".to_string(),
41 }
42 }
43}
44
45impl From<PyElasticNetConfig> for LinearRegressionConfig {
46 fn from(py_config: PyElasticNetConfig) -> Self {
47 // ElasticNet combines L1 and L2 penalties
48 LinearRegressionConfig {
49 fit_intercept: py_config.fit_intercept,
50 penalty: Penalty::ElasticNet {
51 alpha: py_config.alpha,
52 l1_ratio: py_config.l1_ratio,
53 },
54 max_iter: py_config.max_iter,
55 tol: py_config.tol,
56 warm_start: py_config.warm_start,
57 ..Default::default()
58 }
59 }
60}
61
62/// Linear regression with combined L1 and L2 priors as regularizer.
63///
64/// Minimizes the objective function:
65///
66/// ```text
67/// 1 / (2 * n_samples) * ||y - Xw||^2_2
68/// + alpha * l1_ratio * ||w||_1
69/// + 0.5 * alpha * (1 - l1_ratio) * ||w||^2_2
70/// ```
71///
72/// If you are interested in controlling the L1 and L2 penalty
73/// separately, keep in mind that this is equivalent to:
74///
75/// ```text
76/// a * L1 + b * L2
77/// ```
78///
79/// where:
80///
81/// ```text
82/// alpha = a + b and l1_ratio = a / (a + b)
83/// ```
84///
85/// The parameter l1_ratio corresponds to alpha in the glmnet R package
86/// while alpha corresponds to the lambda parameter in glmnet.
87/// Specifically, l1_ratio = 1 is the lasso penalty. Currently, l1_ratio
88/// <= 0.01 is not reliable, unless you supply your own sequence of alpha.
89///
90/// Parameters
91/// ----------
92/// alpha : float, default=1.0
93/// Constant that multiplies the penalty terms. Defaults to 1.0.
94/// See the notes for the exact mathematical meaning of this
95/// parameter. ``alpha = 0`` is equivalent to an ordinary least square,
96/// solved by the :class:`LinearRegression` object. For numerical
97/// reasons, using ``alpha = 0`` with the ``Lasso`` object is not advised.
98/// Given this, you should use the :class:`LinearRegression` object.
99///
100/// l1_ratio : float, default=0.5
101/// The ElasticNet mixing parameter, with ``0 <= l1_ratio <= 1``. For
102/// ``l1_ratio = 0`` the penalty is an L2 penalty. ``For l1_ratio = 1`` it
103/// is an L1 penalty. For ``0 < l1_ratio < 1``, the penalty is a
104/// combination of L1 and L2.
105///
106/// fit_intercept : bool, default=True
107/// Whether to calculate the intercept for this model. If set
108/// to False, no intercept will be used in calculations
109/// (i.e. data is expected to be centered).
110///
111/// copy_X : bool, default=True
112/// If ``True``, X will be copied; else, it may be overwritten.
113///
114/// max_iter : int, default=1000
115/// The maximum number of iterations for the optimization algorithm.
116///
117/// tol : float, default=1e-4
118/// The tolerance for the optimization: if the updates are
119/// smaller than ``tol``, the optimization code checks the
120/// dual gap for optimality and continues until it is smaller
121/// than ``tol``, see Notes below.
122///
123/// warm_start : bool, default=False
124/// When set to ``True``, reuse the solution of the previous call to fit as
125/// initialization, otherwise, just erase the previous solution.
126/// See :term:`the Glossary <warm_start>`.
127///
128/// positive : bool, default=False
129/// When set to ``True``, forces the coefficients to be positive.
130///
131/// random_state : int, RandomState instance, default=None
132/// The seed of the pseudo random number generator that selects a random
133/// feature to update. Used when ``selection`` == 'random'.
134/// Pass an int for reproducible output across multiple function calls.
135/// See :term:`Glossary <random_state>`.
136///
137/// selection : {'cyclic', 'random'}, default='cyclic'
138/// If set to 'random', a random coefficient is updated every iteration
139/// rather than looping over features sequentially by default. This
140/// (setting to 'random') often leads to significantly faster convergence
141/// especially when tol is higher than 1e-4.
142///
143/// Attributes
144/// ----------
145/// coef_ : ndarray of shape (n_features,) or (n_targets, n_features)
146/// Parameter vector (w in the cost function formula).
147///
148/// sparse_coef_ : sparse matrix of shape (n_features,) or \
149/// (n_targets, n_features)
150/// Sparse representation of the fitted ``coef_``.
151///
152/// intercept_ : float or ndarray of shape (n_targets,)
153/// Independent term in decision function.
154///
155/// n_features_in_ : int
156/// Number of features seen during :term:`fit`.
157///
158/// n_iter_ : list of int
159/// Number of iterations run by the coordinate descent solver to reach
160/// the specified tolerance.
161///
162/// Examples
163/// --------
164/// >>> from sklears_python import ElasticNet
165/// >>> from sklearn.datasets import make_regression
166/// >>> X, y = make_regression(n_features=2, random_state=0)
167/// >>> regr = ElasticNet(random_state=0)
168/// >>> regr.fit(X, y)
169/// ElasticNet(random_state=0)
170/// >>> print(regr.coef_)
171/// [18.83816119 64.55968437]
172/// >>> print(regr.intercept_)
173/// 1.451...
174/// >>> print(regr.predict([[0, 0]]))
175/// [1.451...]
176///
177/// Notes
178/// -----
179/// To avoid unnecessary memory duplication the X argument of the fit method
180/// should be directly passed as a Fortran-contiguous NumPy array.
181///
182/// The precise stopping criteria based on `tol` are the following: First,
183/// check that that maximum coordinate update, i.e. :math:`\\max_j |w_j^{new} -
184/// w_j^{old}|` is smaller than `tol` times the maximum absolute coefficient,
185/// :math:`\\max_j |w_j|`. If so, then additionally check whether the dual gap
186/// is smaller than `tol` times :math:`||y||_2^2 / n_\\text{samples}`.
187#[pyclass(name = "ElasticNet")]
188pub struct PyElasticNet {
189 /// Python-specific configuration
190 py_config: PyElasticNetConfig,
191 /// Trained model instance using the actual sklears-linear implementation
192 fitted_model: Option<LinearRegression<Trained>>,
193}
194
195#[pymethods]
196impl PyElasticNet {
197 #[new]
198 #[allow(clippy::too_many_arguments)]
199 #[pyo3(signature = (alpha=1.0, l1_ratio=0.5, fit_intercept=true, copy_x=true, max_iter=1000, tol=1e-4, warm_start=false, positive=false, random_state=None, selection="cyclic"))]
200 fn new(
201 alpha: f64,
202 l1_ratio: f64,
203 fit_intercept: bool,
204 copy_x: bool,
205 max_iter: usize,
206 tol: f64,
207 warm_start: bool,
208 positive: bool,
209 random_state: Option<i32>,
210 selection: &str,
211 ) -> PyResult<Self> {
212 // Validate l1_ratio
213 if !(0.0..=1.0).contains(&l1_ratio) {
214 return Err(PyValueError::new_err(
215 "l1_ratio must be between 0 and 1 (inclusive)",
216 ));
217 }
218
219 // Validate alpha
220 if alpha < 0.0 {
221 return Err(PyValueError::new_err("alpha must be non-negative"));
222 }
223
224 let py_config = PyElasticNetConfig {
225 alpha,
226 l1_ratio,
227 fit_intercept,
228 copy_x,
229 max_iter,
230 tol,
231 warm_start,
232 positive,
233 random_state,
234 selection: selection.to_string(),
235 };
236
237 Ok(Self {
238 py_config,
239 fitted_model: None,
240 })
241 }
242
243 /// Fit the ElasticNet regression model
244 fn fit(&mut self, x: PyReadonlyArray2<f64>, y: PyReadonlyArray1<f64>) -> PyResult<()> {
245 let x_array = pyarray_to_core_array2(x)?;
246 let y_array = pyarray_to_core_array1(y)?;
247
248 // Validate input arrays
249 validate_fit_arrays(&x_array, &y_array)?;
250
251 // Create sklears-linear model with ElasticNet configuration
252 let model = LinearRegression::elastic_net(self.py_config.alpha, self.py_config.l1_ratio)
253 .fit_intercept(self.py_config.fit_intercept);
254
255 // Fit the model using sklears-linear's implementation
256 match model.fit(&x_array, &y_array) {
257 Ok(fitted_model) => {
258 self.fitted_model = Some(fitted_model);
259 Ok(())
260 }
261 Err(e) => Err(PyValueError::new_err(format!(
262 "Failed to fit ElasticNet model: {:?}",
263 e
264 ))),
265 }
266 }
267
268 /// Predict using the fitted model
269 fn predict(&self, py: Python<'_>, x: PyReadonlyArray2<f64>) -> PyResult<Py<PyArray1<f64>>> {
270 let fitted = self
271 .fitted_model
272 .as_ref()
273 .ok_or_else(|| PyValueError::new_err("Model not fitted. Call fit() first."))?;
274
275 let x_array = pyarray_to_core_array2(x)?;
276 validate_predict_array(&x_array)?;
277
278 match fitted.predict(&x_array) {
279 Ok(predictions) => Ok(core_array1_to_py(py, &predictions)),
280 Err(e) => Err(PyValueError::new_err(format!("Prediction failed: {:?}", e))),
281 }
282 }
283
284 /// Get model coefficients
285 #[getter]
286 fn coef_(&self, py: Python<'_>) -> PyResult<Py<PyArray1<f64>>> {
287 let fitted = self
288 .fitted_model
289 .as_ref()
290 .ok_or_else(|| PyValueError::new_err("Model not fitted. Call fit() first."))?;
291
292 Ok(core_array1_to_py(py, fitted.coef()))
293 }
294
295 /// Get model intercept
296 #[getter]
297 fn intercept_(&self) -> PyResult<f64> {
298 let fitted = self
299 .fitted_model
300 .as_ref()
301 .ok_or_else(|| PyValueError::new_err("Model not fitted. Call fit() first."))?;
302
303 Ok(fitted.intercept().unwrap_or(0.0))
304 }
305
306 /// Calculate R² score
307 fn score(&self, x: PyReadonlyArray2<f64>, y: PyReadonlyArray1<f64>) -> PyResult<f64> {
308 let fitted = self
309 .fitted_model
310 .as_ref()
311 .ok_or_else(|| PyValueError::new_err("Model not fitted. Call fit() first."))?;
312
313 let x_array = pyarray_to_core_array2(x)?;
314 let y_array = pyarray_to_core_array1(y)?;
315
316 match fitted.score(&x_array, &y_array) {
317 Ok(score) => Ok(score),
318 Err(e) => Err(PyValueError::new_err(format!(
319 "Score calculation failed: {:?}",
320 e
321 ))),
322 }
323 }
324
325 /// Get number of features
326 #[getter]
327 fn n_features_in_(&self) -> PyResult<usize> {
328 let fitted = self
329 .fitted_model
330 .as_ref()
331 .ok_or_else(|| PyValueError::new_err("Model not fitted. Call fit() first."))?;
332
333 // Infer number of features from coefficient array length
334 Ok(fitted.coef().len())
335 }
336
337 /// Return parameters for this estimator (sklearn compatibility)
338 fn get_params(&self, py: Python<'_>, deep: Option<bool>) -> PyResult<Py<PyDict>> {
339 let _deep = deep.unwrap_or(true);
340
341 let dict = PyDict::new(py);
342
343 dict.set_item("alpha", self.py_config.alpha)?;
344 dict.set_item("l1_ratio", self.py_config.l1_ratio)?;
345 dict.set_item("fit_intercept", self.py_config.fit_intercept)?;
346 dict.set_item("copy_X", self.py_config.copy_x)?;
347 dict.set_item("max_iter", self.py_config.max_iter)?;
348 dict.set_item("tol", self.py_config.tol)?;
349 dict.set_item("warm_start", self.py_config.warm_start)?;
350 dict.set_item("positive", self.py_config.positive)?;
351 dict.set_item("random_state", self.py_config.random_state)?;
352 dict.set_item("selection", &self.py_config.selection)?;
353
354 Ok(dict.into())
355 }
356
357 /// Set parameters for this estimator (sklearn compatibility)
358 fn set_params(&mut self, kwargs: &Bound<'_, PyDict>) -> PyResult<()> {
359 // Update configuration parameters
360 if let Some(alpha) = kwargs.get_item("alpha")? {
361 let alpha_val: f64 = alpha.extract()?;
362 if alpha_val < 0.0 {
363 return Err(PyValueError::new_err("alpha must be non-negative"));
364 }
365 self.py_config.alpha = alpha_val;
366 }
367 if let Some(l1_ratio) = kwargs.get_item("l1_ratio")? {
368 let l1_ratio_val: f64 = l1_ratio.extract()?;
369 if !(0.0..=1.0).contains(&l1_ratio_val) {
370 return Err(PyValueError::new_err(
371 "l1_ratio must be between 0 and 1 (inclusive)",
372 ));
373 }
374 self.py_config.l1_ratio = l1_ratio_val;
375 }
376 if let Some(fit_intercept) = kwargs.get_item("fit_intercept")? {
377 self.py_config.fit_intercept = fit_intercept.extract()?;
378 }
379 if let Some(copy_x) = kwargs.get_item("copy_X")? {
380 self.py_config.copy_x = copy_x.extract()?;
381 }
382 if let Some(max_iter) = kwargs.get_item("max_iter")? {
383 self.py_config.max_iter = max_iter.extract()?;
384 }
385 if let Some(tol) = kwargs.get_item("tol")? {
386 self.py_config.tol = tol.extract()?;
387 }
388 if let Some(warm_start) = kwargs.get_item("warm_start")? {
389 self.py_config.warm_start = warm_start.extract()?;
390 }
391 if let Some(positive) = kwargs.get_item("positive")? {
392 self.py_config.positive = positive.extract()?;
393 }
394 if let Some(random_state) = kwargs.get_item("random_state")? {
395 self.py_config.random_state = random_state.extract()?;
396 }
397 if let Some(selection) = kwargs.get_item("selection")? {
398 let selection_str: String = selection.extract()?;
399 self.py_config.selection = selection_str;
400 }
401
402 // Clear fitted model since config changed
403 self.fitted_model = None;
404
405 Ok(())
406 }
407
408 /// String representation
409 fn __repr__(&self) -> String {
410 format!(
411 "ElasticNet(alpha={}, l1_ratio={}, fit_intercept={}, copy_X={}, max_iter={}, tol={}, warm_start={}, positive={}, random_state={:?}, selection='{}')",
412 self.py_config.alpha,
413 self.py_config.l1_ratio,
414 self.py_config.fit_intercept,
415 self.py_config.copy_x,
416 self.py_config.max_iter,
417 self.py_config.tol,
418 self.py_config.warm_start,
419 self.py_config.positive,
420 self.py_config.random_state,
421 self.py_config.selection
422 )
423 }
424}