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.
- {l0_python-0.1.1 → l0_python-0.3.1}/PKG-INFO +4 -5
- l0_python-0.3.1/l0/calibration.py +495 -0
- {l0_python-0.1.1 → l0_python-0.3.1}/l0_python.egg-info/PKG-INFO +4 -5
- {l0_python-0.1.1 → l0_python-0.3.1}/l0_python.egg-info/SOURCES.txt +2 -0
- {l0_python-0.1.1 → l0_python-0.3.1}/l0_python.egg-info/requires.txt +2 -3
- {l0_python-0.1.1 → l0_python-0.3.1}/pyproject.toml +4 -21
- l0_python-0.3.1/tests/test_calibration.py +545 -0
- {l0_python-0.1.1 → l0_python-0.3.1}/LICENSE +0 -0
- {l0_python-0.1.1 → l0_python-0.3.1}/README.md +0 -0
- {l0_python-0.1.1 → l0_python-0.3.1}/l0/__init__.py +0 -0
- {l0_python-0.1.1 → l0_python-0.3.1}/l0/distributions.py +0 -0
- {l0_python-0.1.1 → l0_python-0.3.1}/l0/gates.py +0 -0
- {l0_python-0.1.1 → l0_python-0.3.1}/l0/layers.py +0 -0
- {l0_python-0.1.1 → l0_python-0.3.1}/l0/penalties.py +0 -0
- {l0_python-0.1.1 → l0_python-0.3.1}/l0/sparse.py +0 -0
- {l0_python-0.1.1 → l0_python-0.3.1}/l0_python.egg-info/dependency_links.txt +0 -0
- {l0_python-0.1.1 → l0_python-0.3.1}/l0_python.egg-info/top_level.txt +0 -0
- {l0_python-0.1.1 → l0_python-0.3.1}/setup.cfg +0 -0
- {l0_python-0.1.1 → l0_python-0.3.1}/tests/test_distributions.py +0 -0
- {l0_python-0.1.1 → l0_python-0.3.1}/tests/test_gates.py +0 -0
- {l0_python-0.1.1 → l0_python-0.3.1}/tests/test_layers.py +0 -0
- {l0_python-0.1.1 → l0_python-0.3.1}/tests/test_penalties.py +0 -0
- {l0_python-0.1.1 → 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
|
+
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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "l0-python"
|
|
7
|
-
version = "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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|