l0-python 0.4.3__tar.gz → 0.6.0__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.4.3/l0_python.egg-info → l0_python-0.6.0}/PKG-INFO +1 -1
  2. {l0_python-0.4.3 → l0_python-0.6.0}/l0/__init__.py +1 -1
  3. {l0_python-0.4.3 → l0_python-0.6.0}/l0/calibration.py +96 -14
  4. {l0_python-0.4.3 → l0_python-0.6.0/l0_python.egg-info}/PKG-INFO +1 -1
  5. {l0_python-0.4.3 → l0_python-0.6.0}/pyproject.toml +1 -1
  6. {l0_python-0.4.3 → l0_python-0.6.0}/tests/test_calibration.py +221 -0
  7. {l0_python-0.4.3 → l0_python-0.6.0}/LICENSE +0 -0
  8. {l0_python-0.4.3 → l0_python-0.6.0}/README.md +0 -0
  9. {l0_python-0.4.3 → l0_python-0.6.0}/l0/distributions.py +0 -0
  10. {l0_python-0.4.3 → l0_python-0.6.0}/l0/gates.py +0 -0
  11. {l0_python-0.4.3 → l0_python-0.6.0}/l0/layers.py +0 -0
  12. {l0_python-0.4.3 → l0_python-0.6.0}/l0/penalties.py +0 -0
  13. {l0_python-0.4.3 → l0_python-0.6.0}/l0/sparse.py +0 -0
  14. {l0_python-0.4.3 → l0_python-0.6.0}/l0_python.egg-info/SOURCES.txt +0 -0
  15. {l0_python-0.4.3 → l0_python-0.6.0}/l0_python.egg-info/dependency_links.txt +0 -0
  16. {l0_python-0.4.3 → l0_python-0.6.0}/l0_python.egg-info/requires.txt +0 -0
  17. {l0_python-0.4.3 → l0_python-0.6.0}/l0_python.egg-info/top_level.txt +0 -0
  18. {l0_python-0.4.3 → l0_python-0.6.0}/setup.cfg +0 -0
  19. {l0_python-0.4.3 → l0_python-0.6.0}/tests/test_distributions.py +0 -0
  20. {l0_python-0.4.3 → l0_python-0.6.0}/tests/test_gates.py +0 -0
  21. {l0_python-0.4.3 → l0_python-0.6.0}/tests/test_layers.py +0 -0
  22. {l0_python-0.4.3 → l0_python-0.6.0}/tests/test_penalties.py +0 -0
  23. {l0_python-0.4.3 → l0_python-0.6.0}/tests/test_sparse.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: l0-python
3
- Version: 0.4.3
3
+ Version: 0.6.0
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
@@ -5,7 +5,7 @@ A PyTorch implementation of L0 regularization for neural network sparsification
5
5
  and intelligent sampling, based on Louizos, Welling, & Kingma (2017).
6
6
  """
7
7
 
8
- __version__ = "0.1.0"
8
+ __version__ = "0.5.0"
9
9
 
10
10
  from .distributions import HardConcrete
11
11
  from .gates import FeatureGate, HybridGate, L0Gate, SampleGate
@@ -60,6 +60,7 @@ class SparseCalibrationWeights(nn.Module):
60
60
  log_weight_jitter_sd: float = 0.0,
61
61
  log_alpha_jitter_sd: float = 0.01,
62
62
  device: str | torch.device = "cpu",
63
+ use_gates: bool = True,
63
64
  ):
64
65
  super().__init__()
65
66
  self.n_features = n_features
@@ -69,6 +70,7 @@ class SparseCalibrationWeights(nn.Module):
69
70
  self.log_weight_jitter_sd = log_weight_jitter_sd
70
71
  self.log_alpha_jitter_sd = log_alpha_jitter_sd
71
72
  self.device = torch.device(device)
73
+ self.use_gates = use_gates
72
74
 
73
75
  # Initialize weights (on original scale)
74
76
  if init_weights is None:
@@ -193,6 +195,10 @@ class SparseCalibrationWeights(nn.Module):
193
195
  torch.Tensor
194
196
  Positive calibration weights with L0 sparsity applied
195
197
  """
