l0-python 0.1.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: l0-python
3
- Version: 0.1.1
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
@@ -46,11 +46,10 @@ Provides-Extra: dev
46
46
  Requires-Dist: pytest>=7.0; extra == "dev"
47
47
  Requires-Dist: pytest-cov>=3.0; extra == "dev"
48
48
  Requires-Dist: black>=22.0; extra == "dev"
49
- Requires-Dist: mypy>=0.900; extra == "dev"
50
- Requires-Dist: ruff>=0.0.280; extra == "dev"
51
- Requires-Dist: pre-commit>=3.0; extra == "dev"
52
49
  Requires-Dist: build>=1.3; extra == "dev"
53
50
  Requires-Dist: scikit-learn>=1.0; extra == "dev"
51
+ Requires-Dist: yaml-changelog>=0.3.0; extra == "dev"
52
+ Requires-Dist: twine>=4.0.0; extra == "dev"
54
53
  Provides-Extra: docs
55
54
  Requires-Dist: mystmd>=1.3; extra == "docs"
56
55
  Requires-Dist: jupyter>=1.0; extra == "docs"
@@ -0,0 +1,495 @@
1
+ """
2
+ L0-regularized calibration weights for survey data.
3
+
4
+ Implements sparse calibration weights that are constrained to be non-negative,
5
+ suitable for survey weighting and importance sampling applications.
6
+ """
7
+
8
+ # ruff: noqa: N803 N806
9
+
10
+ import torch
11
+ import torch.nn as nn
12
+ import numpy as np
13
+ from scipy import sparse as sp
14
+
15
+
16
+ class SparseCalibrationWeights(nn.Module):
17
+ """
18
+ L0-regularized calibration weights with positivity constraint.
19
+
20
+ Designed for survey calibration where weights must be non-negative
21
+ and sparsity is desired to select a subset of households/units.
22
+
23
+ Parameters
24
+ ----------
25
+ n_features : int
26
+ Number of calibration weights (e.g., number of households)
27
+ beta : float
28
+ Temperature parameter for Hard Concrete distribution
29
+ gamma : float
30
+ Lower bound of stretched concrete distribution
31
+ zeta : float
32
+ Upper bound of stretched concrete distribution
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.).
48
+ device : str or torch.device
49
+ Device to run computations on ('cpu' or 'cuda')
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ n_features: int,
55
+ beta: float = 2 / 3,
56
+ gamma: float = -0.1,
57
+ zeta: float = 1.1,
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,
62
+ device: str | torch.device = "cpu",
63
+ ):
64
+ super().__init__()
65
+ self.n_features = n_features
66
+ self.beta = beta
67
+ self.gamma = gamma
68
+ self.zeta = zeta
69
+ self.log_weight_jitter_sd = log_weight_jitter_sd
70
+ self.log_alpha_jitter_sd = log_alpha_jitter_sd
71
+ self.device = torch.device(device)
72
+
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
81
+ )
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))
96
+
97
+ # L0 gate parameters
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
103
+ )
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)
128
+
129
+ # Cache for sparse tensor conversion
130
+ self._cached_M_torch: torch.sparse.Tensor | None = None
131
+ self._cached_M_shape: tuple[int, int] | None = None
132
+
133
+ def _convert_sparse_to_torch(
134
+ self, M_sparse: sp.spmatrix
135
+ ) -> torch.sparse.Tensor:
136
+ """
137
+ Convert scipy sparse matrix to torch sparse tensor.
138
+
139
+ Caches the result if the shape matches to avoid redundant conversions.
140
+ """
141
+ if (
142
+ self._cached_M_torch is not None
143
+ and self._cached_M_shape == M_sparse.shape
144
+ and M_sparse.nnz == self._cached_M_torch._nnz()
145
+ ):
146
+ return self._cached_M_torch
147
+
148
+ M_coo = M_sparse.tocoo()
149
+ indices = torch.LongTensor(np.vstack([M_coo.row, M_coo.col])).to(
150
+ self.device
151
+ )
152
+ values = torch.FloatTensor(M_coo.data).to(self.device)
153
+ M_torch = torch.sparse_coo_tensor(
154
+ indices,
155
+ values,
156
+ M_sparse.shape,
157
+ dtype=torch.float32,
158
+ device=self.device,
159
+ )
160
+
161
+ # Cache for future use
162
+ self._cached_M_torch = M_torch
163
+ self._cached_M_shape = M_sparse.shape
164
+
165
+ return M_torch
166
+
167
+ def _sample_gates(self) -> torch.Tensor:
168
+ """Sample gates using Hard Concrete distribution."""
169
+ eps = 1e-6
170
+ u = torch.rand_like(self.log_alpha).clamp(eps, 1 - eps)
171
+ s = (torch.log(u) - torch.log(1 - u) + self.log_alpha) / self.beta
172
+ s = torch.sigmoid(s)
173
+ s_bar = s * (self.zeta - self.gamma) + self.gamma
174
+ return s_bar.clamp(0, 1)
175
+
176
+ def get_deterministic_gates(self) -> torch.Tensor:
177
+ """Get deterministic gate values (for inference)."""
178
+ s = torch.sigmoid(self.log_alpha / self.beta)
179
+ s_bar = s * (self.zeta - self.gamma) + self.gamma
180
+ return s_bar.clamp(0, 1)
181
+
182
+ def get_weights(self, deterministic: bool = False) -> torch.Tensor:
183
+ """
184
+ Get effective calibration weights.
185
+
186
+ Parameters
187
+ ----------
188
+ deterministic : bool
189
+ Whether to use deterministic gates (for inference)
190
+
191
+ Returns
192
+ -------
193
+ torch.Tensor
194
+ Positive calibration weights with L0 sparsity applied
195
+ """
196
+ # Sample or get deterministic gates
197
+ if deterministic:
198
+ gates = self.get_deterministic_gates()
199
+ else:
200
+ gates = self._sample_gates()
201
+
202
+ # Apply exp transformation and gates together
203
+ # This ensures gradient flow matches successful implementations
204
+ return torch.exp(self.log_weight) * gates
205
+
206
+ def forward(
207
+ self,
208
+ M: sp.spmatrix,
209
+ deterministic: bool = False,
210
+ ) -> torch.Tensor:
211
+ """
212
+ Apply calibration weights to metric matrix.
213
+
214
+ Parameters
215
+ ----------
216
+ M : scipy.sparse matrix
217
+ Metric matrix of shape (n_targets, n_features)
218
+ deterministic : bool
219
+ Whether to use deterministic gates (for inference)
220
+
221
+ Returns
222
+ -------
223
+ torch.Tensor
224
+ Weighted sums of shape (n_targets,)
225
+ """
226
+ # Convert sparse matrix to torch
227
+ M_torch = self._convert_sparse_to_torch(M)
228
+
229
+ # Get effective weights
230
+ weights = self.get_weights(deterministic=deterministic)
231
+
232
+ # Sparse matrix multiplication
233
+ y = torch.sparse.mm(M_torch, weights.unsqueeze(1)).squeeze(1)
234
+
235
+ return y
236
+
237
+ def get_l0_penalty(self) -> torch.Tensor:
238
+ """
239
+ Compute L0 complexity penalty.
240
+
241
+ Returns expected number of active weights.
242
+ """
243
+ c = -self.beta * torch.log(
244
+ torch.tensor(-self.gamma / self.zeta, device=self.device)
245
+ )
246
+ pi = torch.sigmoid(self.log_alpha + c)
247
+ return pi.sum()
248
+
249
+ def get_l2_penalty(self) -> torch.Tensor:
250
+ """
251
+ Compute L2 penalty on positive weights.
252
+
253
+ Helps prevent weight explosion when gates are partially open.
254
+
255
+ Returns
256
+ -------
257
+ torch.Tensor
258
+ L2 norm of positive weights
259
+ """
260
+ positive_weights = torch.exp(self.log_weight)
261
+ return (positive_weights**2).sum()
262
+
263
+ def get_sparsity(self) -> float:
264
+ """
265
+ Get current sparsity level.
266
+
267
+ Returns
268
+ -------
269
+ float
270
+ Fraction of weights that are effectively zero
271
+ """
272
+ with torch.no_grad():
273
+ gates = self.get_deterministic_gates()
274
+ return (gates == 0).float().mean().item()
275
+
276
+ def get_active_weights(self) -> dict:
277
+ """
278
+ Get indices and values of active (non-zero) weights.
279
+
280
+ Returns
281
+ -------
282
+ dict
283
+ Dictionary with 'indices' and 'values' of active weights
284
+ """
285
+ with torch.no_grad():
286
+ weights = self.get_weights(deterministic=True)
287
+ active_mask = weights > 0
288
+
289
+ return {
290
+ "indices": torch.where(active_mask)[0],
291
+ "values": weights[active_mask],
292
+ "count": active_mask.sum().item(),
293
+ }
294
+
295
+ def fit(
296
+ self,
297
+ M: sp.spmatrix,
298
+ y: np.ndarray,
299
+ lambda_l0: float = 0.01,
300
+ lambda_l2: float = 0.0,
301
+ lr: float = 0.01,
302
+ epochs: int = 1000,
303
+ loss_type: str = "mse",
304
+ verbose: bool = False,
305
+ verbose_freq: int = 100,
306
+ target_groups: np.ndarray | None = None,
307
+ ) -> "SparseCalibrationWeights":
308
+ """
309
+ Fit calibration weights using gradient descent.
310
+
311
+ Parameters
312
+ ----------
313
+ M : scipy.sparse matrix
314
+ Metric matrix of shape (n_targets, n_features)
315
+ y : numpy.ndarray
316
+ Target values to match
317
+ lambda_l0 : float
318
+ L0 regularization strength
319
+ lambda_l2 : float
320
+ L2 regularization strength on positive weights
321
+ lr : float
322
+ Learning rate
323
+ epochs : int
324
+ Number of training epochs
325
+ loss_type : str
326
+ Type of loss function: 'mse' or 'relative'
327
+ verbose : bool
328
+ Whether to print progress
329
+ verbose_freq : int
330
+ How often to print progress
331
+ target_groups : numpy.ndarray, optional
332
+ Array of group IDs for each target. Targets in the same group
333
+ will be averaged together so each group contributes equally to loss.
334
+ If None, all targets are treated independently.
335
+
336
+ Returns
337
+ -------
338
+ self
339
+ Fitted model
340
+ """
341
+ # Convert y to tensor
342
+ y = torch.tensor(y, dtype=torch.float32, device=self.device)
343
+
344
+ # Convert M to torch sparse (will be cached)
345
+ M_torch = self._convert_sparse_to_torch(M)
346
+
347
+ # Compute group weights for loss averaging
348
+ if target_groups is not None:
349
+ # Convert to tensor
350
+ target_groups = torch.tensor(
351
+ target_groups, dtype=torch.long, device=self.device
352
+ )
353
+
354
+ # Calculate group weights: 1 / group_size for each target
355
+ 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
364
+ else:
365
+ # No grouping - all targets weighted equally
366
+ group_weights = torch.ones_like(y)
367
+
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
376
+
377
+ # Setup optimizer
378
+ optimizer = torch.optim.Adam([self.log_weight, self.log_alpha], lr=lr)
379
+
380
+ # Training loop
381
+ for epoch in range(epochs):
382
+ # Forward pass
383
+ y_pred = self.forward(M, deterministic=False)
384
+
385
+ # Compute loss with group weighting
386
+ if loss_type == "relative":
387
+ # Relative error: (y - y_pred)^2 / (y + 1)^2
388
+ # Adding 1 to avoid division by zero
389
+ relative_errors = (y - y_pred) / (y + 1)
390
+ # Apply group weights and then average
391
+ weighted_squared_errors = (
392
+ relative_errors.pow(2) * group_weights
393
+ )
394
+ data_loss = (
395
+ weighted_squared_errors.sum()
396
+ ) # Sum because weights already normalize
397
+ else:
398
+ # Standard MSE with group weighting
399
+ squared_errors = (y - y_pred).pow(2)
400
+ weighted_squared_errors = squared_errors * group_weights
401
+ data_loss = (
402
+ weighted_squared_errors.sum()
403
+ ) # Sum because weights already normalize
404
+
405
+ l0_loss = self.get_l0_penalty()
406
+ loss = data_loss + lambda_l0 * l0_loss
407
+
408
+ # Add L2 penalty if specified
409
+ if lambda_l2 > 0:
410
+ l2_loss = self.get_l2_penalty()
411
+ loss = loss + lambda_l2 * l2_loss
412
+
413
+ # Backward pass
414
+ optimizer.zero_grad()
415
+ loss.backward()
416
+ optimizer.step()
417
+
418
+ # Print progress
419
+ if verbose and (epoch + 1) % verbose_freq == 0:
420
+ with torch.no_grad():
421
+ active_info = self.get_active_weights()
422
+ weights = self.get_weights(deterministic=True)
423
+ active_weights = weights[weights > 0]
424
+
425
+ # Compute relative errors for meaningful output
426
+ y_det = self.forward(M, deterministic=True)
427
+ if loss_type == "relative":
428
+ rel_errors = torch.abs((y - y_det) / (y + 1))
429
+ else:
430
+ # For MSE, show relative errors anyway for interpretability
431
+ rel_errors = torch.abs((y - y_det) / (y + 1))
432
+
433
+ # For reporting, we can show both overall and group-averaged errors
434
+ mean_rel_err = rel_errors.mean().item()
435
+ max_rel_err = rel_errors.max().item()
436
+
437
+ # Compute mean group loss if groups are used
438
+ if target_groups is not None:
439
+ # Calculate mean loss per group
440
+ group_losses = []
441
+ for group_id in torch.unique(target_groups):
442
+ group_mask = target_groups == group_id
443
+ group_mean_err = (
444
+ rel_errors[group_mask].mean().item()
445
+ )
446
+ group_losses.append(group_mean_err)
447
+ mean_group_loss = np.mean(group_losses)
448
+ else:
449
+ mean_group_loss = mean_rel_err
450
+
451
+ # Calculate sparsity percentage
452
+ sparsity_pct = 100 * (
453
+ 1 - active_info["count"] / self.n_features
454
+ )
455
+
456
+ # Calculate components of the actual loss being minimized
457
+ actual_data_loss = data_loss.item()
458
+ actual_l0_loss = l0_loss.item()
459
+ actual_total_loss = loss.item()
460
+
461
+ if target_groups is not None:
462
+ print(
463
+ f"Epoch {epoch+1:4d}: "
464
+ f"mean_group_loss={mean_group_loss:.1%}, "
465
+ f"max_error={max_rel_err:.1%}, "
466
+ f"total_loss={actual_total_loss:.3f}, "
467
+ f"active={active_info['count']:4d}/{self.n_features} ({sparsity_pct:.1f}% sparse)"
468
+ )
469
+ else:
470
+ print(
471
+ f"Epoch {epoch+1:4d}: "
472
+ f"mean_error={mean_rel_err:.1%}, "
473
+ f"max_error={max_rel_err:.1%}, "
474
+ f"total_loss={actual_total_loss:.3f}, "
475
+ f"active={active_info['count']:4d}/{self.n_features} ({sparsity_pct:.1f}% sparse)"
476
+ )
477
+
478
+ return self
479
+
480
+ def predict(self, M: sp.spmatrix) -> torch.Tensor:
481
+ """
482
+ Apply calibration weights to new data.
483
+
484
+ Parameters
485
+ ----------
486
+ M : scipy.sparse matrix
487
+ Metric matrix of shape (n_targets, n_features)
488
+
489
+ Returns
490
+ -------
491
+ torch.Tensor
492
+ Calibrated predictions
493
+ """
494
+ with torch.no_grad():
495
+ return self.forward(M, deterministic=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: l0-python
3
- Version: 0.1.1
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
@@ -46,11 +46,10 @@ Provides-Extra: dev
46
46
  Requires-Dist: pytest>=7.0; extra == "dev"
47
47
  Requires-Dist: pytest-cov>=3.0; extra == "dev"
48
48
  Requires-Dist: black>=22.0; extra == "dev"
49
- Requires-Dist: mypy>=0.900; extra == "dev"
50
- Requires-Dist: ruff>=0.0.280; extra == "dev"
51
- Requires-Dist: pre-commit>=3.0; extra == "dev"
52
49
  Requires-Dist: build>=1.3; extra == "dev"
53
50
  Requires-Dist: scikit-learn>=1.0; extra == "dev"
51
+ Requires-Dist: yaml-changelog>=0.3.0; extra == "dev"
52
+ Requires-Dist: twine>=4.0.0; extra == "dev"
54
53
  Provides-Extra: docs
55
54
  Requires-Dist: mystmd>=1.3; extra == "docs"
56
55
  Requires-Dist: jupyter>=1.0; extra == "docs"
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  l0/__init__.py
5
+ l0/calibration.py
5
6
  l0/distributions.py
6
7
  l0/gates.py
7
8
  l0/layers.py
@@ -12,6 +13,7 @@ l0_python.egg-info/SOURCES.txt
12
13
  l0_python.egg-info/dependency_links.txt
13
14
  l0_python.egg-info/requires.txt
14
15
  l0_python.egg-info/top_level.txt
16
+ tests/test_calibration.py
15
17
  tests/test_distributions.py
16
18
  tests/test_gates.py
17
19
  tests/test_layers.py
@@ -6,11 +6,10 @@ scipy>=1.7.0
6
6
  pytest>=7.0
7
7
  pytest-cov>=3.0
8
8
  black>=22.0
9
- mypy>=0.900
10
- ruff>=0.0.280
11
- pre-commit>=3.0
12
9
  build>=1.3
13
10
  scikit-learn>=1.0
11
+ yaml-changelog>=0.3.0
12
+ twine>=4.0.0
14
13
 
15
14
  [docs]
16
15
  mystmd>=1.3
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "l0-python"
7
- version = "0.1.1"
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",
@@ -32,11 +32,10 @@ dev = [
32
32
  "pytest>=7.0",
33
33
  "pytest-cov>=3.0",
34
34
  "black>=22.0",
35
- "mypy>=0.900",
36
- "ruff>=0.0.280",
37
- "pre-commit>=3.0",
38
35
  "build>=1.3",
39
36
  "scikit-learn>=1.0",
37
+ "yaml-changelog>=0.3.0",
38
+ "twine>=4.0.0",
40
39
  ]
41
40
  docs = [
42
41
  "mystmd>=1.3",
@@ -62,22 +61,6 @@ Issues = "https://github.com/PolicyEngine/L0/issues"
62
61
  line-length = 79
63
62
  target-version = ['py313']
64
63
 
65
- [tool.ruff]
66
- line-length = 79
67
- target-version = "py313"
68
- extend-exclude = ["docs/examples/*.ipynb"]
69
-
70
- [tool.ruff.lint]
71
- select = ["E", "F", "W", "I", "N", "UP"]
72
- ignore = ["E501", "N812", "UP038"] # E501: Line length handled by black, N812: Allow F as alias for functional, UP038: Use tuple for isinstance
73
-
74
- [tool.mypy]
75
- python_version = "3.13"
76
- warn_return_any = true
77
- warn_unused_configs = true
78
- disallow_untyped_defs = true
79
- ignore_missing_imports = true
80
-
81
64
  [tool.pytest.ini_options]
82
65
  testpaths = ["tests"]
83
66
  python_files = ["test_*.py"]
@@ -0,0 +1,545 @@
1
+ """
2
+ Tests for SparseCalibrationWeights with positive weight constraints.
3
+ """
4
+
5
+ import numpy as np
6
+ import torch
7
+ import pytest
8
+ from scipy import sparse as sp
9
+ from l0.calibration import SparseCalibrationWeights
10
+
11
+
12
+ class TestSparseCalibrationWeights:
13
+ """Test suite for calibration weights with L0 sparsity."""
14
+
15
+ def test_positive_weights(self):
16
+ """Verify all weights remain non-negative."""
17
+ N = 100
18
+ Q = 20
19
+
20
+ # Create test data
21
+ M = sp.random(Q, N, density=0.3, format="csr")
22
+ y = np.random.randn(Q) + 10
23
+
24
+ model = SparseCalibrationWeights(n_features=N, init_weights=1.0)
25
+ model.fit(M, y, epochs=100, verbose=False)
26
+
27
+ # Check positivity
28
+ with torch.no_grad():
29
+ weights = model.get_weights(deterministic=True)
30
+ assert torch.all(weights >= 0), "Weights must be non-negative"
31
+
32
+ def test_sparse_ground_truth_relative_loss(self):
33
+ """Test recovery of sparse ground truth using relative loss."""
34
+ Q = 200 # targets
35
+ N = 2000 # features
36
+ N_active = 1000 # 50% sparsity
37
+
38
+ np.random.seed(42)
39
+ torch.manual_seed(42)
40
+
41
+ # Generate data with sparse ground truth
42
+ M_dense = np.random.lognormal(mean=1.5, sigma=0.25, size=(Q, N))
43
+ M = sp.csr_matrix(M_dense)
44
+
45
+ w_true = np.zeros(N)
46
+ active_indices = np.random.choice(N, size=N_active, replace=False)
47
+ w_true[active_indices] = np.random.lognormal(
48
+ mean=2.0, sigma=1.0, size=N_active
49
+ )
50
+
51
+ y = M @ w_true
52
+
53
+ # Fit with relative loss
54
+ model = SparseCalibrationWeights(
55
+ n_features=N,
56
+ beta=0.66,
57
+ gamma=-0.1,
58
+ zeta=1.1,
59
+ init_keep_prob=0.3,
60
+ init_weights=1.0, # Start all weights at 1.0
61
+ log_weight_jitter_sd=0.5, # Add jitter for symmetry breaking
62
+ )
63
+
64
+ model.fit(
65
+ M=M,
66
+ y=y,
67
+ lambda_l0=0.0005, # Tuned for ~50% sparsity with relative loss
68
+ lambda_l2=1e-6,
69
+ lr=0.2,
70
+ epochs=2000,
71
+ loss_type="relative",
72
+ verbose=False,
73
+ )
74
+
75
+ # Check sparsity is reasonable (between 30% and 70%)
76
+ sparsity = model.get_sparsity()
77
+ assert (
78
+ 0.3 <= sparsity <= 0.7
79
+ ), f"Sparsity {sparsity:.2%} not in expected range"
80
+
81
+ # Check relative loss is low
82
+ with torch.no_grad():
83
+ y_pred = model.predict(M).cpu().numpy()
84
+ rel_loss = np.mean(((y - y_pred) / (y + 1)) ** 2)
85
+ assert rel_loss < 0.1, f"Relative loss {rel_loss:.4f} too high"
86
+
87
+ def test_relative_vs_mse_loss(self):
88
+ """Compare relative loss vs MSE for large-scale data."""
89
+ Q = 100
90
+ N = 500
91
+
92
+ np.random.seed(123)
93
+ torch.manual_seed(123)
94
+
95
+ # Large-scale data
96
+ M = sp.random(Q, N, density=0.5, format="csr")
97
+ M.data = np.abs(M.data) * 1000 # Large values
98
+ y = np.random.uniform(1000, 100000, size=Q)
99
+
100
+ # Train with MSE
101
+ model_mse = SparseCalibrationWeights(n_features=N)
102
+ model_mse.fit(
103
+ M,
104
+ y,
105
+ lambda_l0=1e-10, # Very small for MSE
106
+ lr=0.1,
107
+ epochs=500,
108
+ loss_type="mse",
109
+ verbose=False,
110
+ )
111
+
112
+ # Train with relative loss
113
+ model_rel = SparseCalibrationWeights(n_features=N)
114
+ model_rel.fit(
115
+ M,
116
+ y,
117
+ lambda_l0=0.001, # Can use larger penalty
118
+ lr=0.1,
119
+ epochs=500,
120
+ loss_type="relative",
121
+ verbose=False,
122
+ )
123
+
124
+ # Relative loss should achieve better relative accuracy
125
+ with torch.no_grad():
126
+ y_pred_mse = model_mse.predict(M).cpu().numpy()
127
+ y_pred_rel = model_rel.predict(M).cpu().numpy()
128
+
129
+ rel_err_mse = np.mean(np.abs((y - y_pred_mse) / (y + 1)))
130
+ rel_err_rel = np.mean(np.abs((y - y_pred_rel) / (y + 1)))
131
+
132
+ # Relative loss should do better on relative error
133
+ assert (
134
+ rel_err_rel <= rel_err_mse * 1.5
135
+ ), f"Relative loss should handle scale better: {rel_err_rel:.4f} vs {rel_err_mse:.4f}"
136
+
137
+ def test_sparsity_control(self):
138
+ """Test that L0 penalty controls sparsity level."""
139
+ Q = 50
140
+ N = 200
141
+
142
+ np.random.seed(123)
143
+ torch.manual_seed(123)
144
+
145
+ M = sp.random(Q, N, density=0.3, format="csr")
146
+ y = np.random.randn(Q) + 10
147
+
148
+ sparsities = []
149
+
150
+ # Test different L0 penalties
151
+ for lambda_l0 in [0.0001, 0.001, 0.01]:
152
+ model = SparseCalibrationWeights(n_features=N, init_keep_prob=0.5)
153
+ model.fit(
154
+ M,
155
+ y,
156
+ lambda_l0=lambda_l0,
157
+ lr=0.1,
158
+ epochs=2000,
159
+ loss_type="relative",
160
+ verbose=False,
161
+ )
162
+ sparsities.append(model.get_sparsity())
163
+
164
+ # Higher penalty should give more sparsity
165
+ assert (
166
+ sparsities[0] < sparsities[1]
167
+ ), "Higher L0 penalty should increase sparsity"
168
+ assert (
169
+ sparsities[1] < sparsities[2]
170
+ ), "Higher L0 penalty should increase sparsity"
171
+
172
+ def test_get_active_weights(self):
173
+ """Test active weight extraction."""
174
+ N = 100
175
+ model = SparseCalibrationWeights(n_features=N, init_weights=1.0)
176
+
177
+ # Simple test data
178
+ M = sp.eye(N, format="csr")
179
+ y = np.ones(N)
180
+
181
+ model.fit(M, y, lambda_l0=0.01, epochs=100, verbose=False)
182
+
183
+ active_info = model.get_active_weights()
184
+
185
+ assert "indices" in active_info
186
+ assert "values" in active_info
187
+ assert "count" in active_info
188
+ assert active_info["count"] == len(active_info["indices"])
189
+ assert len(active_info["values"]) == active_info["count"]
190
+
191
+ # All active values should be positive
192
+ if active_info["count"] > 0:
193
+ assert torch.all(active_info["values"] > 0)
194
+
195
+ def test_deterministic_inference(self):
196
+ """Test that inference is deterministic."""
197
+ N = 50
198
+ Q = 10
199
+
200
+ np.random.seed(123)
201
+ torch.manual_seed(123)
202
+
203
+ M = sp.random(Q, N, density=0.5, format="csr")
204
+ y = np.random.randn(Q)
205
+
206
+ model = SparseCalibrationWeights(n_features=N, init_weights=1.0)
207
+ model.fit(M, y, epochs=100, verbose=False)
208
+
209
+ # Multiple predictions should be identical
210
+ with torch.no_grad():
211
+ pred1 = model.predict(M).cpu().numpy()
212
+ pred2 = model.predict(M).cpu().numpy()
213
+
214
+ np.testing.assert_array_equal(
215
+ pred1, pred2, "Predictions should be deterministic"
216
+ )
217
+
218
+ def test_l2_regularization(self):
219
+ """Test that L2 penalty prevents weight explosion."""
220
+ N = 100
221
+ Q = 20
222
+
223
+ np.random.seed(123)
224
+ torch.manual_seed(123)
225
+
226
+ M = sp.random(Q, N, density=0.3, format="csr")
227
+ y = np.random.randn(Q) * 100 # Large scale
228
+
229
+ # Train without L2
230
+ model_no_l2 = SparseCalibrationWeights(n_features=N)
231
+ model_no_l2.fit(
232
+ M, y, lambda_l0=0.0001, lambda_l2=0.0, epochs=200, verbose=False
233
+ )
234
+
235
+ # Train with L2
236
+ model_with_l2 = SparseCalibrationWeights(n_features=N)
237
+ model_with_l2.fit(
238
+ M, y, lambda_l0=0.0001, lambda_l2=0.01, epochs=200, verbose=False
239
+ )
240
+
241
+ with torch.no_grad():
242
+ weights_no_l2 = model_no_l2.get_weights(deterministic=True)
243
+ weights_with_l2 = model_with_l2.get_weights(deterministic=True)
244
+
245
+ # L2 should reduce weight magnitudes
246
+ assert (
247
+ weights_with_l2.max() <= weights_no_l2.max() * 2.0
248
+ ), "L2 should prevent extreme weights"
249
+
250
+ def test_group_wise_averaging(self):
251
+ """Test that group-wise averaging balances loss contributions."""
252
+ N = 100 # features (households)
253
+
254
+ # Create targets with different cardinalities:
255
+ # - 3 singleton targets (like national targets)
256
+ # - 18 targets in one group (like age bins for one state)
257
+ # - 18 targets in another group (like age bins for another state)
258
+ Q = 3 + 18 + 18 # 39 total targets
259
+
260
+ np.random.seed(42)
261
+ torch.manual_seed(42)
262
+
263
+ # Create matrix with varying scales
264
+ M = sp.random(Q, N, density=0.3, format="csr")
265
+
266
+ # Create target values with different scales
267
+ # Singletons: large values (billions scale)
268
+ y_singletons = np.array([1e9, 5e8, 2e9])
269
+ # Groups: smaller values (thousands scale)
270
+ y_group1 = np.random.uniform(1e3, 1e6, size=18)
271
+ y_group2 = np.random.uniform(1e3, 1e6, size=18)
272
+ y = np.concatenate([y_singletons, y_group1, y_group2])
273
+
274
+ # Create target groups
275
+ # Groups 0, 1, 2: singletons (each national target)
276
+ # Group 3: all 18 targets from first age group
277
+ # Group 4: all 18 targets from second age group
278
+ target_groups = np.array(
279
+ [0, 1, 2] # 3 singletons
280
+ + [3] * 18 # Group 3
281
+ + [4] * 18 # Group 4
282
+ )
283
+
284
+ # Train WITHOUT grouping (baseline)
285
+ model_no_groups = SparseCalibrationWeights(n_features=N)
286
+ model_no_groups.fit(
287
+ M,
288
+ y,
289
+ lambda_l0=0.0001,
290
+ lr=0.1,
291
+ epochs=500,
292
+ loss_type="relative",
293
+ verbose=False,
294
+ target_groups=None, # No grouping
295
+ )
296
+
297
+ # Train WITH grouping
298
+ model_with_groups = SparseCalibrationWeights(n_features=N)
299
+ model_with_groups.fit(
300
+ M,
301
+ y,
302
+ lambda_l0=0.0001,
303
+ lr=0.1,
304
+ epochs=500,
305
+ loss_type="relative",
306
+ verbose=False,
307
+ target_groups=target_groups,
308
+ )
309
+
310
+ # Compute errors by group
311
+ with torch.no_grad():
312
+ y_pred_no_groups = model_no_groups.predict(M).cpu().numpy()
313
+ y_pred_with_groups = model_with_groups.predict(M).cpu().numpy()
314
+
315
+ # Relative errors
316
+ rel_err_no_groups = np.abs((y - y_pred_no_groups) / (y + 1))
317
+ rel_err_with_groups = np.abs((y - y_pred_with_groups) / (y + 1))
318
+
319
+ # Average errors by group
320
+ singleton_err_no_groups = rel_err_no_groups[:3].mean()
321
+ group3_err_no_groups = rel_err_no_groups[3:21].mean()
322
+ group4_err_no_groups = rel_err_no_groups[21:].mean()
323
+
324
+ singleton_err_with_groups = rel_err_with_groups[:3].mean()
325
+ group3_err_with_groups = rel_err_with_groups[3:21].mean()
326
+ group4_err_with_groups = rel_err_with_groups[21:].mean()
327
+
328
+ # With grouping, singleton errors should be much better
329
+ # (they're not dominated by the 36 histogram targets)
330
+ assert singleton_err_with_groups < singleton_err_no_groups * 1.5, (
331
+ f"Grouping should improve singleton accuracy: "
332
+ f"{singleton_err_with_groups:.4f} vs {singleton_err_no_groups:.4f}"
333
+ )
334
+
335
+ # All groups should have relatively balanced errors with grouping
336
+ all_group_errors = [
337
+ singleton_err_with_groups,
338
+ group3_err_with_groups,
339
+ group4_err_with_groups,
340
+ ]
341
+ max_err = max(all_group_errors)
342
+ min_err = min(all_group_errors)
343
+
344
+ # Errors should be within an order of magnitude of each other
345
+ assert max_err < min_err * 10, (
346
+ f"Group errors should be balanced: "
347
+ f"min={min_err:.4f}, max={max_err:.4f}"
348
+ )
349
+
350
+ def test_group_wise_averaging_edge_cases(self):
351
+ """Test edge cases for group-wise averaging."""
352
+ N = 50
353
+ Q = 10
354
+
355
+ np.random.seed(42)
356
+ torch.manual_seed(42)
357
+
358
+ M = sp.random(Q, N, density=0.3, format="csr")
359
+ y = np.random.uniform(100, 1000, size=Q)
360
+
361
+ model = SparseCalibrationWeights(n_features=N, init_weights=1.0)
362
+
363
+ # Test 1: All targets in one group (should behave like no grouping)
364
+ target_groups_single = np.zeros(Q, dtype=int)
365
+ model.fit(
366
+ M,
367
+ y,
368
+ lambda_l0=0.00001, # Lower penalty for better convergence
369
+ epochs=2000, # Plenty of epochs
370
+ lr=0.2, # Higher learning rate
371
+ loss_type="relative",
372
+ verbose=False,
373
+ target_groups=target_groups_single,
374
+ )
375
+
376
+ with torch.no_grad():
377
+ y_pred = model.predict(M).cpu().numpy()
378
+ rel_err = np.mean(np.abs((y - y_pred) / (y + 1)))
379
+ assert (
380
+ rel_err < 0.5
381
+ ), f"Single group should still converge, got {rel_err:.4f}"
382
+
383
+ # Test 2: Each target in its own group (like all singletons)
384
+ target_groups_all_singleton = np.arange(Q)
385
+ model_new = SparseCalibrationWeights(n_features=N)
386
+ model_new.fit(
387
+ M,
388
+ y,
389
+ lambda_l0=0.00001,
390
+ epochs=2000,
391
+ lr=0.2,
392
+ loss_type="relative",
393
+ verbose=False,
394
+ target_groups=target_groups_all_singleton,
395
+ )
396
+
397
+ with torch.no_grad():
398
+ y_pred = model_new.predict(M).cpu().numpy()
399
+ rel_err = np.mean(np.abs((y - y_pred) / (y + 1)))
400
+ assert (
401
+ rel_err < 0.5
402
+ ), f"All singleton groups should converge, got {rel_err:.4f}"
403
+
404
+ # Test 3: Unbalanced groups (1 huge group, several small)
405
+ target_groups_unbalanced = np.array([0] * 7 + [1, 2, 3])
406
+ model_unbalanced = SparseCalibrationWeights(n_features=N)
407
+ model_unbalanced.fit(
408
+ M,
409
+ y,
410
+ lambda_l0=0.00001,
411
+ epochs=2000,
412
+ lr=0.2,
413
+ loss_type="relative",
414
+ verbose=False,
415
+ target_groups=target_groups_unbalanced,
416
+ )
417
+
418
+ with torch.no_grad():
419
+ y_pred = model_unbalanced.predict(M).cpu().numpy()
420
+ # Check that small groups aren't ignored
421
+ small_group_errors = np.abs((y[7:] - y_pred[7:]) / (y[7:] + 1))
422
+ assert (
423
+ np.mean(small_group_errors) < 0.5
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