l0-python 0.1.0__tar.gz → 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: l0-python
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: L0 regularization for sparse neural networks and intelligent sampling
5
5
  Author-email: PolicyEngine <hello@policyengine.org>
6
6
  License: MIT License
@@ -41,6 +41,7 @@ Description-Content-Type: text/markdown
41
41
  License-File: LICENSE
42
42
  Requires-Dist: torch>=2.0.0
43
43
  Requires-Dist: numpy>=1.20.0
44
+ Requires-Dist: scipy>=1.7.0
44
45
  Provides-Extra: dev
45
46
  Requires-Dist: pytest>=7.0; extra == "dev"
46
47
  Requires-Dist: pytest-cov>=3.0; extra == "dev"
@@ -49,6 +50,7 @@ Requires-Dist: mypy>=0.900; extra == "dev"
49
50
  Requires-Dist: ruff>=0.0.280; extra == "dev"
50
51
  Requires-Dist: pre-commit>=3.0; extra == "dev"
51
52
  Requires-Dist: build>=1.3; extra == "dev"
53
+ Requires-Dist: scikit-learn>=1.0; extra == "dev"
52
54
  Provides-Extra: docs
53
55
  Requires-Dist: mystmd>=1.3; extra == "docs"
54
56
  Requires-Dist: jupyter>=1.0; extra == "docs"
@@ -26,6 +26,7 @@ from .penalties import (
26
26
  get_sparsity_stats,
27
27
  update_temperatures,
28
28
  )
29
+ from .sparse import SparseL0Linear
29
30
 