198
+ if not self.use_gates:
199
+ # No gates - just return positive weights
200
+ return torch.exp(self.log_weight)
201
+
196
202
  # Sample or get deterministic gates
197
203
  if deterministic:
198
204
  gates = self.get_deterministic_gates()
@@ -304,6 +310,8 @@ class SparseCalibrationWeights(nn.Module):
304
310
  verbose: bool = False,
305
311
  verbose_freq: int = 100,
306
312
  target_groups: np.ndarray | None = None,
313
+ normalize_groups: bool = True,
314
+ group_multipliers: dict[int, float] | None = None,
307
315
  ) -> "SparseCalibrationWeights":
308
316
  """
309
317
  Fit calibration weights using gradient descent.
@@ -332,6 +340,12 @@ class SparseCalibrationWeights(nn.Module):
332
340
  Array of group IDs for each target. Targets in the same group
333
341
  will be averaged together so each group contributes equally to loss.
334
342
  If None, all targets are treated independently.
343
+ normalize_groups : bool
344
+ Whether to normalize within groups so each group contributes
345
+ equally to the loss. Default True (backward-compatible).
346
+ group_multipliers : dict[int, float], optional
347
+ Per-group loss scaling factors. Applied after normalization
348
+ (if enabled). Requires target_groups to be set.
335
349
 
336
350
  Returns
337
351
  -------
@@ -344,27 +358,53 @@ class SparseCalibrationWeights(nn.Module):
344
358
  # Convert M to torch sparse (will be cached)
345
359
  M_torch = self._convert_sparse_to_torch(M)
346
360
 
361
+ # Validate group_multipliers
362
+ if group_multipliers is not None and target_groups is None:
363
+ raise ValueError(
364
+ "group_multipliers requires target_groups to be set"
365
+ )
366
+
347
367
  # Compute group weights for loss averaging
348
368
  if target_groups is not None:
349
- # Convert to tensor
350
369
  target_groups = torch.tensor(
351
370
  target_groups, dtype=torch.long, device=self.device
352
371
  )
353
-
354
- # Calculate group weights: 1 / group_size for each target
355
372
  unique_groups = torch.unique(target_groups)
356
- group_weights = torch.zeros_like(y)
357
-
358
- for group_id in unique_groups:
359
- group_mask = target_groups == group_id
360
- group_size = group_mask.sum().item()
361
- # Each target in the group gets weight 1/group_size
362
- # so the group's total contribution is 1
363
- group_weights[group_mask] = 1.0 / group_size
373
+
374
+ if normalize_groups:
375
+ group_weights = torch.zeros_like(y)
376
+ for group_id in unique_groups:
377
+ group_mask = target_groups == group_id
378
+ group_size = group_mask.sum().item()
379
+ group_weights[group_mask] = 1.0 / group_size
380
+ else:
381
+ group_weights = torch.ones_like(y)
382
+
383
+ if group_multipliers is not None:
384
+ for gid, mult in group_multipliers.items():
385
+ group_mask = target_groups == gid
386
+ if not group_mask.any():
387
+ raise ValueError(
388
+ f"group_multipliers key {gid} not found "
389
+ f"in target_groups"
390
+ )
391
+ group_weights[group_mask] *= mult
364
392
  else:
365
- # No grouping - all targets weighted equally
366
393
  group_weights = torch.ones_like(y)
367
394
 
395
+ if verbose:
396
+ if target_groups is not None:
397
+ n_groups = len(torch.unique(target_groups))
398
+ parts = [f"{n_groups} groups"]
399
+ parts.append(
400
+ f"normalize={'on' if normalize_groups else 'off'}"
401
+ )
402
+ if group_multipliers is not None:
403
+ parts.append(f"multipliers={group_multipliers}")
404
+ print(f"Groups: {', '.join(parts)}")
405
+ else:
406
+ print("Groups: none (uniform weights)")
407
+
368
408
  # Add jitter to weights to break symmetry (if jitter_sd > 0)
369
409
  if self.log_weight_jitter_sd > 0:
370
410
  with torch.no_grad():
@@ -453,6 +493,46 @@ class SparseCalibrationWeights(nn.Module):
453
493
  1 - active_info["count"] / self.n_features
454
494
  )
455
495
 
496
+ # Weight distribution diagnostic
497
+ if active_weights.numel() > 0:
498
+ w_tiny = (active_weights < 0.01).sum().item()
499
+ w_small = (
500
+ ((active_weights >= 0.01) & (active_weights < 0.1))
501
+ .sum()
502
+ .item()
503
+ )
504
+ w_med = (
505
+ ((active_weights >= 0.1) & (active_weights < 1.0))
506
+ .sum()
507
+ .item()
508
+ )
509
+ w_normal = (
510
+ ((active_weights >= 1.0) & (active_weights < 10.0))
511
+ .sum()
512
+ .item()
513
+ )
514
+ w_large = (
515
+ (
516
+ (active_weights >= 10.0)
517
+ & (active_weights < 1000.0)
518
+ )
519
+ .sum()
520
+ .item()
521
+ )
522
+ w_huge = (active_weights >= 1000.0).sum().item()
523
+
524
+ total_active = active_weights.numel()
525
+ 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}%]"
532
+ )
533
+ else:
534
+ weight_dist = "[no active weights]"
535
+
456
536
  # Calculate components of the actual loss being minimized
457
537
  actual_data_loss = data_loss.item()
458
538
  actual_l0_loss = l0_loss.item()
@@ -464,7 +544,8 @@ class SparseCalibrationWeights(nn.Module):
464
544
  f"mean_group_mare={mean_group_mare:.4%}, "
465
545
  f"max_error={max_rel_err:.1%}, "
466
546
  f"total_loss={actual_total_loss:.3f}, "
467
- f"active={active_info['count']:4d}/{self.n_features} ({sparsity_pct:.1f}% sparse)"
547
+ f"active={active_info['count']:4d}/{self.n_features} ({sparsity_pct:.1f}% sparse)\n"
548
+ f" Weight dist: {weight_dist}"
468
549
  )
469
550
  else:
470
551
  print(
@@ -472,7 +553,8 @@ class SparseCalibrationWeights(nn.Module):
472
553
  f"mean_error={mean_rel_err:.4%}, "
473
554
  f"max_error={max_rel_err:.1%}, "
474
555
  f"total_loss={actual_total_loss:.3f}, "
475
- f"active={active_info['count']:4d}/{self.n_features} ({sparsity_pct:.1f}% sparse)"
556
+ f"active={active_info['count']:4d}/{self.n_features} ({sparsity_pct:.1f}% sparse)\n"
557
+ f" Weight dist: {weight_dist}"
476
558
  )
477
559
 
478
560
  return self
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: l0-python
3
- Version: 0.4.3
3
+ Version: 0.6.0
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "l0-python"
7
- version = "0.4.3"
7
+ version = "0.6.0"
8
8
  description = "L0 regularization for sparse neural networks and intelligent sampling"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -769,3 +769,224 @@ class TestSparseCalibrationWeights:
769
769
  log_alphas = model_extreme.log_alpha.numpy()
770
770
  # Should not have inf or -inf values
771
771
  assert np.all(np.isfinite(log_alphas))
772
+
773
+ def test_group_multipliers_without_normalization(self):
774
+ """Multipliers work with normalize_groups=False."""
775
+ N = 100
776
+ Q = 39 # 3 singletons + 18 + 18
777
+
778
+ np.random.seed(42)
779
+ torch.manual_seed(42)
780
+
781
+ M = sp.random(Q, N, density=0.3, format="csr")
782
+ y_singletons = np.array([1e9, 5e8, 2e9])
783
+ y_group1 = np.random.uniform(1e3, 1e6, size=18)
784
+ y_group2 = np.random.uniform(1e3, 1e6, size=18)
785
+ y = np.concatenate([y_singletons, y_group1, y_group2])
786
+
787
+ target_groups = np.array([0, 1, 2] + [3] * 18 + [4] * 18)
788
+
789
+ # Without multipliers (baseline)
790
+ model_base = SparseCalibrationWeights(n_features=N)
791
+ model_base.fit(
792
+ M,
793
+ y,
794
+ lambda_l0=0.0001,
795
+ lr=0.1,
796
+ epochs=500,
797
+ loss_type="relative",
798
+ verbose=False,
799
+ target_groups=target_groups,
800
+ normalize_groups=False,
801
+ )
802
+
803
+ # With 10x multiplier on singletons
804
+ model_mult = SparseCalibrationWeights(n_features=N)
805
+ model_mult.fit(
806
+ M,
807
+ y,
808
+ lambda_l0=0.0001,
809
+ lr=0.1,
810
+ epochs=500,
811
+ loss_type="relative",
812
+ verbose=False,
813
+ target_groups=target_groups,
814
+ normalize_groups=False,
815
+ group_multipliers={0: 10.0, 1: 10.0, 2: 10.0},
816
+ )
817
+
818
+ with torch.no_grad():
819
+ y_pred_base = model_base.predict(M).cpu().numpy()
820
+ y_pred_mult = model_mult.predict(M).cpu().numpy()
821
+
822
+ # Singleton relative errors
823
+ sing_err_base = np.abs(
824
+ (y[:3] - y_pred_base[:3]) / (y[:3] + 1)
825
+ ).mean()
826
+ sing_err_mult = np.abs(
827
+ (y[:3] - y_pred_mult[:3]) / (y[:3] + 1)
828
+ ).mean()
829
+
830
+ assert sing_err_mult < sing_err_base * 1.1, (
831
+ f"10x multiplier should improve singleton fit: "
832
+ f"{sing_err_mult:.4f} vs {sing_err_base:.4f}"
833
+ )
834
+
835
+ def test_group_multipliers_with_normalization(self):
836
+ """Multipliers compose with normalization."""
837
+ N = 100
838
+ Q = 39
839
+
840
+ np.random.seed(42)
841
+ torch.manual_seed(42)
842
+
843
+ M = sp.random(Q, N, density=0.3, format="csr")
844
+ y_singletons = np.array([1e9, 5e8, 2e9])
845
+ y_group1 = np.random.uniform(1e3, 1e6, size=18)
846
+ y_group2 = np.random.uniform(1e3, 1e6, size=18)
847
+ y = np.concatenate([y_singletons, y_group1, y_group2])
848
+
849
+ target_groups = np.array([0, 1, 2] + [3] * 18 + [4] * 18)
850
+
851
+ # With normalization only (baseline)
852
+ model_norm = SparseCalibrationWeights(n_features=N)
853
+ model_norm.fit(
854
+ M,
855
+ y,
856
+ lambda_l0=0.0001,
857
+ lr=0.1,
858
+ epochs=500,
859
+ loss_type="relative",
860
+ verbose=False,
861
+ target_groups=target_groups,
862
+ normalize_groups=True,
863
+ )
864
+
865
+ # With normalization + 5x multiplier on group 3
866
+ model_norm_mult = SparseCalibrationWeights(n_features=N)
867
+ model_norm_mult.fit(
868
+ M,
869
+ y,
870
+ lambda_l0=0.0001,
871
+ lr=0.1,
872
+ epochs=500,
873
+ loss_type="relative",
874
+ verbose=False,
875
+ target_groups=target_groups,
876
+ normalize_groups=True,
877
+ group_multipliers={3: 5.0},
878
+ )
879
+
880
+ with torch.no_grad():
881
+ y_pred_norm = model_norm.predict(M).cpu().numpy()
882
+ y_pred_mult = model_norm_mult.predict(M).cpu().numpy()
883
+
884
+ # Group 3 error should improve with multiplier
885
+ g3_err_norm = np.abs(
886
+ (y[3:21] - y_pred_norm[3:21]) / (y[3:21] + 1)
887
+ ).mean()
888
+ g3_err_mult = np.abs(
889
+ (y[3:21] - y_pred_mult[3:21]) / (y[3:21] + 1)
890
+ ).mean()
891
+
892
+ assert g3_err_mult < g3_err_norm * 1.1, (
893
+ f"5x multiplier should improve group 3 fit: "
894
+ f"{g3_err_mult:.4f} vs {g3_err_norm:.4f}"
895
+ )
896
+
897
+ def test_group_multipliers_requires_target_groups(self):
898
+ """ValueError when group_multipliers set but target_groups is None."""
899
+ N = 50
900
+ Q = 10
901
+
902
+ M = sp.random(Q, N, density=0.3, format="csr")
903
+ y = np.random.uniform(100, 1000, size=Q)
904
+
905
+ model = SparseCalibrationWeights(n_features=N)
906
+ with pytest.raises(
907
+ ValueError,
908
+ match="group_multipliers requires target_groups",
909
+ ):
910
+ model.fit(
911
+ M,
912
+ y,
913
+ epochs=10,
914
+ verbose=False,
915
+ group_multipliers={0: 2.0},
916
+ )
917
+
918
+ def test_group_multipliers_invalid_group_id(self):
919
+ """ValueError when multiplier key doesn't exist in target_groups."""
920
+ N = 50
921
+ Q = 10
922
+
923
+ M = sp.random(Q, N, density=0.3, format="csr")
924
+ y = np.random.uniform(100, 1000, size=Q)
925
+ target_groups = np.zeros(Q, dtype=int) # All group 0
926
+
927
+ model = SparseCalibrationWeights(n_features=N)
928
+ with pytest.raises(
929
+ ValueError,
930
+ match="group_multipliers key 99 not found",
931
+ ):
932
+ model.fit(
933
+ M,
934
+ y,
935
+ epochs=10,
936
+ verbose=False,
937
+ target_groups=target_groups,
938
+ group_multipliers={99: 2.0},
939
+ )
940
+
941
+ def test_normalize_groups_false_uniform_weights(self):
942
+ """normalize_groups=False with target_groups gives uniform weights."""
943
+ N = 50
944
+ Q = 10
945
+
946
+ np.random.seed(42)
947
+ torch.manual_seed(42)
948
+
949
+ M = sp.random(Q, N, density=0.3, format="csr")
950
+ y = np.random.uniform(100, 1000, size=Q)
951
+
952
+ # Without target_groups at all
953
+ model_none = SparseCalibrationWeights(n_features=N)
954
+ model_none.fit(
955
+ M,
956
+ y,
957
+ lambda_l0=0.0001,
958
+ lr=0.1,
959
+ epochs=500,
960
+ loss_type="relative",
961
+ verbose=False,
962
+ target_groups=None,
963
+ )
964
+
965
+ # With target_groups but normalize_groups=False (should be equivalent)
966
+ torch.manual_seed(42)
967
+ target_groups = np.array([0] * 5 + [1] * 5)
968
+ model_no_norm = SparseCalibrationWeights(n_features=N)
969
+ model_no_norm.fit(
970
+ M,
971
+ y,
972
+ lambda_l0=0.0001,
973
+ lr=0.1,
974
+ epochs=500,
975
+ loss_type="relative",
976
+ verbose=False,
977
+ target_groups=target_groups,
978
+ normalize_groups=False,
979
+ )
980
+
981
+ with torch.no_grad():
982
+ y_pred_none = model_none.predict(M).cpu().numpy()
983
+ y_pred_no_norm = model_no_norm.predict(M).cpu().numpy()
984
+
985
+ err_none = np.abs((y - y_pred_none) / (y + 1)).mean()
986
+ err_no_norm = np.abs((y - y_pred_no_norm) / (y + 1)).mean()
987
+
988
+ # Both should achieve similar error levels
989
+ assert abs(err_none - err_no_norm) < 0.1, (
990
+ f"normalize_groups=False should behave like no groups: "
991
+ f"{err_none:.4f} vs {err_no_norm:.4f}"
992
+ )
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