l0-python 0.6.1__tar.gz → 0.6.2__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.
Files changed (23) hide show
  1. {l0_python-0.6.1/l0_python.egg-info → l0_python-0.6.2}/PKG-INFO +2 -2
  2. {l0_python-0.6.1 → l0_python-0.6.2}/l0/__init__.py +3 -0
  3. {l0_python-0.6.1 → l0_python-0.6.2}/l0/calibration.py +60 -44
  4. {l0_python-0.6.1 → l0_python-0.6.2}/l0/distributions.py +35 -14
  5. {l0_python-0.6.1 → l0_python-0.6.2}/l0/gates.py +3 -9
  6. {l0_python-0.6.1 → l0_python-0.6.2}/l0/layers.py +2 -6
  7. {l0_python-0.6.1 → l0_python-0.6.2}/l0/penalties.py +3 -9
  8. {l0_python-0.6.1 → l0_python-0.6.2}/l0/sparse.py +36 -21
  9. {l0_python-0.6.1 → l0_python-0.6.2/l0_python.egg-info}/PKG-INFO +2 -2
  10. {l0_python-0.6.1 → l0_python-0.6.2}/l0_python.egg-info/requires.txt +1 -1
  11. {l0_python-0.6.1 → l0_python-0.6.2}/pyproject.toml +5 -5
  12. {l0_python-0.6.1 → l0_python-0.6.2}/tests/test_calibration.py +104 -93
  13. {l0_python-0.6.1 → l0_python-0.6.2}/tests/test_distributions.py +127 -3
  14. {l0_python-0.6.1 → l0_python-0.6.2}/tests/test_gates.py +4 -4
  15. {l0_python-0.6.1 → l0_python-0.6.2}/tests/test_layers.py +2 -6
  16. {l0_python-0.6.1 → l0_python-0.6.2}/tests/test_penalties.py +3 -9
  17. {l0_python-0.6.1 → l0_python-0.6.2}/tests/test_sparse.py +48 -27
  18. {l0_python-0.6.1 → l0_python-0.6.2}/LICENSE +0 -0
  19. {l0_python-0.6.1 → l0_python-0.6.2}/README.md +0 -0
  20. {l0_python-0.6.1 → l0_python-0.6.2}/l0_python.egg-info/SOURCES.txt +0 -0
  21. {l0_python-0.6.1 → l0_python-0.6.2}/l0_python.egg-info/dependency_links.txt +0 -0
  22. {l0_python-0.6.1 → l0_python-0.6.2}/l0_python.egg-info/top_level.txt +0 -0
  23. {l0_python-0.6.1 → l0_python-0.6.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: l0-python
3
- Version: 0.6.1
3
+ Version: 0.6.2
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
@@ -45,7 +45,7 @@ Requires-Dist: scipy>=1.7.0
45
45
  Provides-Extra: dev
46
46
  Requires-Dist: pytest>=7.0; extra == "dev"
47
47
  Requires-Dist: pytest-cov>=3.0; extra == "dev"
48
- Requires-Dist: black>=22.0; extra == "dev"
48
+ Requires-Dist: ruff>=0.9.0; extra == "dev"
49
49
  Requires-Dist: build>=1.3; extra == "dev"
50
50
  Requires-Dist: scikit-learn>=1.0; extra == "dev"
51
51
  Requires-Dist: yaml-changelog>=0.3.0; extra == "dev"
@@ -7,6 +7,7 @@ and intelligent sampling, based on Louizos, Welling, & Kingma (2017).
7
7
 
8
8
  __version__ = "0.5.0"
9
9
 
10
+ from .calibration import SparseCalibrationWeights
10
11
  from .distributions import HardConcrete
11
12
  from .gates import FeatureGate, HybridGate, L0Gate, SampleGate
12
13
  from .layers import (
@@ -39,6 +40,8 @@ __all__ = [
39
40
  "prune_model",
40
41
  # Sparse
41
42
  "SparseL0Linear",
43
+ # Calibration
44
+ "SparseCalibrationWeights",
42
45
  # Gates
43
46
  "L0Gate",
44
47
  "SampleGate",
@@ -47,6 +47,12 @@ class SparseCalibrationWeights(nn.Module):
47
47
  Set to 0 to disable jitter. Default is 0.01 (following Louizos et al.).
48
48
  device : str or torch.device
49
49
  Device to run computations on ('cpu' or 'cuda')
50
+ seed : int, optional
51
+ Seed for the RNG used by the `log_alpha` init jitter and (inside
52
+ ``fit``) the `log_weight` jitter. When set, two models with the same
53
+ inputs produce byte-identical initial `log_alpha` and jitter without
54
+ the caller having to manage PyTorch's global RNG. ``None`` (default)
55
+ preserves legacy behaviour of using the global RNG.
50
56
  """
51
57
 
52
58
  def __init__(
@@ -61,6 +67,7 @@ class SparseCalibrationWeights(nn.Module):
61
67
  log_alpha_jitter_sd: float = 0.01,
62
68
  device: str | torch.device = "cpu",
63
69
  use_gates: bool = True,
70
+ seed: int | None = None,
64
71
  ):
65
72
  super().__init__()
66
73
  self.n_features = n_features
@@ -71,6 +78,17 @@ class SparseCalibrationWeights(nn.Module):
71
78
  self.log_alpha_jitter_sd = log_alpha_jitter_sd
72
79
  self.device = torch.device(device)
73
80
  self.use_gates = use_gates
81
+ self.seed = seed
82
+
83
+ # Local RNG (only used when `seed` is provided). Kept on the module so
84
+ # `fit`'s `log_weight` jitter uses the same deterministic stream.
85
+ if seed is not None:
86
+ self._generator: torch.Generator | None = torch.Generator(
87
+ device=self.device
88
+ )
89
+ self._generator.manual_seed(int(seed))
90
+ else:
91
+ self._generator = None
74
92
 
75
93
  # Initialize weights (on original scale)
76
94
  if init_weights is None:
@@ -121,7 +139,7 @@ class SparseCalibrationWeights(nn.Module):
121
139
  # Add jitter to break symmetry (if specified)
122
140
  if self.log_alpha_jitter_sd > 0:
123
141
  jitter = (
124
- torch.randn(n_features, device=self.device)
142
+ torch.randn(n_features, generator=self._generator, device=self.device)
125
143
  * self.log_alpha_jitter_sd
126
144
  )
127
145
  self.log_alpha = nn.Parameter(mu + jitter)
@@ -132,9 +150,7 @@ class SparseCalibrationWeights(nn.Module):
132
150
  self._cached_M_torch: torch.sparse.Tensor | None = None
133
151
  self._cached_M_shape: tuple[int, int] | None = None
134
152
 
135
- def _convert_sparse_to_torch(
136
- self, M_sparse: sp.spmatrix
137
- ) -> torch.sparse.Tensor:
153
+ def _convert_sparse_to_torch(self, M_sparse: sp.spmatrix) -> torch.sparse.Tensor:
138
154
  """
139
155
  Convert scipy sparse matrix to torch sparse tensor.
140
156
 
@@ -148,9 +164,7 @@ class SparseCalibrationWeights(nn.Module):
148
164
  return self._cached_M_torch
149
165
 
150
166
  M_coo = M_sparse.tocoo()
151
- indices = torch.LongTensor(np.vstack([M_coo.row, M_coo.col])).to(
152
- self.device
153
- )
167
+ indices = torch.LongTensor(np.vstack([M_coo.row, M_coo.col])).to(self.device)
154
168
  values = torch.FloatTensor(M_coo.data).to(self.device)
155
169
  M_torch = torch.sparse_coo_tensor(
156
170
  indices,
@@ -166,18 +180,26 @@ class SparseCalibrationWeights(nn.Module):
166
180
 
167
181
  return M_torch
168
182
 
183
+ # Bounds for `log_alpha` before scaling by `beta`. Values outside
184
+ # ``[-LOG_ALPHA_BOUND, LOG_ALPHA_BOUND]`` saturate the sigmoid and
185
+ # vanish gradients; clamping keeps sampling and deterministic gates
186
+ # well-defined under fp16/bf16.
187
+ LOG_ALPHA_BOUND: float = 20.0
188
+
169
189
  def _sample_gates(self) -> torch.Tensor:
170
190
  """Sample gates using Hard Concrete distribution."""
171
191
  eps = 1e-6
172
- u = torch.rand_like(self.log_alpha).clamp(eps, 1 - eps)
173
- s = (torch.log(u) - torch.log(1 - u) + self.log_alpha) / self.beta
192
+ log_alpha = self.log_alpha.clamp(-self.LOG_ALPHA_BOUND, self.LOG_ALPHA_BOUND)
193
+ u = torch.rand_like(log_alpha).clamp(eps, 1 - eps)
194
+ s = (torch.log(u) - torch.log(1 - u) + log_alpha) / self.beta
174
195
  s = torch.sigmoid(s)
175
196
  s_bar = s * (self.zeta - self.gamma) + self.gamma
176
197
  return s_bar.clamp(0, 1)
177
198
 
178
199
  def get_deterministic_gates(self) -> torch.Tensor:
179
200
  """Get deterministic gate values (for inference)."""
180
- s = torch.sigmoid(self.log_alpha / self.beta)
201
+ log_alpha = self.log_alpha.clamp(-self.LOG_ALPHA_BOUND, self.LOG_ALPHA_BOUND)
202
+ s = torch.sigmoid(log_alpha / self.beta)
181
203
  s_bar = s * (self.zeta - self.gamma) + self.gamma
182
204
  return s_bar.clamp(0, 1)
183
205
 
@@ -249,7 +271,8 @@ class SparseCalibrationWeights(nn.Module):
249
271
  c = -self.beta * torch.log(
250
272
  torch.tensor(-self.gamma / self.zeta, device=self.device)
251
273
  )
252
- pi = torch.sigmoid(self.log_alpha + c)
274
+ log_alpha = self.log_alpha.clamp(-self.LOG_ALPHA_BOUND, self.LOG_ALPHA_BOUND)
275
+ pi = torch.sigmoid(log_alpha + c)
253
276
  return pi.sum()
254
277
 
255
278
  def get_l2_penalty(self) -> torch.Tensor:
@@ -360,9 +383,7 @@ class SparseCalibrationWeights(nn.Module):
360
383
 
361
384
  # Validate group_multipliers
362
385
  if group_multipliers is not None and target_groups is None:
363
- raise ValueError(
364
- "group_multipliers requires target_groups to be set"
365
- )
386
+ raise ValueError("group_multipliers requires target_groups to be set")
366
387
 
367
388
  # Compute group weights for loss averaging
368
389
  if target_groups is not None:
@@ -385,8 +406,7 @@ class SparseCalibrationWeights(nn.Module):
385
406
  group_mask = target_groups == gid
386
407
  if not group_mask.any():
387
408
  raise ValueError(
388
- f"group_multipliers key {gid} not found "
389
- f"in target_groups"
409
+ f"group_multipliers key {gid} not found in target_groups"
390
410
  )
391
411
  group_weights[group_mask] *= mult
392
412
  else:
@@ -396,9 +416,7 @@ class SparseCalibrationWeights(nn.Module):
396
416
  if target_groups is not None:
397
417
  n_groups = len(torch.unique(target_groups))
398
418
  parts = [f"{n_groups} groups"]
399
- parts.append(
400
- f"normalize={'on' if normalize_groups else 'off'}"
401
- )
419
+ parts.append(f"normalize={'on' if normalize_groups else 'off'}")
402
420
  if group_multipliers is not None:
403
421
  parts.append(f"multipliers={group_multipliers}")
404
422
  print(f"Groups: {', '.join(parts)}")
@@ -408,10 +426,17 @@ class SparseCalibrationWeights(nn.Module):
408
426
  # Add jitter to weights to break symmetry (if jitter_sd > 0)
409
427
  if self.log_weight_jitter_sd > 0:
410
428
  with torch.no_grad():
411
- jitter = (
412
- torch.randn_like(self.log_weight)
413
- * self.log_weight_jitter_sd
414
- )
429
+ # `torch.randn_like` can't take a generator kwarg, so draw
430
+ # explicitly via `torch.randn` when a local RNG is set.
431
+ if self._generator is not None:
432
+ noise = torch.randn(
433
+ self.log_weight.shape,
434
+ generator=self._generator,
435
+ device=self.device,
436
+ )
437
+ else:
438
+ noise = torch.randn_like(self.log_weight)
439
+ jitter = noise * self.log_weight_jitter_sd
415
440
  self.log_weight.data += jitter
416
441
 
417
442
  # Setup optimizer
@@ -428,9 +453,7 @@ class SparseCalibrationWeights(nn.Module):
428
453
  # Adding 1 to avoid division by zero
429
454
  relative_errors = (y - y_pred) / (y + 1)
430
455
  # Apply group weights and then average
431
- weighted_squared_errors = (
432
- relative_errors.pow(2) * group_weights
433
- )
456
+ weighted_squared_errors = relative_errors.pow(2) * group_weights
434
457
  data_loss = (
435
458
  weighted_squared_errors.sum()
436
459
  ) # Sum because weights already normalize
@@ -480,18 +503,14 @@ class SparseCalibrationWeights(nn.Module):
480
503
  group_losses = []
481
504
  for group_id in torch.unique(target_groups):
482
505
  group_mask = target_groups == group_id
483
- group_mean_err = (
484
- rel_errors[group_mask].mean().item()
485
- )
506
+ group_mean_err = rel_errors[group_mask].mean().item()
486
507
  group_losses.append(group_mean_err)
487
508
  mean_group_mare = np.mean(group_losses)
488
509
  else:
489
510
  mean_group_mare = mean_rel_err
490
511
 
491
512
  # Calculate sparsity percentage
492
- sparsity_pct = 100 * (
493
- 1 - active_info["count"] / self.n_features
494
- )
513
+ sparsity_pct = 100 * (1 - active_info["count"] / self.n_features)
495
514
 
496
515
  # Weight distribution diagnostic
497
516
  if active_weights.numel() > 0:
@@ -512,10 +531,7 @@ class SparseCalibrationWeights(nn.Module):
512
531
  .item()
513
532
  )
514
533
  w_large = (
515
- (
516
- (active_weights >= 10.0)
517
- & (active_weights < 1000.0)
518
- )
534
+ ((active_weights >= 10.0) & (active_weights < 1000.0))
519
535
  .sum()
520
536
  .item()
521
537
  )
@@ -523,12 +539,12 @@ class SparseCalibrationWeights(nn.Module):
523
539
 
524
540
  total_active = active_weights.numel()
525
541
  weight_dist = (
526
- f"[<0.01: {100*w_tiny/total_active:.1f}%, "
527
- f"0.01-0.1: {100*w_small/total_active:.1f}%, "
528
- f"0.1-1: {100*w_med/total_active:.1f}%, "
529
- f"1-10: {100*w_normal/total_active:.1f}%, "
530
- f"10-1000: {100*w_large/total_active:.1f}%, "
531
- f">1000: {100*w_huge/total_active:.1f}%]"
542
+ f"[<0.01: {100 * w_tiny / total_active:.1f}%, "
543
+ f"0.01-0.1: {100 * w_small / total_active:.1f}%, "
544
+ f"0.1-1: {100 * w_med / total_active:.1f}%, "
545
+ f"1-10: {100 * w_normal / total_active:.1f}%, "
546
+ f"10-1000: {100 * w_large / total_active:.1f}%, "
547
+ f">1000: {100 * w_huge / total_active:.1f}%]"
532
548
  )
533
549
  else:
534
550
  weight_dist = "[no active weights]"
@@ -540,7 +556,7 @@ class SparseCalibrationWeights(nn.Module):
540
556
 
541
557
  if target_groups is not None:
542
558
  print(
543
- f"Epoch {epoch+1:4d}: "
559
+ f"Epoch {epoch + 1:4d}: "
544
560
  f"mean_group_mare={mean_group_mare:.4%}, "
545
561
  f"max_error={max_rel_err:.1%}, "
546
562
  f"total_loss={actual_total_loss:.3f}, "
@@ -549,7 +565,7 @@ class SparseCalibrationWeights(nn.Module):
549
565
  )
550
566
  else:
551
567
  print(
552
- f"Epoch {epoch+1:4d}: "
568
+ f"Epoch {epoch + 1:4d}: "
553
569
  f"mean_error={mean_rel_err:.4%}, "
554
570
  f"max_error={max_rel_err:.1%}, "
555
571
  f"total_loss={actual_total_loss:.3f}, "
@@ -69,9 +69,7 @@ class HardConcrete(nn.Module):
69
69
  init_val = math.log(self.init_mean / (1 - self.init_mean))
70
70
  self.qz_logits.data.fill_(init_val)
71
71
 
72
- def forward(
73
- self, input_shape: tuple[int, ...] | None = None
74
- ) -> torch.Tensor:
72
+ def forward(self, input_shape: tuple[int, ...] | None = None) -> torch.Tensor:
75
73
  """
76
74
  Sample or compute gates.
77
75
 
@@ -98,6 +96,16 @@ class HardConcrete(nn.Module):
98
96
 
99
97
  return gates
100
98
 
99
+ # Bounds for `qz_logits` before sigmoid/temperature scaling. Values
100
+ # outside ``[-LOG_ALPHA_BOUND, LOG_ALPHA_BOUND]`` saturate the sigmoid
101
+ # and vanish gradients; clamping keeps sampling and deterministic gates
102
+ # well-defined in fp16/bf16 as well. See Louizos et al. 2017.
103
+ LOG_ALPHA_BOUND: float = 20.0
104
+
105
+ # Epsilon for uniform sampling; fp16-safe (``1e-8`` underflows to zero
106
+ # at fp16 and ``log(0) = -inf``).
107
+ _UNIFORM_EPS: float = 1e-6
108
+
101
109
  def _sample_gates(self) -> torch.Tensor:
102
110
  """
103
111
  Sample gates using the reparameterization trick.
@@ -107,12 +115,19 @@ class HardConcrete(nn.Module):
107
115
  torch.Tensor
108
116
  Sampled gate values in [0, 1]
109
117
  """
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)
118
+ # Clamp logits to a safe range so log-odds stay finite under fp16/bf16
119
+ # and gradients don't vanish at saturation.
120
+ logits = self.qz_logits.clamp(-self.LOG_ALPHA_BOUND, self.LOG_ALPHA_BOUND)
121
+
122
+ # Sample uniform noise (avoiding exact 0 and 1 for numerical stability).
123
+ # ``1e-6`` matches calibration.py / sparse.py and is fp16-safe.
124
+ u = torch.zeros_like(logits).uniform_(
125
+ self._UNIFORM_EPS, 1.0 - self._UNIFORM_EPS
126
+ )
112
127
 
113
128
  # Apply the concrete distribution transformation
114
129
  # s = sigmoid((log(u) - log(1-u) + logits) / temperature)
115
- s = torch.log(u) - torch.log(1 - u) + self.qz_logits
130
+ s = torch.log(u) - torch.log(1 - u) + logits
116
131
  s = torch.sigmoid(s / self.temperature)
117
132
 
118
133
  # Stretch and clamp to create hard concrete
@@ -125,13 +140,20 @@ class HardConcrete(nn.Module):
125
140
  """
126
141
  Compute deterministic gates for evaluation.
127
142
 
143
+ Uses the mean of the Hard Concrete distribution, which applies the
144
+ temperature scaling to ``qz_logits`` so that ``eval()`` output is
145
+ consistent with ``_sample_gates`` and ``get_penalty`` /
146
+ ``get_active_prob`` (see Louizos et al. 2017, Eq. 11).
147
+
128
148
  Returns
129
149
  -------
130
150
  torch.Tensor
131
151
  Deterministic gate values in [0, 1]
132
152
  """
133
- # Use mean of the distribution
134
- probs = torch.sigmoid(self.qz_logits)
153
+ # Clamp logits so eval output stays well-defined in fp16/bf16.
154
+ logits = self.qz_logits.clamp(-self.LOG_ALPHA_BOUND, self.LOG_ALPHA_BOUND)
155
+ # Mean of the binary concrete before stretch: sigmoid(logits / beta).
156
+ probs = torch.sigmoid(logits / self.temperature)
135
157
 
136
158
  # Apply stretching transformation
137
159
  gates = probs * (self.zeta - self.gamma) + self.gamma
@@ -147,10 +169,10 @@ class HardConcrete(nn.Module):
147
169
  torch.Tensor
148
170
  Expected number of non-zero gates
149
171
  """
172
+ # Clamp logits so sigmoid doesn't saturate and kill gradients.
173
+ logits = self.qz_logits.clamp(-self.LOG_ALPHA_BOUND, self.LOG_ALPHA_BOUND)
150
174
  # Shift logits to account for hard concrete bounds
151
- logits_shifted = self.qz_logits - self.temperature * math.log(
152
- -self.gamma / self.zeta
153
- )
175
+ logits_shifted = logits - self.temperature * math.log(-self.gamma / self.zeta)
154
176
 
155
177
  # Probability that gate is active (non-zero)
156
178
  prob_active = torch.sigmoid(logits_shifted)
@@ -166,9 +188,8 @@ class HardConcrete(nn.Module):
166
188
  torch.Tensor
167
189
  Probability of each gate being non-zero
168
190
  """
169
- logits_shifted = self.qz_logits - self.temperature * math.log(
170
- -self.gamma / self.zeta
171
- )
191
+ logits = self.qz_logits.clamp(-self.LOG_ALPHA_BOUND, self.LOG_ALPHA_BOUND)
192
+ logits_shifted = logits - self.temperature * math.log(-self.gamma / self.zeta)
172
193
  return torch.sigmoid(logits_shifted)
173
194
 
174
195
  def get_sparsity(self) -> float:
@@ -130,9 +130,7 @@ class SampleGate(L0Gate):
130
130
  self.n_samples = n_samples
131
131
  self.target_samples = target_samples
132
132
 
133
- def select_samples(
134
- self, data: torch.Tensor
135
- ) -> tuple[torch.Tensor, torch.Tensor]:
133
+ def select_samples(self, data: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
136
134
  """
137
135
  Select samples from data.
138
136
 
@@ -219,9 +217,7 @@ class FeatureGate(L0Gate):
219
217
  self.n_features = n_features
220
218
  self.max_features = max_features
221
219
 
222
- def select_features(
223
- self, data: torch.Tensor
224
- ) -> tuple[torch.Tensor, torch.Tensor]:
220
+ def select_features(self, data: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
225
221
  """
226
222
  Select features from data.
227
223
 
@@ -377,9 +373,7 @@ class HybridGate(nn.Module):
377
373
 
378
374
  # Track selection type
379
375
  # Create initial selection type array
380
- _ = np.array(
381
- ["l0"] * len(l0_indices) + ["random"] * len(random_indices)
382
- )
376
+ _ = np.array(["l0"] * len(l0_indices) + ["random"] * len(random_indices))
383
377
 
384
378
  # Reorder selection_type to match sorted indices
385
379
  _ = torch.cat(
@@ -222,9 +222,7 @@ class L0Conv2d(nn.Module):
222
222
  gates = gates.view(self.weight.shape)
223
223
  masked_weight = self.weight * gates
224
224
 
225
- return F.conv2d(
226
- input, masked_weight, self.bias, self.stride, self.padding
227
- )
225
+ return F.conv2d(input, masked_weight, self.bias, self.stride, self.padding)
228
226
 
229
227
  def get_l0_penalty(self) -> torch.Tensor:
230
228
  """Get L0 penalty."""
@@ -398,9 +396,7 @@ class SparseMLP(nn.Module):
398
396
  """Get sparsity statistics for all layers."""
399
397
  stats = {}
400
398
  for name, module in self.named_modules():
401
- if hasattr(module, "get_sparsity") and hasattr(
402
- module, "get_l0_penalty"
403
- ):
399
+ if hasattr(module, "get_sparsity") and hasattr(module, "get_l0_penalty"):
404
400
  stats[name] = {
405
401
  "sparsity": module.get_sparsity(),
406
402
  "active_params": module.get_l0_penalty().item(),
@@ -107,9 +107,7 @@ def get_sparsity_stats(model: nn.Module) -> dict[str, dict[str, Any]]:
107
107
  stats = {}
108
108
 
109
109
  for name, module in model.named_modules():
110
- if hasattr(module, "get_sparsity") and hasattr(
111
- module, "get_l0_penalty"
112
- ):
110
+ if hasattr(module, "get_sparsity") and hasattr(module, "get_l0_penalty"):
113
111
  active_params = module.get_l0_penalty().item()
114
112
 
115
113
  # Calculate total parameters
@@ -204,16 +202,12 @@ class TemperatureScheduler:
204
202
  progress = epoch / self.anneal_epochs
205
203
 
206
204
  if self.schedule == "linear":
207
- temp = (
208
- self.initial_temp
209
- - (self.initial_temp - self.final_temp) * progress
210
- )
205
+ temp = self.initial_temp - (self.initial_temp - self.final_temp) * progress
211
206
 
212
207
  elif self.schedule == "exponential":
213
208
  log_temp = (
214
209
  np.log(self.initial_temp)
215
- - (np.log(self.initial_temp) - np.log(self.final_temp))
216
- * progress
210
+ - (np.log(self.initial_temp) - np.log(self.final_temp)) * progress
217
211
  )
218
212
  temp = np.exp(log_temp)
219
213
 
@@ -7,7 +7,6 @@ sparse input matrices without converting them to dense format.
7
7
 
8
8
  # ruff: noqa: N803 N806
9
9
 
10
-
11
10
  import numpy as np
12
11
  import torch
13
12
  import torch.nn as nn
@@ -37,6 +36,12 @@ class SparseL0Linear(nn.Module):
37
36
  Initial probability of keeping each feature
38
37
  device : str or torch.device
39
38
  Device to run computations on ('cpu' or 'cuda')
39
+ seed : int, optional
40
+ Seed for the RNG used by the ``log_alpha`` init. When set, two
41
+ models with the same inputs produce byte-identical initial
42
+ ``log_alpha`` without the caller having to manage PyTorch's
43
+ global RNG. ``None`` (default) preserves legacy behaviour of
44
+ using the global RNG.
40
45
  """
41
46
 
42
47
  def __init__(
@@ -48,6 +53,7 @@ class SparseL0Linear(nn.Module):
48
53
  zeta: float = 1.1,
49
54
  init_keep_prob: float = 0.5,
50
55
  device: str | torch.device = "cpu",
56
+ seed: int | None = None,
51
57
  ):
52
58
  super().__init__()
53
59
  self.n_features = n_features
@@ -56,6 +62,15 @@ class SparseL0Linear(nn.Module):
56
62
  self.gamma = gamma
57
63
  self.zeta = zeta
58
64
  self.device = torch.device(device)
65
+ self.seed = seed
66
+
67
+ if seed is not None:
68
+ self._generator: torch.Generator | None = torch.Generator(
69
+ device=self.device
70
+ )
71
+ self._generator.manual_seed(int(seed))
72
+ else:
73
+ self._generator = None
59
74
 
60
75
  # Model parameters
61
76
  self.weight = nn.Parameter(torch.zeros(n_features, device=self.device))
@@ -64,21 +79,16 @@ class SparseL0Linear(nn.Module):
64
79
  else:
65
80
  self.register_parameter("bias", None)
66
81
 
67
- # L0 gate parameters
82
+ # L0 gate parameters: mu + N(0, 0.01) jitter, optionally seeded.
68
83
  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
- )
84
+ noise = torch.randn(n_features, generator=self._generator, device=self.device)
85
+ self.log_alpha = nn.Parameter(mu.item() + 0.01 * noise)
74
86
 
75
87
  # Cache for sparse tensor conversion
76
88
  self._cached_X_torch: torch.sparse.Tensor | None = None
77
89
  self._cached_X_shape: tuple[int, int] | None = None
78
90
 
79
- def _convert_sparse_to_torch(
80
- self, X_sparse: sp.spmatrix
81
- ) -> torch.sparse.Tensor:
91
+ def _convert_sparse_to_torch(self, X_sparse: sp.spmatrix) -> torch.sparse.Tensor:
82
92
  """
83
93
  Convert scipy sparse matrix to torch sparse tensor.
84
94
 
@@ -92,9 +102,7 @@ class SparseL0Linear(nn.Module):
92
102
  return self._cached_X_torch
93
103
 
94
104
  X_coo = X_sparse.tocoo()
95
- indices = torch.LongTensor(np.vstack([X_coo.row, X_coo.col])).to(
96
- self.device
97
- )
105
+ indices = torch.LongTensor(np.vstack([X_coo.row, X_coo.col])).to(self.device)
98
106
  values = torch.FloatTensor(X_coo.data).to(self.device)
99
107
  X_torch = torch.sparse_coo_tensor(
100
108
  indices,
@@ -110,18 +118,26 @@ class SparseL0Linear(nn.Module):
110
118
 
111
119
  return X_torch
112
120
 
121
+ # Bounds for `log_alpha` before scaling by `beta`. Values outside
122
+ # ``[-LOG_ALPHA_BOUND, LOG_ALPHA_BOUND]`` saturate the sigmoid and
123
+ # vanish gradients; clamping keeps sampling and deterministic gates
124
+ # well-defined under fp16/bf16.
125
+ LOG_ALPHA_BOUND: float = 20.0
126
+
113
127
  def _sample_gates(self) -> torch.Tensor:
114
128
  """Sample gates using Hard Concrete distribution."""
115
129
  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
130
+ log_alpha = self.log_alpha.clamp(-self.LOG_ALPHA_BOUND, self.LOG_ALPHA_BOUND)
131
+ u = torch.rand_like(log_alpha).clamp(eps, 1 - eps)
132
+ X = (torch.log(u) - torch.log(1 - u) + log_alpha) / self.beta
118
133
  s = torch.sigmoid(X)
119
134
  s_bar = s * (self.zeta - self.gamma) + self.gamma
120
135
  return s_bar.clamp(0, 1)
121
136
 
122
137
  def get_deterministic_gates(self) -> torch.Tensor:
123
138
  """Get deterministic gate values (for inference)."""
124
- X = self.log_alpha / self.beta
139
+ log_alpha = self.log_alpha.clamp(-self.LOG_ALPHA_BOUND, self.LOG_ALPHA_BOUND)
140
+ X = log_alpha / self.beta
125
141
  s = torch.sigmoid(X)
126
142
  s_bar = s * (self.zeta - self.gamma) + self.gamma
127
143
  return s_bar.clamp(0, 1)
@@ -178,7 +194,8 @@ class SparseL0Linear(nn.Module):
178
194
  c = -self.beta * torch.log(
179
195
  torch.tensor(-self.gamma / self.zeta, device=self.device)
180
196
  )
181
- pi = torch.sigmoid(self.log_alpha + c)
197
+ log_alpha = self.log_alpha.clamp(-self.LOG_ALPHA_BOUND, self.LOG_ALPHA_BOUND)
198
+ pi = torch.sigmoid(log_alpha + c)
182
199
  return pi.sum()
183
200
 
184
201
  def get_sparsity(self) -> float:
@@ -291,7 +308,7 @@ class SparseL0Linear(nn.Module):
291
308
  with torch.no_grad():
292
309
  n_active = (self.get_deterministic_gates() > 0.01).sum()
293
310
  print(
294
- f"Epoch {epoch+1:4d}: "
311
+ f"Epoch {epoch + 1:4d}: "
295
312
  f"loss={loss.item():.4f}, "
296
313
  f"mse={mse_loss.item():.4f}, "
297
314
  f"l0={l0_loss.item():.2f}, "
@@ -300,9 +317,7 @@ class SparseL0Linear(nn.Module):
300
317
 
301
318
  return self
302
319
 
303
- def predict(
304
- self, X_sparse: sp.spmatrix | torch.sparse.Tensor
305
- ) -> torch.Tensor:
320
+ def predict(self, X_sparse: sp.spmatrix | torch.sparse.Tensor) -> torch.Tensor:
306
321
  """
307
322
  Make predictions on new data.
308
323
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: l0-python
3
- Version: 0.6.1
3
+ Version: 0.6.2
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
@@ -45,7 +45,7 @@ Requires-Dist: scipy>=1.7.0
45
45
  Provides-Extra: dev
46
46
  Requires-Dist: pytest>=7.0; extra == "dev"
47
47
  Requires-Dist: pytest-cov>=3.0; extra == "dev"
48
- Requires-Dist: black>=22.0; extra == "dev"
48
+ Requires-Dist: ruff>=0.9.0; extra == "dev"
49
49
  Requires-Dist: build>=1.3; extra == "dev"
50
50
  Requires-Dist: scikit-learn>=1.0; extra == "dev"
51
51
  Requires-Dist: yaml-changelog>=0.3.0; extra == "dev"
@@ -5,7 +5,7 @@ scipy>=1.7.0
5
5
  [dev]
6
6
  pytest>=7.0
7
7
  pytest-cov>=3.0
8
- black>=22.0
8
+ ruff>=0.9.0
9
9
  build>=1.3
10
10
  scikit-learn>=1.0
11
11
  yaml-changelog>=0.3.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "l0-python"
7
- version = "0.6.1"
7
+ version = "0.6.2"
8
8
  description = "L0 regularization for sparse neural networks and intelligent sampling"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -31,7 +31,7 @@ keywords = ["l0", "regularization", "sparsity", "neural networks", "calibration"
31
31
  dev = [
32
32
  "pytest>=7.0",
33
33
  "pytest-cov>=3.0",
34
- "black>=22.0",
34
+ "ruff>=0.9.0",
35
35
  "build>=1.3",
36
36
  "scikit-learn>=1.0",
37
37
  "yaml-changelog>=0.3.0",
@@ -58,9 +58,9 @@ Repository = "https://github.com/PolicyEngine/L0"
58
58
  Documentation = "https://github.com/PolicyEngine/L0#readme"
59
59
  Issues = "https://github.com/PolicyEngine/L0/issues"
60
60
 
61
- [tool.black]
62
- line-length = 79
63
- target-version = ['py313']
61
+ [tool.ruff]
62
+ # ruff 0.16+ also formats Markdown code blocks; keep formatting checks to Python.
63
+ extend-exclude = ["*.md"]
64
64
 
65
65
  [tool.pytest.ini_options]
66
66
  testpaths = ["tests"]