l0-python 0.3.0__tar.gz → 0.3.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.
Files changed (23) hide show
  1. {l0_python-0.3.0 → l0_python-0.3.1}/PKG-INFO +2 -2
  2. {l0_python-0.3.0 → l0_python-0.3.1}/l0/calibration.py +80 -21
  3. {l0_python-0.3.0 → l0_python-0.3.1}/l0_python.egg-info/PKG-INFO +2 -2
  4. {l0_python-0.3.0 → l0_python-0.3.1}/pyproject.toml +2 -2
  5. {l0_python-0.3.0 → l0_python-0.3.1}/tests/test_calibration.py +127 -5
  6. {l0_python-0.3.0 → l0_python-0.3.1}/LICENSE +0 -0
  7. {l0_python-0.3.0 → l0_python-0.3.1}/README.md +0 -0
  8. {l0_python-0.3.0 → l0_python-0.3.1}/l0/__init__.py +0 -0
  9. {l0_python-0.3.0 → l0_python-0.3.1}/l0/distributions.py +0 -0
  10. {l0_python-0.3.0 → l0_python-0.3.1}/l0/gates.py +0 -0
  11. {l0_python-0.3.0 → l0_python-0.3.1}/l0/layers.py +0 -0
  12. {l0_python-0.3.0 → l0_python-0.3.1}/l0/penalties.py +0 -0
  13. {l0_python-0.3.0 → l0_python-0.3.1}/l0/sparse.py +0 -0
  14. {l0_python-0.3.0 → l0_python-0.3.1}/l0_python.egg-info/SOURCES.txt +0 -0
  15. {l0_python-0.3.0 → l0_python-0.3.1}/l0_python.egg-info/dependency_links.txt +0 -0
  16. {l0_python-0.3.0 → l0_python-0.3.1}/l0_python.egg-info/requires.txt +0 -0
  17. {l0_python-0.3.0 → l0_python-0.3.1}/l0_python.egg-info/top_level.txt +0 -0
  18. {l0_python-0.3.0 → l0_python-0.3.1}/setup.cfg +0 -0
  19. {l0_python-0.3.0 → l0_python-0.3.1}/tests/test_distributions.py +0 -0
  20. {l0_python-0.3.0 → l0_python-0.3.1}/tests/test_gates.py +0 -0
  21. {l0_python-0.3.0 → l0_python-0.3.1}/tests/test_layers.py +0 -0
  22. {l0_python-0.3.0 → l0_python-0.3.1}/tests/test_penalties.py +0 -0
  23. {l0_python-0.3.0 → l0_python-0.3.1}/tests/test_sparse.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: l0-python
3
- Version: 0.3.0
3
+ Version: 0.3.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
@@ -36,7 +36,7 @@ Classifier: License :: OSI Approved :: MIT License
36
36
  Classifier: Programming Language :: Python :: 3
37
37
  Classifier: Programming Language :: Python :: 3.13
38
38
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
39
- Requires-Python: >=3.13
39
+ Requires-Python: >=3.11
40
40
  Description-Content-Type: text/markdown
41
41
  License-File: LICENSE
42
42
  Requires-Dist: torch>=2.0.0
@@ -30,10 +30,21 @@ class SparseCalibrationWeights(nn.Module):
30
30
  Lower bound of stretched concrete distribution
31
31
  zeta : float
32
32
  Upper bound of stretched concrete distribution
33
- init_keep_prob : float
34
- Initial probability of keeping each weight active
35
- init_weight_scale : float
36
- Initial scale for log weights (controls initial weight magnitude)
33
+ init_keep_prob : float or array-like
34
+ Initial probability of keeping each weight active.
35
+ If float, all weights use the same probability.
36
+ If array, must have shape (n_features,)
37
+ init_weights : float, array-like, or None
38
+ Initial weight values (on original scale, not log).
39
+ If float, all weights initialized to this value.
40
+ If array, must have shape (n_features,).
41
+ If None, defaults to 1.0 for all weights.
42
+ log_weight_jitter_sd : float
43
+ Standard deviation of noise added to log weights at start of fit() to break symmetry.
44
+ Set to 0 to disable jitter. Default is 0.0 (no jitter).
45
+ log_alpha_jitter_sd : float
46
+ Standard deviation of noise added to log_alpha at initialization to break symmetry.
47
+ Set to 0 to disable jitter. Default is 0.01 (following Louizos et al.).
37
48
  device : str or torch.device
38
49
  Device to run computations on ('cpu' or 'cuda')
