l0-python 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """
2
+ L0 Regularization Package
3
+
4
+ A PyTorch implementation of L0 regularization for neural network sparsification
5
+ and intelligent sampling, based on Louizos, Welling, & Kingma (2017).
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ from .distributions import HardConcrete
11
+ from .gates import FeatureGate, HybridGate, L0Gate, SampleGate
12
+ from .layers import (
13
+ L0Conv2d,
14
+ L0DepthwiseConv2d,
15
+ L0Linear,
16
+ SparseMLP,
17
+ prune_model,
18
+ )
19
+ from .penalties import (
20
+ PenaltyTracker,
21
+ TemperatureScheduler,
22
+ compute_l0_penalty,
23
+ compute_l0l2_penalty,
24
+ compute_l2_penalty,
25
+ get_active_parameter_count,
26
+ get_sparsity_stats,
27
+ update_temperatures,
28
+ )
29
+
30
+ __all__ = [
31
+ # Distributions
32
+ "HardConcrete",
33
+ # Layers
34
+ "L0Linear",
35
+ "L0Conv2d",
36
+ "L0DepthwiseConv2d",
37
+ "SparseMLP",
38
+ "prune_model",
39
+ # Gates
40
+ "L0Gate",
41
+ "SampleGate",
42
+ "FeatureGate",
43
+ "HybridGate",
44
+ # Penalties
45
+ "compute_l0_penalty",
46
+ "compute_l2_penalty",
47
+ "compute_l0l2_penalty",
48
+ "get_sparsity_stats",
49
+ "get_active_parameter_count",
50
+ "TemperatureScheduler",
51
+ "update_temperatures",
52
+ "PenaltyTracker",
53
+ ]
l0/distributions.py ADDED
@@ -0,0 +1,205 @@
1
+ """
2
+ Hard Concrete distribution for L0 regularization.
3
+
4
+ Based on Louizos, Welling, & Kingma (2017): "Learning Sparse Neural Networks
5
+ through L0 Regularization" (https://arxiv.org/abs/1712.01312)
6
+ """
7
+
8
+ import math
9
+
10
+ import torch
11
+ import torch.nn as nn
12
+
13
+
14
+ class HardConcrete(nn.Module):
15
+ """
16
+ Hard Concrete distribution for L0 regularization.
17
+
18
+ This distribution enables differentiable approximation of the L0 norm
19
+ by using stochastic gates that can be optimized via gradient descent.
20
+
21
+ Parameters
22
+ ----------
23
+ input_dim : int
24
+ Size of the first dimension (e.g., number of samples or features)
25
+ output_dim : Optional[int]
26
+ Size of the second dimension for 2D gates (e.g., for weight matrices)
27
+ temperature : float
28
+ Temperature parameter controlling the hardness of the distribution.
29
+ Lower values make the distribution more discrete.
30
+ stretch : float
31
+ Stretch parameter for the hard concrete transformation
32
+ init_mean : float
33
+ Initial mean activation probability for the gates (0 to 1)
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ input_dim: int,
39
+ output_dim: int | None = None,
40
+ temperature: float = 0.5,
41
+ stretch: float = 0.1,
42
+ init_mean: float = 0.5,
43
+ ):
44
+ super().__init__()
45
+
46
+ # Set gate dimensions
47
+ if output_dim is None:
48
+ self.gate_size: tuple[int, ...] = (input_dim,)
49
+ else:
50
+ self.gate_size = (input_dim, output_dim)
51
+
52
+ # Learnable parameters - logits for each gate
53
+ self.qz_logits = nn.Parameter(torch.zeros(self.gate_size))
54
+
55
+ # Distribution parameters
56
+ self.temperature = temperature
57
+ self.stretch = stretch
58
+ self.gamma = -stretch # Lower bound after stretching
59
+ self.zeta = 1.0 + stretch # Upper bound after stretching
60
+ self.init_mean = init_mean
61
+
62
+ # Initialize parameters
63
+ self.reset_parameters()
64
+
65
+ def reset_parameters(self) -> None:
66
+ """Initialize gate logits based on desired initial mean."""
67
+ if self.init_mean is not None:
68
+ # Convert mean probability to logit space
69
+ init_val = math.log(self.init_mean / (1 - self.init_mean))
70
+ self.qz_logits.data.fill_(init_val)
71
+
72
+ def forward(
73
+ self, input_shape: tuple[int, ...] | None = None
74
+ ) -> torch.Tensor:
75
+ """
76
+ Sample or compute gates.
77
+
78
+ Parameters
79
+ ----------
80
+ input_shape : Optional[Tuple[int, ...]]
81
+ Shape of the input tensor for broadcasting gates if needed
82
+
83
+ Returns
84
+ -------
85
+ torch.Tensor
86
+ Gate values in [0, 1], either sampled (training) or deterministic (eval)
87
+ """
88
+ if self.training:
89
+ gates = self._sample_gates()
90
+ else:
91
+ gates = self._deterministic_gates()
92
+
93
+ # Broadcast gates if needed for convolution layers
94
+ if input_shape is not None and len(input_shape) > len(gates.shape):
95
+ # Add dimensions for spatial dims (e.g., height, width in Conv2d)
96
+ for _ in range(len(input_shape) - len(gates.shape)):
97
+ gates = gates.unsqueeze(-1)
98
+
99
+ return gates
100
+
101
+ def _sample_gates(self) -> torch.Tensor:
102
+ """
103
+ Sample gates using the reparameterization trick.
104
+
105
+ Returns
106
+ -------
107
+ torch.Tensor
108
+ Sampled gate values in [0, 1]
109
+ """
110
+ # Sample uniform noise (avoiding exact 0 and 1 for numerical stability)
111
+ u = torch.zeros_like(self.qz_logits).uniform_(1e-8, 1.0 - 1e-8)
112
+
113
+ # Apply the concrete distribution transformation
114
+ # s = sigmoid((log(u) - log(1-u) + logits) / temperature)
115
+ s = torch.log(u) - torch.log(1 - u) + self.qz_logits
116
+ s = torch.sigmoid(s / self.temperature)
117
+
118
+ # Stretch and clamp to create hard concrete
119
+ s = s * (self.zeta - self.gamma) + self.gamma
120
+ gates = torch.clamp(s, 0, 1)
121
+
122
+ return gates
123
+
124
+ def _deterministic_gates(self) -> torch.Tensor:
125
+ """
126
+ Compute deterministic gates for evaluation.
127
+
128
+ Returns
129
+ -------
130
+ torch.Tensor
131
+ Deterministic gate values in [0, 1]
132
+ """
133
+ # Use mean of the distribution
134
+ probs = torch.sigmoid(self.qz_logits)
135
+
136
+ # Apply stretching transformation
137
+ gates = probs * (self.zeta - self.gamma) + self.gamma
138
+
139
+ return torch.clamp(gates, 0, 1)
140
+
141
+ def get_penalty(self) -> torch.Tensor:
142
+ """
143
+ Compute the expected L0 norm (number of active gates).
144
+
145
+ Returns
146
+ -------
147
+ torch.Tensor
148
+ Expected number of non-zero gates
149
+ """
150
+ # Shift logits to account for hard concrete bounds
151
+ logits_shifted = self.qz_logits - self.temperature * math.log(
152
+ -self.gamma / self.zeta
153
+ )
154
+
155
+ # Probability that gate is active (non-zero)
156
+ prob_active = torch.sigmoid(logits_shifted)
157
+
158
+ return prob_active.sum()
159
+
160
+ def get_active_prob(self) -> torch.Tensor:
161
+ """
162
+ Get the probability that each gate is active.
163
+
164
+ Returns
165
+ -------
166
+ torch.Tensor
167
+ Probability of each gate being non-zero
168
+ """
169
+ logits_shifted = self.qz_logits - self.temperature * math.log(
170
+ -self.gamma / self.zeta
171
+ )
172
+ return torch.sigmoid(logits_shifted)
173
+
174
+ def get_sparsity(self) -> float:
175
+ """
176
+ Get the overall sparsity level (fraction of inactive gates).
177
+
178
+ Returns
179
+ -------
180
+ float
181
+ Sparsity level in [0, 1] where 1 means all gates are inactive
182
+ """
183
+ with torch.no_grad():
184
+ prob_active = self.get_active_prob()
185
+ return 1.0 - prob_active.mean().item()
186
+
187
+ @torch.no_grad()
188
+ def get_num_active(self) -> int:
189
+ """
190
+ Get the number of active gates (for monitoring).
191
+
192
+ Returns
193
+ -------
194
+ int
195
+ Number of gates that are likely active
196
+ """
197
+ prob_active = self.get_active_prob()
198
+ return int((prob_active > 0.5).sum().item())
199
+
200
+ def extra_repr(self) -> str:
201
+ """String representation for printing."""
202
+ return (
203
+ f"gate_size={self.gate_size}, temperature={self.temperature}, "
204
+ f"stretch={self.stretch}, init_mean={self.init_mean}"
205
+ )
l0/gates.py ADDED
@@ -0,0 +1,407 @@
1
+ """
2
+ Standalone gate modules for sample and feature selection.
3
+
4
+ These gates can be used independently of neural network layers
5
+ for tasks like survey calibration and feature selection.
6
+ """
7
+
8
+ import numpy as np
9
+ import torch
10
+ import torch.nn as nn
11
+
12
+ from .distributions import HardConcrete
13
+
14
+
15
+ class L0Gate(nn.Module):
16
+ """
17
+ Generic L0 gate for selecting items (samples, features, etc.).
18
+
19
+ Parameters
20
+ ----------
21
+ size : int
22
+ Number of items to gate
23
+ temperature : float
24
+ Temperature for HardConcrete distribution
25
+ init_mean : float
26
+ Initial mean activation probability
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ size: int,
32
+ temperature: float = 0.5,
33
+ init_mean: float = 0.5,
34
+ ):
35
+ super().__init__()
36
+ self.size = size
37
+ self.temperature = temperature
38
+ self.init_mean = init_mean
39
+
40
+ # Create HardConcrete gates
41
+ self.gates = HardConcrete(
42
+ size,
43
+ temperature=temperature,
44
+ init_mean=init_mean,
45
+ )
46
+
47
+ def get_gates(self) -> torch.Tensor:
48
+ """Get current gate values."""
49
+ gates_result: torch.Tensor = self.gates()
50
+ return gates_result
51
+
52
+ def apply_gates(self, x: torch.Tensor, dim: int = 0) -> torch.Tensor:
53
+ """
54
+ Apply gates to a tensor along specified dimension.
55
+
56
+ Parameters
57
+ ----------
58
+ x : torch.Tensor
59
+ Input tensor to gate
60
+ dim : int
61
+ Dimension along which to apply gates
62
+
63
+ Returns
64
+ -------
65
+ torch.Tensor
66
+ Gated tensor
67
+ """
68
+ gates = self.get_gates()
69
+
70
+ # Reshape gates to match tensor dimension
71
+ shape = [1] * len(x.shape)
72
+ shape[dim] = -1
73
+ gates = gates.view(shape)
74
+
75
+ return x * gates
76
+
77
+ def get_penalty(self) -> torch.Tensor:
78
+ """Get L0 penalty (expected number of active gates)."""
79
+ return self.gates.get_penalty()
80
+
81
+ def get_active_indices(self, threshold: float = 0.5) -> torch.Tensor:
82
+ """
83
+ Get indices of active items.
84
+
85
+ Parameters
86
+ ----------
87
+ threshold : float
88
+ Threshold for considering a gate active
89
+
90
+ Returns
91
+ -------
92
+ torch.Tensor
93
+ Indices of active items
94
+ """
95
+ with torch.no_grad():
96
+ gates = self.get_gates()
97
+ active_mask = gates > threshold
98
+ return torch.where(active_mask)[0]
99
+
100
+
101
+ class SampleGate(L0Gate):
102
+ """
103
+ L0 gate specifically for sample/observation selection.
104
+
105
+ Useful for survey calibration and intelligent subsampling.
106
+
107
+ Parameters
108
+ ----------
109
+ n_samples : int
110
+ Total number of samples
111
+ target_samples : int
112
+ Target number of samples to select
113
+ temperature : float
114
+ Temperature for HardConcrete distribution
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ n_samples: int,
120
+ target_samples: int,
121
+ temperature: float = 0.25,
122
+ ):
123
+ # Initialize with mean set to achieve target
124
+ init_mean = target_samples / n_samples
125
+ super().__init__(
126
+ size=n_samples,
127
+ temperature=temperature,
128
+ init_mean=init_mean,
129
+ )
130
+ self.n_samples = n_samples
131
+ self.target_samples = target_samples
132
+
133
+ def select_samples(
134
+ self, data: torch.Tensor
135
+ ) -> tuple[torch.Tensor, torch.Tensor]:
136
+ """
137
+ Select samples from data.
138
+
139
+ Parameters
140
+ ----------
141
+ data : torch.Tensor
142
+ Data tensor of shape (n_samples, ...)
143
+
144
+ Returns
145
+ -------
146
+ selected_data : torch.Tensor
147
+ Selected samples
148
+ indices : torch.Tensor
149
+ Indices of selected samples
150
+ """
151
+ indices = self.get_active_indices()
152
+ selected_data = data[indices]
153
+ return selected_data, indices
154
+
155
+ def select_weighted_samples(
156
+ self, data: torch.Tensor, weights: torch.Tensor
157
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
158
+ """
159
+ Select samples with associated weights.
160
+
161
+ Parameters
162
+ ----------
163
+ data : torch.Tensor
164
+ Data tensor of shape (n_samples, ...)
165
+ weights : torch.Tensor
166
+ Weight vector of shape (n_samples,)
167
+
168
+ Returns
169
+ -------
170
+ selected_data : torch.Tensor
171
+ Selected samples
172
+ selected_weights : torch.Tensor
173
+ Weights of selected samples (scaled by gates)
174
+ indices : torch.Tensor
175
+ Indices of selected samples
176
+ """
177
+ gates = self.get_gates()
178
+
179
+ # Apply gates to weights
180
+ gated_weights = weights * gates
181
+
182
+ # Select non-zero weights
183
+ active_mask = gated_weights > 1e-8
184
+ indices = torch.where(active_mask)[0]
185
+
186
+ selected_data = data[indices]
187
+ selected_weights = gated_weights[indices]
188
+
189
+ return selected_data, selected_weights, indices
190
+
191
+
192
+ class FeatureGate(L0Gate):
193
+ """
194
+ L0 gate for feature selection.
195
+
196
+ Parameters
197
+ ----------
198
+ n_features : int
199
+ Total number of features
200
+ max_features : int
201
+ Maximum number of features to select
202
+ temperature : float
203
+ Temperature for HardConcrete distribution
204
+ """
205
+
206
+ def __init__(
207
+ self,
208
+ n_features: int,
209
+ max_features: int,
210
+ temperature: float = 0.1,
211
+ ):
212
+ # Initialize to select approximately max_features
213
+ init_mean = min(max_features / n_features, 0.99)
214
+ super().__init__(
215
+ size=n_features,
216
+ temperature=temperature,
217
+ init_mean=init_mean,
218
+ )
219
+ self.n_features = n_features
220
+ self.max_features = max_features
221
+
222
+ def select_features(
223
+ self, data: torch.Tensor
224
+ ) -> tuple[torch.Tensor, torch.Tensor]:
225
+ """
226
+ Select features from data.
227
+
228
+ Parameters
229
+ ----------
230
+ data : torch.Tensor
231
+ Data tensor of shape (..., n_features)
232
+
233
+ Returns
234
+ -------
235
+ selected_data : torch.Tensor
236
+ Data with selected features
237
+ feature_indices : torch.Tensor
238
+ Indices of selected features
239
+ """
240
+ feature_indices = self.get_active_indices()
241
+ selected_data = data[..., feature_indices]
242
+ return selected_data, feature_indices
243
+
244
+ def select_features_with_names(
245
+ self, data: torch.Tensor, feature_names: list[str]
246
+ ) -> tuple[torch.Tensor, list[str]]:
247
+ """
248
+ Select features and return their names.
249
+
250
+ Parameters
251
+ ----------
252
+ data : torch.Tensor
253
+ Data tensor of shape (..., n_features)
254
+ feature_names : List[str]
255
+ Names of all features
256
+
257
+ Returns
258
+ -------
259
+ selected_data : torch.Tensor
260
+ Data with selected features
261
+ selected_names : List[str]
262
+ Names of selected features
263
+ """
264
+ selected_data, indices = self.select_features(data)
265
+ selected_names = [feature_names[i] for i in indices]
266
+ return selected_data, selected_names
267
+
268
+ def get_feature_importance(self) -> torch.Tensor:
269
+ """
270
+ Get feature importance scores based on gate probabilities.
271
+
272
+ Returns
273
+ -------
274
+ torch.Tensor
275
+ Importance score for each feature
276
+ """
277
+ with torch.no_grad():
278
+ return self.gates.get_active_prob()
279
+
280
+
281
+ class HybridGate(nn.Module):
282
+ """
283
+ Hybrid selection combining L0 and random sampling.
284
+
285
+ Useful for combining intelligent selection with random sampling
286
+ for better coverage and reduced bias.
287
+
288
+ Parameters
289
+ ----------
290
+ n_items : int
291
+ Total number of items
292
+ l0_fraction : float
293
+ Fraction to select via L0 gates
294
+ random_fraction : float
295
+ Fraction to select randomly
296
+ target_items : int
297
+ Total target number of items
298
+ temperature : float
299
+ Temperature for L0 gates
300
+ """
301
+
302
+ def __init__(
303
+ self,
304
+ n_items: int,
305
+ l0_fraction: float = 0.25,
306
+ random_fraction: float = 0.75,
307
+ target_items: int | None = None,
308
+ temperature: float = 0.25,
309
+ ):
310
+ super().__init__()
311
+
312
+ assert l0_fraction + random_fraction <= 1.0
313
+
314
+ self.n_items = n_items
315
+ self.l0_fraction = l0_fraction
316
+ self.random_fraction = random_fraction
317
+ self.target_items = target_items or int(n_items * 0.2)
318
+
319
+ # Number of items for each selection method
320
+ self.n_l0 = int(self.target_items * l0_fraction)
321
+ self.n_random = int(self.target_items * random_fraction)
322
+
323
+ # L0 gates for intelligent selection
324
+ self.l0_gate = SampleGate(
325
+ n_samples=n_items,
326
+ target_samples=self.n_l0,
327
+ temperature=temperature,
328
+ )
329
+
330
+ def select(
331
+ self,
332
+ data: torch.Tensor,
333
+ random_seed: int | None = None,
334
+ ) -> tuple[torch.Tensor, torch.Tensor, np.ndarray]:
335
+ """
336
+ Select items using hybrid approach.
337
+
338
+ Parameters
339
+ ----------
340
+ data : torch.Tensor
341
+ Data tensor of shape (n_items, ...)
342
+ random_seed : Optional[int]
343
+ Random seed for reproducibility
344
+
345
+ Returns
346
+ -------
347
+ selected_data : torch.Tensor
348
+ Selected items
349
+ indices : torch.Tensor
350
+ Indices of selected items
351
+ selection_type : np.ndarray
352
+ Type of selection for each item ('l0' or 'random')
353
+ """
354
+ if random_seed is not None:
355
+ torch.manual_seed(random_seed)
356
+
357
+ # L0 selection
358
+ l0_indices = self.l0_gate.get_active_indices()
359
+
360
+ # Random selection (excluding L0 selected indices)
361
+ all_indices = torch.arange(self.n_items)
362
+ mask = torch.ones(self.n_items, dtype=torch.bool)
363
+ mask[l0_indices] = False
364
+ available_indices = all_indices[mask]
365
+
366
+ # Randomly select from available indices
367
+ n_random_actual = min(self.n_random, len(available_indices))
368
+ if n_random_actual > 0:
369
+ perm = torch.randperm(len(available_indices))[:n_random_actual]
370
+ random_indices = available_indices[perm]
371
+ else:
372
+ random_indices = torch.tensor([], dtype=torch.long)
373
+
374
+ # Combine indices
375
+ all_selected = torch.cat([l0_indices, random_indices])
376
+ all_selected = torch.sort(all_selected)[0]
377
+
378
+ # Track selection type
379
+ # Create initial selection type array
380
+ _ = np.array(
381
+ ["l0"] * len(l0_indices) + ["random"] * len(random_indices)
382
+ )
383
+
384
+ # Reorder selection_type to match sorted indices
385
+ _ = torch.cat(
386
+ [
387
+ torch.arange(len(l0_indices)),
388
+ torch.arange(len(random_indices)) + len(l0_indices),
389
+ ]
390
+ )
391
+ idx_map = {idx.item(): i for i, idx in enumerate(all_selected)}
392
+ l0_positions = [idx_map[idx.item()] for idx in l0_indices]
393
+ random_positions = [idx_map[idx.item()] for idx in random_indices]
394
+
395
+ selection_type_sorted = np.empty(len(all_selected), dtype="<U6")
396
+ for pos in l0_positions:
397
+ selection_type_sorted[pos] = "l0"
398
+ for pos in random_positions:
399
+ selection_type_sorted[pos] = "random"
400
+
401
+ selected_data = data[all_selected]
402
+
403
+ return selected_data, all_selected, selection_type_sorted
404
+
405
+ def get_penalty(self) -> torch.Tensor:
406
+ """Get L0 penalty from the L0 component."""
407
+ return self.l0_gate.get_penalty()