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.
- {l0_python-0.1.0 → l0_python-0.1.1}/PKG-INFO +3 -1
- {l0_python-0.1.0 → l0_python-0.1.1}/l0/__init__.py +3 -0
- l0_python-0.1.1/l0/sparse.py +321 -0
- {l0_python-0.1.0 → l0_python-0.1.1}/l0_python.egg-info/PKG-INFO +3 -1
- {l0_python-0.1.0 → l0_python-0.1.1}/l0_python.egg-info/SOURCES.txt +3 -1
- {l0_python-0.1.0 → l0_python-0.1.1}/l0_python.egg-info/requires.txt +2 -0
- {l0_python-0.1.0 → l0_python-0.1.1}/pyproject.toml +3 -1
- l0_python-0.1.1/tests/test_sparse.py +240 -0
- {l0_python-0.1.0 → l0_python-0.1.1}/LICENSE +0 -0
- {l0_python-0.1.0 → l0_python-0.1.1}/README.md +0 -0
- {l0_python-0.1.0 → l0_python-0.1.1}/l0/distributions.py +0 -0
- {l0_python-0.1.0 → l0_python-0.1.1}/l0/gates.py +0 -0
- {l0_python-0.1.0 → l0_python-0.1.1}/l0/layers.py +0 -0
- {l0_python-0.1.0 → l0_python-0.1.1}/l0/penalties.py +0 -0
- {l0_python-0.1.0 → l0_python-0.1.1}/l0_python.egg-info/dependency_links.txt +0 -0
- {l0_python-0.1.0 → l0_python-0.1.1}/l0_python.egg-info/top_level.txt +0 -0
- {l0_python-0.1.0 → l0_python-0.1.1}/setup.cfg +0 -0
- {l0_python-0.1.0 → l0_python-0.1.1}/tests/test_distributions.py +0 -0
- {l0_python-0.1.0 → l0_python-0.1.1}/tests/test_gates.py +0 -0
- {l0_python-0.1.0 → l0_python-0.1.1}/tests/test_layers.py +0 -0
- {l0_python-0.1.0 → l0_python-0.1.1}/tests/test_penalties.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: l0-python
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "l0-python"
|
|
7
|
-
version = "0.1.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|