39
50
  """
@@ -44,8 +55,10 @@ class SparseCalibrationWeights(nn.Module):
44
55
  beta: float = 2 / 3,
45
56
  gamma: float = -0.1,
46
57
  zeta: float = 1.1,
47
- init_keep_prob: float = 0.5,
48
- init_weight_scale: float = 1.0,
58
+ init_keep_prob: float | np.ndarray = 0.5,
59
+ init_weights: float | np.ndarray | None = None,
60
+ log_weight_jitter_sd: float = 0.0,
61
+ log_alpha_jitter_sd: float = 0.01,
49
62
  device: str | torch.device = "cpu",
50
63
  ):
51
64
  super().__init__()
@@ -53,25 +66,65 @@ class SparseCalibrationWeights(nn.Module):
53
66
  self.beta = beta
54
67
  self.gamma = gamma
55
68
  self.zeta = zeta
69
+ self.log_weight_jitter_sd = log_weight_jitter_sd
70
+ self.log_alpha_jitter_sd = log_alpha_jitter_sd
56
71
  self.device = torch.device(device)
57
72
 
58
- # Log weights to ensure positivity via exp transformation
59
- self.log_weight = nn.Parameter(
60
- torch.normal(
61
- mean=0.0,
62
- std=init_weight_scale,
63
- size=(n_features,),
64
- device=self.device,
73
+ # Initialize weights (on original scale)
74
+ if init_weights is None:
75
+ # Default: all weights start at 1.0
76
+ weight_values = torch.ones(n_features, device=self.device)
77
+ elif isinstance(init_weights, (int, float)):
78
+ # Scalar: all weights start at this value
79
+ weight_values = torch.full(
80
+ (n_features,), float(init_weights), device=self.device
65
81
  )
66
- )
82
+ else:
83
+ # Array: use provided values
84
+ weight_values = torch.tensor(
85
+ init_weights, dtype=torch.float32, device=self.device
86
+ )
87
+ if weight_values.shape != (n_features,):
88
+ raise ValueError(
89
+ f"init_weights array must have shape ({n_features},), "
90
+ f"got {weight_values.shape}"
91
+ )
92
+
93
+ # Convert to log space to ensure positivity via exp transformation
94
+ # Add small epsilon to avoid log(0)
95
+ self.log_weight = nn.Parameter(torch.log(weight_values + 1e-8))
67
96
 
68
97
  # L0 gate parameters
69
- mu = torch.log(torch.tensor(init_keep_prob / (1 - init_keep_prob)))
70
- self.log_alpha = nn.Parameter(
71
- torch.normal(
72
- mu.item(), 0.01, size=(n_features,), device=self.device
98
+ # Handle init_keep_prob as scalar or array
99
+ if isinstance(init_keep_prob, (int, float)):
100
+ # Scalar: broadcast to all features
101
+ keep_prob_values = torch.full(
102
+ (n_features,), float(init_keep_prob), device=self.device
73
103
  )
74
- )
104
+ else:
105
+ # Array: use provided values
106
+ keep_prob_values = torch.tensor(
107
+ init_keep_prob, dtype=torch.float32, device=self.device
108
+ )
109
+ if keep_prob_values.shape != (n_features,):
110
+ raise ValueError(
111
+ f"init_keep_prob array must have shape ({n_features},), "
112
+ f"got {keep_prob_values.shape}"
113
+ )
114
+ # Clip to valid probability range to avoid log(0) or log(inf)
115
+ keep_prob_values = keep_prob_values.clamp(1e-6, 1 - 1e-6)
116
+
117
+ # Convert probabilities to log_alpha
118
+ mu = torch.log(keep_prob_values / (1 - keep_prob_values))
119
+ # Add jitter to break symmetry (if specified)
120
+ if self.log_alpha_jitter_sd > 0:
121
+ jitter = (
122
+ torch.randn(n_features, device=self.device)
123
+ * self.log_alpha_jitter_sd
124
+ )
125
+ self.log_alpha = nn.Parameter(mu + jitter)
126
+ else:
127
+ self.log_alpha = nn.Parameter(mu)
75
128
 
76
129
  # Cache for sparse tensor conversion
77
130
  self._cached_M_torch: torch.sparse.Tensor | None = None
@@ -312,8 +365,14 @@ class SparseCalibrationWeights(nn.Module):
312
365
  # No grouping - all targets weighted equally
313
366
  group_weights = torch.ones_like(y)
314
367
 
315
- # Initialize weights
316
- nn.init.normal_(self.log_weight, 0, 0.5)
368
+ # Add jitter to weights to break symmetry (if jitter_sd > 0)
369
+ if self.log_weight_jitter_sd > 0:
370
+ with torch.no_grad():
371
+ jitter = (
372
+ torch.randn_like(self.log_weight)
373
+ * self.log_weight_jitter_sd
374
+ )
375
+ self.log_weight.data += jitter
317
376
 
318
377
  # Setup optimizer
319
378
  optimizer = torch.optim.Adam([self.log_weight, self.log_alpha], lr=lr)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: l0-python
3
- Version: 0.3.0
3
+ Version: 0.3.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
@@ -36,7 +36,7 @@ Classifier: License :: OSI Approved :: MIT License
36
36
  Classifier: Programming Language :: Python :: 3
37
37
  Classifier: Programming Language :: Python :: 3.13
38
38
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
39
- Requires-Python: >=3.13
39
+ Requires-Python: >=3.11
40
40
  Description-Content-Type: text/markdown
41
41
  License-File: LICENSE
42
42
  Requires-Dist: torch>=2.0.0
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "l0-python"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "L0 regularization for sparse neural networks and intelligent sampling"
9
9
  readme = "README.md"
10
10
  authors = [
11
11
  {name = "PolicyEngine", email = "hello@policyengine.org"},
12
12
  ]
13
13
  license = {file = "LICENSE"}
14
- requires-python = ">=3.13"
14
+ requires-python = ">=3.11"
15
15
  classifiers = [
16
16
  "Development Status :: 4 - Beta",
17
17
  "Intended Audience :: Science/Research",
@@ -21,7 +21,7 @@ class TestSparseCalibrationWeights:
21
21
  M = sp.random(Q, N, density=0.3, format="csr")
22
22
  y = np.random.randn(Q) + 10
23
23
 
24
- model = SparseCalibrationWeights(n_features=N)
24
+ model = SparseCalibrationWeights(n_features=N, init_weights=1.0)
25
25
  model.fit(M, y, epochs=100, verbose=False)
26
26
 
27
27
  # Check positivity
@@ -57,7 +57,8 @@ class TestSparseCalibrationWeights:
57
57
  gamma=-0.1,
58
58
  zeta=1.1,
59
59
  init_keep_prob=0.3,
60
- init_weight_scale=0.5,
60
+ init_weights=1.0, # Start all weights at 1.0
61
+ log_weight_jitter_sd=0.5, # Add jitter for symmetry breaking
61
62
  )
62
63
 
63
64
  model.fit(
@@ -171,7 +172,7 @@ class TestSparseCalibrationWeights:
171
172
  def test_get_active_weights(self):
172
173
  """Test active weight extraction."""
173
174
  N = 100
174
- model = SparseCalibrationWeights(n_features=N)
175
+ model = SparseCalibrationWeights(n_features=N, init_weights=1.0)
175
176
 
176
177
  # Simple test data
177
178
  M = sp.eye(N, format="csr")
@@ -202,7 +203,7 @@ class TestSparseCalibrationWeights:
202
203
  M = sp.random(Q, N, density=0.5, format="csr")
203
204
  y = np.random.randn(Q)
204
205
 
205
- model = SparseCalibrationWeights(n_features=N)
206
+ model = SparseCalibrationWeights(n_features=N, init_weights=1.0)
206
207
  model.fit(M, y, epochs=100, verbose=False)
207
208
 
208
209
  # Multiple predictions should be identical
@@ -357,7 +358,7 @@ class TestSparseCalibrationWeights:
357
358
  M = sp.random(Q, N, density=0.3, format="csr")
358
359
  y = np.random.uniform(100, 1000, size=Q)
359
360
 
360
- model = SparseCalibrationWeights(n_features=N)
361
+ model = SparseCalibrationWeights(n_features=N, init_weights=1.0)
361
362
 
362
363
  # Test 1: All targets in one group (should behave like no grouping)
363
364
  target_groups_single = np.zeros(Q, dtype=int)
@@ -421,3 +422,124 @@ class TestSparseCalibrationWeights:
421
422
  assert (
422
423
  np.mean(small_group_errors) < 0.5
423
424
  ), "Small groups should not be ignored"
425
+
426
+ def test_init_weights_options(self):
427
+ """Test different weight initialization options."""
428
+ N = 50
429
+
430
+ # Test 1: Default (None) should give all weights = 1.0
431
+ model_default = SparseCalibrationWeights(n_features=N)
432
+ with torch.no_grad():
433
+ weights = torch.exp(model_default.log_weight)
434
+ assert torch.allclose(weights, torch.ones(N), atol=1e-6)
435
+
436
+ # Test 2: Scalar initialization
437
+ model_scalar = SparseCalibrationWeights(n_features=N, init_weights=2.5)
438
+ with torch.no_grad():
439
+ weights = torch.exp(model_scalar.log_weight)
440
+ assert torch.allclose(weights, torch.full((N,), 2.5), atol=1e-6)
441
+
442
+ # Test 3: Array initialization
443
+ init_array = np.random.uniform(0.5, 2.0, size=N)
444
+ model_array = SparseCalibrationWeights(
445
+ n_features=N, init_weights=init_array
446
+ )
447
+ with torch.no_grad():
448
+ weights = torch.exp(model_array.log_weight).cpu().numpy()
449
+ np.testing.assert_allclose(weights, init_array, rtol=1e-5)
450
+
451
+ # Test 4: Wrong shape should raise error
452
+ with pytest.raises(ValueError, match="must have shape"):
453
+ SparseCalibrationWeights(n_features=N, init_weights=np.ones(N + 1))
454
+
455
+ def test_weight_jitter(self):
456
+ """Test that weight jitter works correctly."""
457
+ N = 100
458
+ Q = 20
459
+
460
+ np.random.seed(42)
461
+ torch.manual_seed(42)
462
+
463
+ M = sp.random(Q, N, density=0.3, format="csr")
464
+ y = np.random.randn(Q) + 10
465
+
466
+ # Model with jitter
467
+ model_with_jitter = SparseCalibrationWeights(
468
+ n_features=N, init_weights=1.0, log_weight_jitter_sd=0.5
469
+ )
470
+
471
+ # Store initial weights
472
+ initial_weights = model_with_jitter.log_weight.data.clone()
473
+
474
+ # Fit should add jitter
475
+ model_with_jitter.fit(M, y, epochs=10, verbose=False)
476
+
477
+ # Weights should have changed due to jitter (and training)
478
+ final_weights = model_with_jitter.log_weight.data
479
+ assert not torch.allclose(initial_weights, final_weights)
480
+
481
+ # Model without jitter
482
+ torch.manual_seed(42) # Reset seed
483
+ model_no_jitter = SparseCalibrationWeights(
484
+ n_features=N,
485
+ init_weights=1.0,
486
+ log_weight_jitter_sd=0.0, # No jitter
487
+ )
488
+
489
+ initial_weights_no_jitter = model_no_jitter.log_weight.data.clone()
490
+ model_no_jitter.fit(M, y, epochs=1, verbose=False) # Just 1 epoch
491
+
492
+ # After 1 epoch, change should be small without jitter
493
+ weights_after_1_epoch = model_no_jitter.log_weight.data
494
+ # The change is due to gradient updates only
495
+ change = (
496
+ (weights_after_1_epoch - initial_weights_no_jitter).abs().max()
497
+ )
498
+ assert change < 1.0, "Without jitter, initial change should be small"
499
+
500
+ def test_init_keep_prob_options(self):
501
+ """Test init_keep_prob as scalar and array."""
502
+ n_features = 20
503
+
504
+ # Test 1: Scalar init_keep_prob (existing behavior)
505
+ model_scalar = SparseCalibrationWeights(
506
+ n_features=n_features,
507
+ init_keep_prob=0.7,
508
+ )
509
+ # All log_alpha values should be similar (around log(0.7/0.3) plus small jitter)
510
+ expected_mu = np.log(0.7 / 0.3)
511
+ with torch.no_grad():
512
+ log_alphas = model_scalar.log_alpha.numpy()
513
+ # Check they're all close to expected value (within jitter range)
514
+ assert np.all(np.abs(log_alphas - expected_mu) < 0.1)
515
+
516
+ # Test 2: Array init_keep_prob
517
+ keep_probs = np.linspace(0.1, 0.9, n_features)
518
+ model_array = SparseCalibrationWeights(
519
+ n_features=n_features,
520
+ init_keep_prob=keep_probs,
521
+ )
522
+ # Each log_alpha should correspond to its keep_prob
523
+ with torch.no_grad():
524
+ log_alphas = model_array.log_alpha.numpy()
525
+ expected_mus = np.log(keep_probs / (1 - keep_probs))
526
+ # Check each is close to its expected value
527
+ assert np.all(np.abs(log_alphas - expected_mus) < 0.1)
528
+
529
+ # Test 3: Wrong shape should raise error
530
+ with pytest.raises(ValueError, match="must have shape"):
531
+ SparseCalibrationWeights(
532
+ n_features=10,
533
+ init_keep_prob=np.ones(5), # Wrong size
534
+ )
535
+
536
+ # Test 4: Edge case probabilities get clamped
537
+ extreme_probs = np.array([0.0, 0.5, 1.0])
538
+ model_extreme = SparseCalibrationWeights(
539
+ n_features=3,
540
+ init_keep_prob=extreme_probs,
541
+ )
542
+ with torch.no_grad():
543
+ log_alphas = model_extreme.log_alpha.numpy()
544
+ # Should not have inf or -inf values
545
+ assert np.all(np.isfinite(log_alphas))
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