30
31
  __all__ = [
31
32
  # Distributions
@@ -36,6 +37,8 @@ __all__ = [
36
37
  "L0DepthwiseConv2d",
37
38
  "SparseMLP",
38
39
  "prune_model",
40
+ # Sparse
41
+ "SparseL0Linear",
39
42
  # Gates
40
43
  "L0Gate",
41
44
  "SampleGate",
@@ -0,0 +1,321 @@
1
+ """
2
+ L0 regularized linear models for sparse data.
3
+
4
+ Implements L0-regularized linear regression that efficiently handles
5
+ sparse input matrices without converting them to dense format.
6
+ """
7
+
8
+ # ruff: noqa: N803 N806
9
+
10
+
11
+ import numpy as np
12
+ import torch
13
+ import torch.nn as nn
14
+ from scipy import sparse as sp
15
+
16
+
17
+ class SparseL0Linear(nn.Module):
18
+ """
19
+ L0-regularized linear model for sparse input data.
20
+
21
+ Efficiently handles sparse matrices (scipy.sparse) without
22
+ converting to dense format during computation.
23
+
24
+ Parameters
25
+ ----------
26
+ n_features : int
27
+ Number of input features
28
+ fit_intercept : bool
29
+ Whether to fit an intercept term
30
+ beta : float
31
+ Temperature parameter for Hard Concrete distribution
32
+ gamma : float
33
+ Lower bound of stretched concrete distribution
34
+ zeta : float
35
+ Upper bound of stretched concrete distribution
36
+ init_keep_prob : float
37
+ Initial probability of keeping each feature
38
+ device : str or torch.device
39
+ Device to run computations on ('cpu' or 'cuda')
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ n_features: int,
45
+ fit_intercept: bool = True,
46
+ beta: float = 2 / 3,
47
+ gamma: float = -0.1,
48
+ zeta: float = 1.1,
49
+ init_keep_prob: float = 0.5,
50
+ device: str | torch.device = "cpu",
51
+ ):
52
+ super().__init__()
53
+ self.n_features = n_features
54
+ self.fit_intercept = fit_intercept
55
+ self.beta = beta
56
+ self.gamma = gamma
57
+ self.zeta = zeta
58
+ self.device = torch.device(device)
59
+
60
+ # Model parameters
61
+ self.weight = nn.Parameter(torch.zeros(n_features, device=self.device))
62
+ if fit_intercept:
63
+ self.bias = nn.Parameter(torch.zeros(1, device=self.device))
64
+ else:
65
+ self.register_parameter("bias", None)
66
+
67
+ # L0 gate parameters
68
+ mu = torch.log(torch.tensor(init_keep_prob / (1 - init_keep_prob)))
69
+ self.log_alpha = nn.Parameter(
70
+ torch.normal(
71
+ mu.item(), 0.01, size=(n_features,), device=self.device
72
+ )
73
+ )
74
+
75
+ # Cache for sparse tensor conversion
76
+ self._cached_X_torch: torch.sparse.Tensor | None = None
77
+ self._cached_X_shape: tuple[int, int] | None = None
78
+
79
+ def _convert_sparse_to_torch(
80
+ self, X_sparse: sp.spmatrix
81
+ ) -> torch.sparse.Tensor:
82
+ """
83
+ Convert scipy sparse matrix to torch sparse tensor.
84
+
85
+ Caches the result if the shape matches to avoid redundant conversions.
86
+ """
87
+ if (
88
+ self._cached_X_torch is not None
89
+ and self._cached_X_shape == X_sparse.shape
90
+ and X_sparse.nnz == self._cached_X_torch._nnz()
91
+ ):
92
+ return self._cached_X_torch
93
+
94
+ X_coo = X_sparse.tocoo()
95
+ indices = torch.LongTensor(np.vstack([X_coo.row, X_coo.col])).to(
96
+ self.device
97
+ )
98
+ values = torch.FloatTensor(X_coo.data).to(self.device)
99
+ X_torch = torch.sparse_coo_tensor(
100
+ indices,
101
+ values,
102
+ X_sparse.shape,
103
+ dtype=torch.float32,
104
+ device=self.device,
105
+ )
106
+
107
+ # Cache for future use
108
+ self._cached_X_torch = X_torch
109
+ self._cached_X_shape = X_sparse.shape
110
+
111
+ return X_torch
112
+
113
+ def _sample_gates(self) -> torch.Tensor:
114
+ """Sample gates using Hard Concrete distribution."""
115
+ eps = 1e-6
116
+ u = torch.rand_like(self.log_alpha).clamp(eps, 1 - eps)
117
+ X = (torch.log(u) - torch.log(1 - u) + self.log_alpha) / self.beta
118
+ s = torch.sigmoid(X)
119
+ s_bar = s * (self.zeta - self.gamma) + self.gamma
120
+ return s_bar.clamp(0, 1)
121
+
122
+ def get_deterministic_gates(self) -> torch.Tensor:
123
+ """Get deterministic gate values (for inference)."""
124
+ X = self.log_alpha / self.beta
125
+ s = torch.sigmoid(X)
126
+ s_bar = s * (self.zeta - self.gamma) + self.gamma
127
+ return s_bar.clamp(0, 1)
128
+
129
+ def forward(
130
+ self,
131
+ X: torch.sparse.Tensor | sp.spmatrix,
132
+ deterministic: bool = False,
133
+ ) -> torch.Tensor:
134
+ """
135
+ Forward pass maintaining sparse computation.
136
+
137
+ Parameters
138
+ ----------
139
+ X : torch.sparse.Tensor or scipy.sparse matrix
140
+ Sparse input tensor of shape (n_samples, n_features)
141
+ deterministic : bool
142
+ Whether to use deterministic gates (for inference)
143
+
144
+ Returns
145
+ -------
146
+ torch.Tensor
147
+ Predictions of shape (n_samples,)
148
+ """
149
+ # Convert scipy sparse to torch if needed
150
+ if isinstance(X, sp.spmatrix):
151
+ X = self._convert_sparse_to_torch(X)
152
+
153
+ # Sample or get deterministic gates
154
+ if deterministic:
155
+ gates = self.get_deterministic_gates()
156
+ else:
157
+ gates = self._sample_gates()
158
+
159
+ # Apply gates to weights
160
+ gated_weight = self.weight * gates
161
+
162
+ # Sparse matrix multiplication
163
+ # X is (n_samples, n_features), gated_weight is (n_features,)
164
+ y = torch.sparse.mm(X, gated_weight.unsqueeze(1)).squeeze(1)
165
+
166
+ # Add bias if needed
167
+ if self.fit_intercept and self.bias is not None:
168
+ y = y + self.bias
169
+
170
+ return y # type: ignore[no-any-return]
171
+
172
+ def get_l0_penalty(self) -> torch.Tensor:
173
+ """
174
+ Compute L0 complexity penalty.
175
+
176
+ Returns expected number of active parameters.
177
+ """
178
+ c = -self.beta * torch.log(
179
+ torch.tensor(-self.gamma / self.zeta, device=self.device)
180
+ )
181
+ pi = torch.sigmoid(self.log_alpha + c)
182
+ return pi.sum()
183
+
184
+ def get_sparsity(self) -> float:
185
+ """
186
+ Get current sparsity level.
187
+
188
+ Returns
189
+ -------
190
+ float
191
+ Fraction of weights that are effectively zero
192
+ """
193
+ with torch.no_grad():
194
+ gates = self.get_deterministic_gates()
195
+ return (gates < 0.01).float().mean().item()
196
+
197
+ def get_selected_features(self, threshold: float = 0.01) -> torch.Tensor:
198
+ """
199
+ Get indices of selected (non-zero) features.
200
+
201
+ Parameters
202
+ ----------
203
+ threshold : float
204
+ Gate values below this are considered zero
205
+
206
+ Returns
207
+ -------
208
+ torch.Tensor
209
+ Indices of selected features
210
+ """
211
+ with torch.no_grad():
212
+ gates = self.get_deterministic_gates()
213
+ return torch.where(gates > threshold)[0]
214
+
215
+ def fit(
216
+ self,
217
+ X_sparse: sp.spmatrix,
218
+ y: torch.Tensor | np.ndarray,
219
+ lambda_reg: float = 0.01,
220
+ lr: float = 0.01,
221
+ epochs: int = 1000,
222
+ verbose: bool = False,
223
+ verbose_freq: int = 100,
224
+ ) -> "SparseL0Linear":
225
+ """
226
+ Fit the model using gradient descent.
227
+
228
+ Parameters
229
+ ----------
230
+ X_sparse : scipy.sparse matrix
231
+ Sparse input features
232
+ y : torch.Tensor or numpy.ndarray
233
+ Target values
234
+ lambda_reg : float
235
+ L0 regularization strength
236
+ lr : float
237
+ Learning rate
238
+ epochs : int
239
+ Number of training epochs
240
+ verbose : bool
241
+ Whether to print progress
242
+ verbose_freq : int
243
+ How often to print progress
244
+
245
+ Returns
246
+ -------
247
+ self
248
+ Fitted model
249
+ """
250
+ import numpy as np
251
+
252
+ # Convert y to tensor if needed
253
+ if isinstance(y, np.ndarray):
254
+ y = torch.tensor(y, dtype=torch.float32, device=self.device)
255
+ elif not isinstance(y, torch.Tensor):
256
+ y = torch.tensor(y, dtype=torch.float32, device=self.device)
257
+ else:
258
+ y = y.to(self.device)
259
+
260
+ # Convert sparse matrix to torch sparse tensor (will be cached)
261
+ X_torch = self._convert_sparse_to_torch(X_sparse)
262
+
263
+ # Initialize weights with small random values
264
+ nn.init.normal_(self.weight, 0, 0.1)
265
+ if self.fit_intercept and self.bias is not None:
266
+ self.bias.data.fill_(y.mean())
267
+
268
+ # Setup optimizer
269
+ params = [self.weight, self.log_alpha]
270
+ if self.fit_intercept and self.bias is not None:
271
+ params.append(self.bias)
272
+ optimizer = torch.optim.Adam(params, lr=lr)
273
+
274
+ # Training loop
275
+ for epoch in range(epochs):
276
+ # Forward pass
277
+ y_pred = self.forward(X_torch, deterministic=False)
278
+
279
+ # Compute loss
280
+ mse_loss = (y - y_pred).pow(2).mean()
281
+ l0_loss = self.get_l0_penalty()
282
+ loss = mse_loss + lambda_reg * l0_loss
283
+
284
+ # Backward pass
285
+ optimizer.zero_grad()
286
+ loss.backward()
287
+ optimizer.step()
288
+
289
+ # Print progress
290
+ if verbose and (epoch + 1) % verbose_freq == 0:
291
+ with torch.no_grad():
292
+ n_active = (self.get_deterministic_gates() > 0.01).sum()
293
+ print(
294
+ f"Epoch {epoch+1:4d}: "
295
+ f"loss={loss.item():.4f}, "
296
+ f"mse={mse_loss.item():.4f}, "
297
+ f"l0={l0_loss.item():.2f}, "
298
+ f"active={n_active.item()}"
299
+ )
300
+
301
+ return self
302
+
303
+ def predict(
304
+ self, X_sparse: sp.spmatrix | torch.sparse.Tensor
305
+ ) -> torch.Tensor:
306
+ """
307
+ Make predictions on new data.
308
+
309
+ Parameters
310
+ ----------
311
+ X_sparse : scipy.sparse matrix or torch.sparse.Tensor
312
+ Sparse input features
313
+
314
+ Returns
315
+ -------
316
+ torch.Tensor
317
+ Predictions
318
+ """
319
+ # Make deterministic predictions
320
+ with torch.no_grad():
321
+ return self.forward(X_sparse, deterministic=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: l0-python
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: L0 regularization for sparse neural networks and intelligent sampling
5
5
  Author-email: PolicyEngine <hello@policyengine.org>
6
6
  License: MIT License
@@ -41,6 +41,7 @@ Description-Content-Type: text/markdown
41
41
  License-File: LICENSE
42
42
  Requires-Dist: torch>=2.0.0
43
43
  Requires-Dist: numpy>=1.20.0
44
+ Requires-Dist: scipy>=1.7.0
44
45
  Provides-Extra: dev
45
46
  Requires-Dist: pytest>=7.0; extra == "dev"
46
47
  Requires-Dist: pytest-cov>=3.0; extra == "dev"
@@ -49,6 +50,7 @@ Requires-Dist: mypy>=0.900; extra == "dev"
49
50
  Requires-Dist: ruff>=0.0.280; extra == "dev"
50
51
  Requires-Dist: pre-commit>=3.0; extra == "dev"
51
52
  Requires-Dist: build>=1.3; extra == "dev"
53
+ Requires-Dist: scikit-learn>=1.0; extra == "dev"
52
54
  Provides-Extra: docs
53
55
  Requires-Dist: mystmd>=1.3; extra == "docs"
54
56
  Requires-Dist: jupyter>=1.0; extra == "docs"
@@ -6,6 +6,7 @@ l0/distributions.py
6
6
  l0/gates.py
7
7
  l0/layers.py
8
8
  l0/penalties.py
9
+ l0/sparse.py
9
10
  l0_python.egg-info/PKG-INFO
10
11
  l0_python.egg-info/SOURCES.txt
11
12
  l0_python.egg-info/dependency_links.txt
@@ -14,4 +15,5 @@ l0_python.egg-info/top_level.txt
14
15
  tests/test_distributions.py
15
16
  tests/test_gates.py
16
17
  tests/test_layers.py
17
- tests/test_penalties.py
18
+ tests/test_penalties.py
19
+ tests/test_sparse.py
@@ -1,5 +1,6 @@
1
1
  torch>=2.0.0
2
2
  numpy>=1.20.0
3
+ scipy>=1.7.0
3
4
 
4
5
  [dev]
5
6
  pytest>=7.0
@@ -9,6 +10,7 @@ mypy>=0.900
9
10
  ruff>=0.0.280
10
11
  pre-commit>=3.0
11
12
  build>=1.3
13
+ scikit-learn>=1.0
12
14
 
13
15
  [docs]
14
16
  mystmd>=1.3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "l0-python"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "L0 regularization for sparse neural networks and intelligent sampling"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -23,6 +23,7 @@ classifiers = [
23
23
  dependencies = [
24
24
  "torch>=2.0.0",
25
25
  "numpy>=1.20.0",
26
+ "scipy>=1.7.0",
26
27
  ]
27
28
  keywords = ["l0", "regularization", "sparsity", "neural networks", "calibration"]
28
29
 
@@ -35,6 +36,7 @@ dev = [
35
36
  "ruff>=0.0.280",
36
37
  "pre-commit>=3.0",
37
38
  "build>=1.3",
39
+ "scikit-learn>=1.0",
38
40
  ]
39
41
  docs = [
40
42
  "mystmd>=1.3",
@@ -0,0 +1,240 @@
1
+ """
2
+ Tests for sparse L0 linear models.
3
+ """
4
+
5
+ # ruff: noqa: N806
6
+
7
+ import numpy as np
8
+ import pytest
9
+ import torch
10
+ from scipy import sparse as sp
11
+ from sklearn.linear_model import LinearRegression
12
+
13
+ from l0.sparse import SparseL0Linear
14
+
15
+
16
+ class TestSparseL0Linear:
17
+ """Test cases for SparseL0Linear model."""
18
+
19
+ def test_sparse_data_recovery(self):
20
+ """
21
+ Test that the model can recover sparse coefficients from sparse data.
22
+ Uses the same data generating process as the demonstration.
23
+ """
24
+ # Set seeds for reproducibility
25
+ torch.manual_seed(12543)
26
+ np.random.seed(12543)
27
+
28
+ # Data parameters
29
+ n = 1000
30
+ p = 100
31
+ X_sparsity = 0.95 # 95% of X entries are zero
32
+ beta_sparsity = 0.95 # 95% of true coefficients are zero
33
+
34
+ # Create sparse true coefficients
35
+ num_nonzero = int(p * (1 - beta_sparsity))
36
+ nonzero_indices = np.random.choice(p, num_nonzero, replace=False)
37
+ b = np.zeros(p)
38
+ b[nonzero_indices] = np.random.choice(
39
+ [-3, -2, -1, 1, 2, 3], num_nonzero
40
+ )
41
+
42
+ # Generate sparse X matrix
43
+ X_dense = np.random.randn(n, p) * 0.5
44
+ mask = np.random.random((n, p)) < X_sparsity
45
+ X_dense[mask] = 0
46
+
47
+ # Add correlation structure
48
+ for i in range(0, p - 1, 2):
49
+ if i + 1 < p:
50
+ corr_mask = ~(mask[:, i] | mask[:, i + 1])
51
+ X_dense[corr_mask, i + 1] += 0.3 * X_dense[corr_mask, i]
52
+
53
+ # Convert to sparse
54
+ X_sparse = sp.csr_matrix(X_dense)
55
+
56
+ # Generate y with intercept
57
+ b0 = 30
58
+ sigma_e = 1.5
59
+ y_true = b0 + X_sparse @ b
60
+ y = y_true + sigma_e * np.random.randn(n)
61
+
62
+ # Fit baseline linear regression for lambda selection
63
+ lr = LinearRegression(fit_intercept=True).fit(X_sparse, y)
64
+ residuals = y - lr.predict(X_sparse)
65
+ sigma2_hat = np.var(residuals)
66
+ lambda_reg = 0.01 * sigma2_hat
67
+
68
+ # Fit our sparse L0 model
69
+ model = SparseL0Linear(
70
+ n_features=p, fit_intercept=True, init_keep_prob=0.5
71
+ )
72
+
73
+ model.fit(
74
+ X_sparse,
75
+ y,
76
+ lambda_reg=lambda_reg,
77
+ lr=0.01,
78
+ epochs=2000,
79
+ verbose=False,
80
+ )
81
+
82
+ # Check feature selection performance
83
+ selected = model.get_selected_features().cpu().numpy()
84
+ true_positives = len(set(selected) & set(nonzero_indices))
85
+
86
+ # We expect to recover at least 60% of true features
87
+ recall = (
88
+ true_positives / len(nonzero_indices)
89
+ if len(nonzero_indices) > 0
90
+ else 0
91
+ )
92
+ assert recall >= 0.6, f"Recall {recall:.2%} is too low"
93
+
94
+ # We expect reasonable precision (at least 50%)
95
+ if len(selected) > 0:
96
+ precision = true_positives / len(selected)
97
+ assert precision >= 0.5, f"Precision {precision:.2%} is too low"
98
+
99
+ # Check that sparsity is achieved
100
+ sparsity = model.get_sparsity()
101
+ assert sparsity >= 0.7, f"Sparsity {sparsity:.2%} is too low"
102
+
103
+ # Check prediction quality on training data
104
+ y_pred = model.predict(X_sparse).cpu().numpy()
105
+ mse = np.mean((y - y_pred) ** 2)
106
+ baseline_mse = np.mean((y - lr.predict(X_sparse)) ** 2)
107
+
108
+ # Should be comparable to baseline
109
+ assert mse < 1.5 * baseline_mse, "MSE is much worse than baseline"
110
+
111
+ def test_no_intercept(self):
112
+ """Test that the model works without intercept."""
113
+ np.random.seed(42)
114
+ torch.manual_seed(42)
115
+
116
+ n, p = 100, 20
117
+ X_dense = np.random.randn(n, p)
118
+ mask = np.random.random((n, p)) < 0.8
119
+ X_dense[mask] = 0
120
+ X_sparse = sp.csr_matrix(X_dense)
121
+
122
+ # Generate y without intercept
123
+ b = np.zeros(p)
124
+ b[:3] = [2, -3, 1]
125
+ y = X_sparse @ b + 0.1 * np.random.randn(n)
126
+
127
+ # Fit model without intercept
128
+ model = SparseL0Linear(
129
+ n_features=p, fit_intercept=False, init_keep_prob=0.5
130
+ )
131
+
132
+ assert (
133
+ model.bias is None
134
+ ), "Bias should be None when fit_intercept=False"
135
+
136
+ model.fit(X_sparse, y, epochs=500, verbose=False)
137
+
138
+ # Check that we find the right features
139
+ selected = model.get_selected_features().cpu().numpy()
140
+ assert (
141
+ 0 in selected or 1 in selected or 2 in selected
142
+ ), "Should select at least one true feature"
143
+
144
+ # Make predictions
145
+ y_pred = model.predict(X_sparse)
146
+ assert y_pred.shape == (n,), "Predictions should have correct shape"
147
+
148
+ def test_sparse_efficiency(self):
149
+ """Test that sparse computations remain sparse."""
150
+ np.random.seed(42)
151
+ torch.manual_seed(42)
152
+
153
+ # Create very sparse data
154
+ n, p = 500, 1000
155
+ X_dense = np.random.randn(n, p)
156
+ mask = np.random.random((n, p)) < 0.99 # 99% sparse
157
+ X_dense[mask] = 0
158
+ X_sparse = sp.csr_matrix(X_dense)
159
+
160
+ # Check sparsity
161
+ sparsity = 1 - X_sparse.nnz / (n * p)
162
+ assert sparsity > 0.98, "Data should be very sparse"
163
+
164
+ y = np.random.randn(n)
165
+
166
+ model = SparseL0Linear(n_features=p, fit_intercept=True)
167
+
168
+ # The model should handle this efficiently
169
+ model.fit(X_sparse, y, epochs=100, verbose=False)
170
+
171
+ # Check that cached tensor is sparse
172
+ assert (
173
+ model._cached_X_torch is not None
174
+ ), "Should cache the sparse tensor"
175
+ assert (
176
+ model._cached_X_torch.is_sparse
177
+ ), "Cached tensor should be sparse"
178
+
179
+ # Multiple predictions should reuse cached tensor
180
+ y_pred1 = model.predict(X_sparse)
181
+ y_pred2 = model.predict(X_sparse)
182
+
183
+ # Should get same predictions
184
+ torch.testing.assert_close(y_pred1, y_pred2)
185
+
186
+ def test_device_support(self):
187
+ """Test that model works on different devices."""
188
+ if not torch.cuda.is_available():
189
+ pytest.skip("CUDA not available")
190
+
191
+ np.random.seed(42)
192
+ torch.manual_seed(42)
193
+
194
+ n, p = 50, 10
195
+ X_dense = np.random.randn(n, p)
196
+ X_sparse = sp.csr_matrix(X_dense)
197
+ y = np.random.randn(n)
198
+
199
+ # Test on CUDA
200
+ model = SparseL0Linear(n_features=p, fit_intercept=True, device="cuda")
201
+
202
+ assert model.weight.device.type == "cuda"
203
+ assert model.log_alpha.device.type == "cuda"
204
+
205
+ model.fit(X_sparse, y, epochs=100, verbose=False)
206
+ y_pred = model.predict(X_sparse)
207
+
208
+ assert y_pred.device.type == "cuda"
209
+
210
+ def test_deterministic_vs_stochastic(self):
211
+ """Test that deterministic mode gives consistent predictions."""
212
+ np.random.seed(42)
213
+ torch.manual_seed(42)
214
+
215
+ n, p = 100, 20
216
+ X_dense = np.random.randn(n, p)
217
+ X_sparse = sp.csr_matrix(X_dense)
218
+ y = np.random.randn(n)
219
+
220
+ model = SparseL0Linear(n_features=p, fit_intercept=True)
221
+ model.fit(X_sparse, y, epochs=200, verbose=False)
222
+
223
+ # Convert X for forward pass
224
+ X_torch = model._convert_sparse_to_torch(X_sparse)
225
+
226
+ # Deterministic predictions should be the same
227
+ with torch.no_grad():
228
+ y_det1 = model.forward(X_torch, deterministic=True)
229
+ y_det2 = model.forward(X_torch, deterministic=True)
230
+
231
+ torch.testing.assert_close(y_det1, y_det2)
232
+
233
+ # Stochastic predictions should differ
234
+ with torch.no_grad():
235
+ y_stoch1 = model.forward(X_torch, deterministic=False)
236
+ y_stoch2 = model.forward(X_torch, deterministic=False)
237
+
238
+ assert not torch.allclose(
239
+ y_stoch1, y_stoch2
240
+ ), "Stochastic predictions should differ"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes