factorlasso 0.3.2__tar.gz → 0.3.4__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.
- {factorlasso-0.3.2 → factorlasso-0.3.4}/PKG-INFO +56 -3
- {factorlasso-0.3.2 → factorlasso-0.3.4}/README.md +55 -2
- {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso/lasso_estimator.py +162 -9
- {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso.egg-info/PKG-INFO +56 -3
- {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso.egg-info/SOURCES.txt +1 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/pyproject.toml +1 -1
- factorlasso-0.3.4/tests/test_l1_weight.py +473 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/LICENSE +0 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso/__init__.py +0 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso/cluster_utils.py +0 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso/cv.py +0 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso/ewm_utils.py +0 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso/factor_covar.py +0 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso/py.typed +0 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso.egg-info/dependency_links.txt +0 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso.egg-info/requires.txt +0 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso.egg-info/top_level.txt +0 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/setup.cfg +0 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/tests/test_corr_zero_variance.py +0 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/tests/test_coverage_extras.py +0 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/tests/test_external_parity.py +0 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/tests/test_factorlasso.py +0 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/tests/test_integration.py +0 -0
- {factorlasso-0.3.2 → factorlasso-0.3.4}/tests/test_new_features.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: factorlasso
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Sparse factor model estimation with sign-constrained LASSO, prior-centered regularisation, and hierarchical group LASSO (HCGL)
|
|
5
5
|
Author-email: Artur Sepp <artursepp@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -45,7 +45,7 @@ Dynamic: license-file
|
|
|
45
45
|
# factorlasso
|
|
46
46
|
|
|
47
47
|
**Sparse multi-output regression with sign constraints, prior-centered
|
|
48
|
-
regularisation, and
|
|
48
|
+
regularisation, hierarchical group LASSO, and sparse group LASSO — via CVXPY.**
|
|
49
49
|
|
|
50
50
|
[](https://pypi.org/project/factorlasso/)
|
|
51
51
|
[](https://pypi.org/project/factorlasso/)
|
|
@@ -59,7 +59,7 @@ Y = X\beta^\top + \varepsilon,
|
|
|
59
59
|
\qquad \beta \in \mathbb{R}^{N \times M}
|
|
60
60
|
$$
|
|
61
61
|
|
|
62
|
-
when
|
|
62
|
+
when four things matter:
|
|
63
63
|
|
|
64
64
|
- Some coefficients **must be zero, non-negative, or non-positive**, possibly by
|
|
65
65
|
asset, by factor, or both.
|
|
@@ -67,6 +67,9 @@ when three things matter:
|
|
|
67
67
|
- You want **structured sparsity** — groups of responses entering or leaving
|
|
68
68
|
the model together — where the groups are either user-supplied or discovered
|
|
69
69
|
by hierarchical clustering of the response correlation matrix (HCGL).
|
|
70
|
+
- You want to combine **group-level selection with within-group elementwise
|
|
71
|
+
sparsity** via a tunable mix of group L2 and L1 penalties (Sparse Group
|
|
72
|
+
LASSO).
|
|
70
73
|
|
|
71
74
|
It is written in pure numpy/pandas/scipy/cvxpy. No numba, no custom
|
|
72
75
|
coordinate descent. The solver is CVXPY (default `CLARABEL`), so problem
|
|
@@ -174,6 +177,49 @@ Useful when you suspect group structure in the responses but don't know the
|
|
|
174
177
|
partition — or when the correct partition drifts over time, so any manual
|
|
175
178
|
grouping would need to be refit anyway.
|
|
176
179
|
|
|
180
|
+
### 4. Sparse Group LASSO
|
|
181
|
+
|
|
182
|
+
Group LASSO selects whole groups in or out — every response inside an
|
|
183
|
+
"active" group gets a non-zero loading. When the discovered groups are
|
|
184
|
+
slightly heterogeneous (and HCGL clusters often are, especially at coarser
|
|
185
|
+
`cutoff_fraction`), this admits noisy within-group loadings on responses
|
|
186
|
+
that don't actually load on the factor.
|
|
187
|
+
|
|
188
|
+
The `l1_weight` mixing parameter α ∈ [0, 1] adds an elementwise L1 penalty
|
|
189
|
+
on top of the group L2 (Simon, Friedman, Hastie & Tibshirani 2013):
|
|
190
|
+
|
|
191
|
+
$$
|
|
192
|
+
\mathcal{P}(\beta) = (1 - \alpha)\,\lambda \sum_g w_g \, \|\beta_g - \beta_0\|_{2,1}
|
|
193
|
+
\;+\; \alpha\,\lambda \, \|\beta - \beta_0\|_1
|
|
194
|
+
$$
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
model = LassoModel(
|
|
198
|
+
model_type=LassoModelType.GROUP_LASSO_CLUSTERS,
|
|
199
|
+
reg_lambda=1e-4,
|
|
200
|
+
cutoff_fraction=0.65, # coarser clusters
|
|
201
|
+
l1_weight=0.10, # α — group L2 still primary, L1 corrects within-group
|
|
202
|
+
).fit(x=X, y=Y)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The interpretation is "group-then-prune": the group L2 term still drives
|
|
206
|
+
group-level selection, while the L1 term zeros individual asset-factor
|
|
207
|
+
coefficients within active groups whose contribution is noise. Setting
|
|
208
|
+
`l1_weight=0.0` (the default) reduces exactly to pure group LASSO and is
|
|
209
|
+
backward-compatible — the L1 term is dropped from the CVX problem entirely
|
|
210
|
+
when α = 0, with zero runtime cost.
|
|
211
|
+
|
|
212
|
+
Typical research range: α ∈ [0.05, 0.20]. Above ~0.30 the group structure
|
|
213
|
+
stops driving the model and the result reverts toward plain LASSO. The
|
|
214
|
+
penalty is centered on the same prior `β₀` as the group term, so the two
|
|
215
|
+
shrinkage mechanisms compose consistently.
|
|
216
|
+
|
|
217
|
+
The L1 term respects the same per-element sign constraints and the same
|
|
218
|
+
prior as the group term, so all four features in this section compose: a
|
|
219
|
+
single fit can simultaneously enforce sign constraints, shrink toward a
|
|
220
|
+
prior, group-select via HCGL clusters, and apply within-group elementwise
|
|
221
|
+
sparsity.
|
|
222
|
+
|
|
177
223
|
---
|
|
178
224
|
|
|
179
225
|
## When to use it — and when not
|
|
@@ -184,6 +230,8 @@ grouping would need to be refit anyway.
|
|
|
184
230
|
matrix.
|
|
185
231
|
- You have a prior `β₀` that should shrink the fit instead of zero.
|
|
186
232
|
- You need discovered-group structured sparsity (HCGL).
|
|
233
|
+
- You need group-level selection with within-group elementwise sparsity
|
|
234
|
+
(sparse group LASSO at small-to-moderate α).
|
|
187
235
|
- You want a small, auditable CVXPY-based tool rather than a coordinate-descent
|
|
188
236
|
library with opaque internals.
|
|
189
237
|
|
|
@@ -193,6 +241,11 @@ grouping would need to be refit anyway.
|
|
|
193
241
|
`celer`, or `skglm` will be faster and have years of battle-testing.
|
|
194
242
|
- You need fixed-group group LASSO at very large scale — `group-lasso` or
|
|
195
243
|
`asgl` are the standard tools.
|
|
244
|
+
- You need sparse group LASSO at large α (close to 1.0) or at very large
|
|
245
|
+
scale — specialised solvers like `asgl` or `SGL` handle proximal-operator
|
|
246
|
+
acceleration that the CVXPY formulation here does not. This package's
|
|
247
|
+
sparse group LASSO is intended for moderate α ∈ [0, 0.3] where the group
|
|
248
|
+
structure remains primary.
|
|
196
249
|
- You need non-linear models, random effects, or GLM link functions.
|
|
197
250
|
|
|
198
251
|
A feature-by-feature comparison matrix is in
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# factorlasso
|
|
2
2
|
|
|
3
3
|
**Sparse multi-output regression with sign constraints, prior-centered
|
|
4
|
-
regularisation, and
|
|
4
|
+
regularisation, hierarchical group LASSO, and sparse group LASSO — via CVXPY.**
|
|
5
5
|
|
|
6
6
|
[](https://pypi.org/project/factorlasso/)
|
|
7
7
|
[](https://pypi.org/project/factorlasso/)
|
|
@@ -15,7 +15,7 @@ Y = X\beta^\top + \varepsilon,
|
|
|
15
15
|
\qquad \beta \in \mathbb{R}^{N \times M}
|
|
16
16
|
$$
|
|
17
17
|
|
|
18
|
-
when
|
|
18
|
+
when four things matter:
|
|
19
19
|
|
|
20
20
|
- Some coefficients **must be zero, non-negative, or non-positive**, possibly by
|
|
21
21
|
asset, by factor, or both.
|
|
@@ -23,6 +23,9 @@ when three things matter:
|
|
|
23
23
|
- You want **structured sparsity** — groups of responses entering or leaving
|
|
24
24
|
the model together — where the groups are either user-supplied or discovered
|
|
25
25
|
by hierarchical clustering of the response correlation matrix (HCGL).
|
|
26
|
+
- You want to combine **group-level selection with within-group elementwise
|
|
27
|
+
sparsity** via a tunable mix of group L2 and L1 penalties (Sparse Group
|
|
28
|
+
LASSO).
|
|
26
29
|
|
|
27
30
|
It is written in pure numpy/pandas/scipy/cvxpy. No numba, no custom
|
|
28
31
|
coordinate descent. The solver is CVXPY (default `CLARABEL`), so problem
|
|
@@ -130,6 +133,49 @@ Useful when you suspect group structure in the responses but don't know the
|
|
|
130
133
|
partition — or when the correct partition drifts over time, so any manual
|
|
131
134
|
grouping would need to be refit anyway.
|
|
132
135
|
|
|
136
|
+
### 4. Sparse Group LASSO
|
|
137
|
+
|
|
138
|
+
Group LASSO selects whole groups in or out — every response inside an
|
|
139
|
+
"active" group gets a non-zero loading. When the discovered groups are
|
|
140
|
+
slightly heterogeneous (and HCGL clusters often are, especially at coarser
|
|
141
|
+
`cutoff_fraction`), this admits noisy within-group loadings on responses
|
|
142
|
+
that don't actually load on the factor.
|
|
143
|
+
|
|
144
|
+
The `l1_weight` mixing parameter α ∈ [0, 1] adds an elementwise L1 penalty
|
|
145
|
+
on top of the group L2 (Simon, Friedman, Hastie & Tibshirani 2013):
|
|
146
|
+
|
|
147
|
+
$$
|
|
148
|
+
\mathcal{P}(\beta) = (1 - \alpha)\,\lambda \sum_g w_g \, \|\beta_g - \beta_0\|_{2,1}
|
|
149
|
+
\;+\; \alpha\,\lambda \, \|\beta - \beta_0\|_1
|
|
150
|
+
$$
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
model = LassoModel(
|
|
154
|
+
model_type=LassoModelType.GROUP_LASSO_CLUSTERS,
|
|
155
|
+
reg_lambda=1e-4,
|
|
156
|
+
cutoff_fraction=0.65, # coarser clusters
|
|
157
|
+
l1_weight=0.10, # α — group L2 still primary, L1 corrects within-group
|
|
158
|
+
).fit(x=X, y=Y)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The interpretation is "group-then-prune": the group L2 term still drives
|
|
162
|
+
group-level selection, while the L1 term zeros individual asset-factor
|
|
163
|
+
coefficients within active groups whose contribution is noise. Setting
|
|
164
|
+
`l1_weight=0.0` (the default) reduces exactly to pure group LASSO and is
|
|
165
|
+
backward-compatible — the L1 term is dropped from the CVX problem entirely
|
|
166
|
+
when α = 0, with zero runtime cost.
|
|
167
|
+
|
|
168
|
+
Typical research range: α ∈ [0.05, 0.20]. Above ~0.30 the group structure
|
|
169
|
+
stops driving the model and the result reverts toward plain LASSO. The
|
|
170
|
+
penalty is centered on the same prior `β₀` as the group term, so the two
|
|
171
|
+
shrinkage mechanisms compose consistently.
|
|
172
|
+
|
|
173
|
+
The L1 term respects the same per-element sign constraints and the same
|
|
174
|
+
prior as the group term, so all four features in this section compose: a
|
|
175
|
+
single fit can simultaneously enforce sign constraints, shrink toward a
|
|
176
|
+
prior, group-select via HCGL clusters, and apply within-group elementwise
|
|
177
|
+
sparsity.
|
|
178
|
+
|
|
133
179
|
---
|
|
134
180
|
|
|
135
181
|
## When to use it — and when not
|
|
@@ -140,6 +186,8 @@ grouping would need to be refit anyway.
|
|
|
140
186
|
matrix.
|
|
141
187
|
- You have a prior `β₀` that should shrink the fit instead of zero.
|
|
142
188
|
- You need discovered-group structured sparsity (HCGL).
|
|
189
|
+
- You need group-level selection with within-group elementwise sparsity
|
|
190
|
+
(sparse group LASSO at small-to-moderate α).
|
|
143
191
|
- You want a small, auditable CVXPY-based tool rather than a coordinate-descent
|
|
144
192
|
library with opaque internals.
|
|
145
193
|
|
|
@@ -149,6 +197,11 @@ grouping would need to be refit anyway.
|
|
|
149
197
|
`celer`, or `skglm` will be faster and have years of battle-testing.
|
|
150
198
|
- You need fixed-group group LASSO at very large scale — `group-lasso` or
|
|
151
199
|
`asgl` are the standard tools.
|
|
200
|
+
- You need sparse group LASSO at large α (close to 1.0) or at very large
|
|
201
|
+
scale — specialised solvers like `asgl` or `SGL` handle proximal-operator
|
|
202
|
+
acceleration that the CVXPY formulation here does not. This package's
|
|
203
|
+
sparse group LASSO is intended for moderate α ∈ [0, 0.3] where the group
|
|
204
|
+
structure remains primary.
|
|
152
205
|
- You need non-linear models, random effects, or GLM link functions.
|
|
153
206
|
|
|
154
207
|
A feature-by-feature comparison matrix is in
|
|
@@ -89,7 +89,27 @@ class LassoEstimationResult:
|
|
|
89
89
|
estimated_beta : np.ndarray, shape (N, M)
|
|
90
90
|
Factor loadings. NaN if solver failed.
|
|
91
91
|
alpha : np.ndarray, shape (N,)
|
|
92
|
-
EWMA-weighted mean
|
|
92
|
+
EWMA-weighted mean of the *demeaned* residuals, per response.
|
|
93
|
+
|
|
94
|
+
Important: this is **not** the regression intercept in the original
|
|
95
|
+
``y = α + Xβ + ε`` representation. Because :func:`get_x_y_np` removes
|
|
96
|
+
the conditional mean of both ``y`` and ``X`` before the solver runs,
|
|
97
|
+
the model that is actually fitted is
|
|
98
|
+
|
|
99
|
+
``y_demeaned ≈ X_demeaned · β`` (no intercept term)
|
|
100
|
+
|
|
101
|
+
and ``alpha`` here is computed *post-hoc* as the weighted mean of the
|
|
102
|
+
residuals on the demeaned data. In particular:
|
|
103
|
+
|
|
104
|
+
* If ``span is None`` (sample-mean demeaning), this quantity is
|
|
105
|
+
identically zero by the OLS first-order condition.
|
|
106
|
+
* If ``span`` is set (one-sided EWMA demeaning), this quantity is the
|
|
107
|
+
leftover when the EWMA mean does not match the sample mean — i.e.
|
|
108
|
+
a *finite-sample EWMA-demean residual*, not an intercept.
|
|
109
|
+
|
|
110
|
+
``LassoModel`` exposes this value as ``model.intercept_`` for
|
|
111
|
+
backward compatibility; the **economic intercept** of the regression
|
|
112
|
+
in original units is available separately as ``model.alpha_const_``.
|
|
93
113
|
ss_total : np.ndarray, shape (N,)
|
|
94
114
|
EWMA-weighted total variance per response variable.
|
|
95
115
|
ss_res : np.ndarray, shape (N,)
|
|
@@ -114,7 +134,16 @@ def _compute_solver_diagnostics(
|
|
|
114
134
|
estimated_beta: np.ndarray,
|
|
115
135
|
weights: np.ndarray,
|
|
116
136
|
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
117
|
-
"""In-sample fit diagnostics from solver weights.
|
|
137
|
+
"""In-sample fit diagnostics from solver weights.
|
|
138
|
+
|
|
139
|
+
The returned ``alpha`` is the EWMA-weighted mean of residuals on the
|
|
140
|
+
demeaned data the solver received. It is **not** the regression
|
|
141
|
+
intercept in original units; see the docstring of
|
|
142
|
+
:class:`LassoEstimationResult` for the distinction. The economic
|
|
143
|
+
intercept of ``y = α + Xβ + ε`` is computed in
|
|
144
|
+
:meth:`LassoModel.fit` from the sample means of the original (pre-
|
|
145
|
+
demean) ``y`` and ``X``, and is exposed as ``model.alpha_const_``.
|
|
146
|
+
"""
|
|
118
147
|
col_sums = np.sum(weights, axis=0)
|
|
119
148
|
norm_w = np.divide(weights, col_sums, out=np.zeros_like(weights),
|
|
120
149
|
where=col_sums != 0)
|
|
@@ -374,6 +403,7 @@ def solve_group_lasso_cvx_problem(
|
|
|
374
403
|
factors_beta_loading_signs: Optional[np.ndarray] = None,
|
|
375
404
|
factors_beta_prior: Optional[np.ndarray] = None,
|
|
376
405
|
group_penalty: str = "normalized",
|
|
406
|
+
l1_weight: float = 0.0,
|
|
377
407
|
) -> LassoEstimationResult:
|
|
378
408
|
r"""
|
|
379
409
|
Group LASSO multi-output regression via CVXPY.
|
|
@@ -383,14 +413,22 @@ def solve_group_lasso_cvx_problem(
|
|
|
383
413
|
.. math::
|
|
384
414
|
|
|
385
415
|
\frac{1}{T}\|W \odot (X\beta^\top - Y)\|_F^2
|
|
386
|
-
+ \lambda \sum_g w_g \sum_{i \in g}
|
|
416
|
+
+ (1 - \alpha)\,\lambda \sum_g w_g \sum_{i \in g}
|
|
387
417
|
\|\beta_{i,:} - \beta_{0,i,:}\|_2
|
|
418
|
+
+ \alpha\,\lambda \,\|\beta - \beta_0\|_1
|
|
388
419
|
|
|
389
420
|
where *g* indexes groups of response variables (rows of β) and the
|
|
390
421
|
per-group weight ``w_g`` is set by ``group_penalty`` (see below).
|
|
391
|
-
The inner sum is the ``L_{2,1}`` norm of the group
|
|
392
|
-
each response's loading vector is shrunk by an L2 norm,
|
|
393
|
-
sparsity is driven across responses within a group.
|
|
422
|
+
The inner sum of the group term is the ``L_{2,1}`` norm of the group
|
|
423
|
+
submatrix — each response's loading vector is shrunk by an L2 norm,
|
|
424
|
+
and block sparsity is driven across responses within a group. The
|
|
425
|
+
optional L1 term drives elementwise sparsity on top, zeroing
|
|
426
|
+
individual assets whose loadings are noisy even within an "active"
|
|
427
|
+
group — the Simon–Friedman–Hastie–Tibshirani (2013) Sparse Group
|
|
428
|
+
LASSO formulation.
|
|
429
|
+
|
|
430
|
+
At ``l1_weight=0.0`` (default) the problem reduces to the previous
|
|
431
|
+
pure group LASSO and is numerically identical to v0.3.1.
|
|
394
432
|
|
|
395
433
|
Parameters
|
|
396
434
|
----------
|
|
@@ -418,6 +456,18 @@ def solve_group_lasso_cvx_problem(
|
|
|
418
456
|
The two conventions are related by a constant factor √G, so
|
|
419
457
|
results under ``"yuan_lin"`` at regularisation ``λ`` match
|
|
420
458
|
results under ``"normalized"`` at regularisation ``λ·√G``.
|
|
459
|
+
l1_weight : float, default 0.0
|
|
460
|
+
Sparse Group LASSO mixing parameter ``α ∈ [0, 1]``. Weight on
|
|
461
|
+
the elementwise L1 penalty term; ``(1 - α)`` weights the group
|
|
462
|
+
L2 term. Set ``α = 0`` (default) for pure group LASSO —
|
|
463
|
+
backward compatible with v0.3.1. Set ``α = 1`` for pure LASSO
|
|
464
|
+
(no group structure). Typical research values are ``α ∈
|
|
465
|
+
[0.05, 0.20]``: preserve group structure as the primary
|
|
466
|
+
selection mechanism while allowing additional within-group
|
|
467
|
+
elementwise zeroing for assets whose loadings are noisy. The
|
|
468
|
+
L1 term shrinks ``β`` toward the prior ``β_0`` elementwise,
|
|
469
|
+
consistent with the group term which also shrinks toward the
|
|
470
|
+
prior.
|
|
421
471
|
|
|
422
472
|
Returns
|
|
423
473
|
-------
|
|
@@ -429,6 +479,10 @@ def solve_group_lasso_cvx_problem(
|
|
|
429
479
|
f"group_penalty must be 'normalized' or 'yuan_lin', "
|
|
430
480
|
f"got {group_penalty!r}"
|
|
431
481
|
)
|
|
482
|
+
if not (0.0 <= l1_weight <= 1.0):
|
|
483
|
+
raise ValueError(
|
|
484
|
+
f"l1_weight must lie in [0, 1], got {l1_weight!r}"
|
|
485
|
+
)
|
|
432
486
|
assert y.ndim == 2 and x.ndim == 2 and group_loadings.ndim == 2
|
|
433
487
|
assert x.shape[0] == y.shape[0] and y.shape[1] == group_loadings.shape[0]
|
|
434
488
|
|
|
@@ -464,16 +518,26 @@ def solve_group_lasso_cvx_problem(
|
|
|
464
518
|
return float(np.sqrt(g))
|
|
465
519
|
return float(np.sqrt(g / n_groups))
|
|
466
520
|
|
|
467
|
-
# Group penalty
|
|
521
|
+
# Group penalty (L_{2,1} norm within each group, scaled by (1 - α))
|
|
468
522
|
masks = [
|
|
469
523
|
np.isclose(group_loadings[:, g], 1.0) for g in range(n_groups)
|
|
470
524
|
]
|
|
471
|
-
|
|
525
|
+
group_pen = cvx.sum([
|
|
472
526
|
reg_lambda * _weight(m)
|
|
473
527
|
* cvx.sum(cvx.norm2(beta[m, :] - prior[m, :], axis=1))
|
|
474
528
|
for m in masks
|
|
475
529
|
])
|
|
476
530
|
|
|
531
|
+
# Elementwise L1 penalty (scaled by α). Shrinks toward the same
|
|
532
|
+
# prior used by the group term so at α=1 the problem is consistent
|
|
533
|
+
# with plain LASSO centred on β₀. At α=0 this term vanishes and
|
|
534
|
+
# the problem reduces exactly to the v0.3.1 pure group LASSO.
|
|
535
|
+
if l1_weight > 0.0:
|
|
536
|
+
l1_pen = reg_lambda * cvx.sum(cvx.abs(beta - prior))
|
|
537
|
+
penalty = (1.0 - l1_weight) * group_pen + l1_weight * l1_pen
|
|
538
|
+
else:
|
|
539
|
+
penalty = group_pen
|
|
540
|
+
|
|
477
541
|
problem = cvx.Problem(cvx.Minimize(fit + penalty), constraints) \
|
|
478
542
|
if constraints else cvx.Problem(cvx.Minimize(fit + penalty))
|
|
479
543
|
problem.solve(verbose=verbose, solver=solver)
|
|
@@ -559,6 +623,17 @@ class LassoModel:
|
|
|
559
623
|
``"yuan_lin"`` uses the classical Yuan–Lin (2006) ``√|g|``.
|
|
560
624
|
Ignored for ``model_type == LASSO``. See
|
|
561
625
|
:func:`solve_group_lasso_cvx_problem` for the full formula.
|
|
626
|
+
l1_weight : float, default 0.0
|
|
627
|
+
Sparse Group LASSO mixing parameter ``α ∈ [0, 1]``. Adds an
|
|
628
|
+
elementwise L1 penalty ``α·λ·|β - β₀|`` on top of the standard
|
|
629
|
+
group L2 penalty (which is scaled by ``(1 - α)``). Set ``α = 0``
|
|
630
|
+
(default) for pure group LASSO — backward compatible with
|
|
631
|
+
v0.3.1. Typical research values: ``α ∈ [0.05, 0.20]`` — preserve
|
|
632
|
+
group structure as the primary mechanism while allowing
|
|
633
|
+
additional within-group elementwise zeroing for assets whose
|
|
634
|
+
loadings are noisy. Only consumed when ``model_type`` is
|
|
635
|
+
``GROUP_LASSO`` or ``GROUP_LASSO_CLUSTERS``; ignored for pure
|
|
636
|
+
``LASSO`` since L1 is the only penalty already.
|
|
562
637
|
factors_beta_loading_signs : pd.DataFrame, optional
|
|
563
638
|
factors_beta_prior : pd.DataFrame, optional
|
|
564
639
|
demean : bool, default True
|
|
@@ -569,8 +644,31 @@ class LassoModel:
|
|
|
569
644
|
--------------------------------------
|
|
570
645
|
coef_ : pd.DataFrame, shape (N, M)
|
|
571
646
|
Estimated factor loadings β.
|
|
647
|
+
alpha_const_ : pd.Series, shape (N,)
|
|
648
|
+
**Economic intercept α** — what users typically mean by "alpha"
|
|
649
|
+
when decomposing returns into ``y = α + Xβ + ε``. Reconstructed
|
|
650
|
+
from sample means of the *original* (pre-demean) ``y`` and ``X``
|
|
651
|
+
and the fitted β, so the identity ``y_mean = α + x_mean · β``
|
|
652
|
+
holds exactly. For ``span=None`` and unconstrained coefficients
|
|
653
|
+
this equals the OLS intercept exactly; for ``span=integer`` it
|
|
654
|
+
is reconstructed from sample means rather than EWMA means and
|
|
655
|
+
thus stays interpretable regardless of EWMA span.
|
|
656
|
+
|
|
657
|
+
This is the field to read when reporting the regression intercept.
|
|
572
658
|
intercept_ : pd.Series, shape (N,)
|
|
573
|
-
|
|
659
|
+
Raw solver output: the EWMA-weighted mean of residuals on the
|
|
660
|
+
*demeaned* data, equal to ``estimation_result_.alpha``. Because
|
|
661
|
+
the underlying solver fits a no-intercept model on centered data,
|
|
662
|
+
this is a mechanical artefact of the fit, **not** the regression
|
|
663
|
+
intercept in original units:
|
|
664
|
+
|
|
665
|
+
* for ``span=None`` this is identically zero by the OLS
|
|
666
|
+
first-order condition;
|
|
667
|
+
* for ``span=integer`` it is a finite-sample EWMA-demean leftover.
|
|
668
|
+
|
|
669
|
+
Preserved under this name for back-compatibility with code that
|
|
670
|
+
read ``model.intercept_`` in pre-0.3.4 versions. New code should
|
|
671
|
+
use ``alpha_const_`` for the economic intercept.
|
|
574
672
|
estimation_result_ : LassoEstimationResult
|
|
575
673
|
Full diagnostics (alpha, ss_total, ss_res, r2).
|
|
576
674
|
clusters_ : pd.Series or None
|
|
@@ -606,6 +704,7 @@ class LassoModel:
|
|
|
606
704
|
span_freq_dict: Optional[Dict[str, float]] = None
|
|
607
705
|
cutoff_fraction: float = DEFAULT_CUTOFF_FRACTION
|
|
608
706
|
group_penalty: str = "normalized"
|
|
707
|
+
l1_weight: float = 0.0
|
|
609
708
|
demean: bool = True
|
|
610
709
|
solver: str = 'CLARABEL'
|
|
611
710
|
warmup_period: Optional[int] = 12
|
|
@@ -618,6 +717,7 @@ class LassoModel:
|
|
|
618
717
|
y_: Optional[pd.DataFrame] = None
|
|
619
718
|
coef_: Optional[pd.DataFrame] = None
|
|
620
719
|
intercept_: Optional[pd.Series] = None
|
|
720
|
+
alpha_const_: Optional[pd.Series] = None
|
|
621
721
|
estimation_result_: Optional[LassoEstimationResult] = None
|
|
622
722
|
clusters_: Optional[pd.Series] = None
|
|
623
723
|
linkage_: Optional[np.ndarray] = None
|
|
@@ -641,6 +741,10 @@ class LassoModel:
|
|
|
641
741
|
f"group_penalty must be 'normalized' or 'yuan_lin', "
|
|
642
742
|
f"got {self.group_penalty!r}"
|
|
643
743
|
)
|
|
744
|
+
if not (0.0 <= self.l1_weight <= 1.0):
|
|
745
|
+
raise ValueError(
|
|
746
|
+
f"l1_weight must lie in [0, 1], got {self.l1_weight!r}"
|
|
747
|
+
)
|
|
644
748
|
|
|
645
749
|
# ── Backward-compatible property aliases ─────────────────────────
|
|
646
750
|
|
|
@@ -860,6 +964,7 @@ class LassoModel:
|
|
|
860
964
|
factors_beta_loading_signs=signs_np,
|
|
861
965
|
factors_beta_prior=prior_np,
|
|
862
966
|
group_penalty=self.group_penalty,
|
|
967
|
+
l1_weight=self.l1_weight,
|
|
863
968
|
)
|
|
864
969
|
|
|
865
970
|
elif self.model_type == LassoModelType.GROUP_LASSO_CLUSTERS:
|
|
@@ -903,12 +1008,14 @@ class LassoModel:
|
|
|
903
1008
|
factors_beta_loading_signs=signs_np,
|
|
904
1009
|
factors_beta_prior=prior_np,
|
|
905
1010
|
group_penalty=self.group_penalty,
|
|
1011
|
+
l1_weight=self.l1_weight,
|
|
906
1012
|
)
|
|
907
1013
|
else:
|
|
908
1014
|
raise NotImplementedError(f"Unsupported model_type: {self.model_type}")
|
|
909
1015
|
|
|
910
1016
|
# Zero out betas for variables with insufficient history
|
|
911
1017
|
est_beta = result.estimated_beta
|
|
1018
|
+
short_assets: Optional[pd.Index] = None
|
|
912
1019
|
if self.warmup_period is not None:
|
|
913
1020
|
n_valid = np.count_nonzero(~np.isnan(y.to_numpy()), axis=0)
|
|
914
1021
|
short = n_valid < self.warmup_period
|
|
@@ -916,6 +1023,14 @@ class LassoModel:
|
|
|
916
1023
|
est_beta[short, :] = 0.0
|
|
917
1024
|
for attr in ('alpha', 'ss_total', 'ss_res', 'r2'):
|
|
918
1025
|
getattr(result, attr)[short] = np.nan
|
|
1026
|
+
# Capture for use below — the cluster-assignment step
|
|
1027
|
+
# at the end of fit() needs to drop these same assets
|
|
1028
|
+
# so clusters_, coef_, and per-asset diagnostics stay
|
|
1029
|
+
# mutually consistent. Without this the zeroed-beta
|
|
1030
|
+
# assets would still receive spurious singleton cluster
|
|
1031
|
+
# labels that inflate downstream n_clusters and pollute
|
|
1032
|
+
# cluster-based risk attribution / regime diagnostics.
|
|
1033
|
+
short_assets = y.columns[short]
|
|
919
1034
|
|
|
920
1035
|
# Store fitted state (trailing underscore convention)
|
|
921
1036
|
self.x_ = x
|
|
@@ -925,9 +1040,35 @@ class LassoModel:
|
|
|
925
1040
|
self.coef_ = pd.DataFrame(
|
|
926
1041
|
est_beta, index=y.columns, columns=x.columns,
|
|
927
1042
|
)
|
|
1043
|
+
# intercept_ : preserved from v0.3.3 — the raw solver output, namely
|
|
1044
|
+
# the EWMA-weighted residual mean on the demeaned data. This is the
|
|
1045
|
+
# mechanical artefact of fitting a no-intercept model on centered
|
|
1046
|
+
# inputs (see :class:`LassoEstimationResult` docstring on ``alpha``).
|
|
1047
|
+
# It is NOT the regression intercept in the original
|
|
1048
|
+
# ``y = α + Xβ + ε`` representation; for span=None it is identically
|
|
1049
|
+
# zero by construction. Kept under this name for back-compat with
|
|
1050
|
+
# any analytics that read ``model.intercept_``.
|
|
928
1051
|
self.intercept_ = pd.Series(
|
|
929
1052
|
result.alpha, index=y.columns, name='intercept',
|
|
930
1053
|
)
|
|
1054
|
+
# alpha_const_ : the economic intercept α — what users typically
|
|
1055
|
+
# mean by "alpha" when decomposing returns into ``α + Xβ + ε``.
|
|
1056
|
+
# Reconstructed from sample means of the *original* (pre-demean)
|
|
1057
|
+
# ``y`` and ``X`` and the fitted β so the identity
|
|
1058
|
+
# ``y_mean = α + x_mean · β`` holds exactly. For span=None and
|
|
1059
|
+
# unconstrained coefficients this equals the OLS intercept exactly;
|
|
1060
|
+
# for span=integer it remains the interpretable
|
|
1061
|
+
# ``α + factor exposure`` decomposition regardless of EWMA span.
|
|
1062
|
+
x_mean = np.asarray(x.mean(skipna=True).values, dtype=float)
|
|
1063
|
+
y_mean = np.asarray(y.mean(skipna=True).values, dtype=float)
|
|
1064
|
+
beta_arr = np.where(np.isnan(est_beta), 0.0, est_beta)
|
|
1065
|
+
alpha_const_arr = y_mean - beta_arr @ x_mean
|
|
1066
|
+
alpha_const_ser = pd.Series(
|
|
1067
|
+
alpha_const_arr, index=y.columns, name='alpha_const',
|
|
1068
|
+
)
|
|
1069
|
+
if short_assets is not None:
|
|
1070
|
+
alpha_const_ser.loc[short_assets] = np.nan
|
|
1071
|
+
self.alpha_const_ = alpha_const_ser
|
|
931
1072
|
self.estimation_result_ = result
|
|
932
1073
|
# For GROUP_LASSO with externally supplied group_data, record it as
|
|
933
1074
|
# clusters_ so downstream consumers (CurrentFactorCovarData,
|
|
@@ -936,6 +1077,18 @@ class LassoModel:
|
|
|
936
1077
|
# modes. The HCGL path already sets `clusters` above.
|
|
937
1078
|
if clusters is None and self.model_type == LassoModelType.GROUP_LASSO:
|
|
938
1079
|
clusters = self.group_data.reindex(y.columns).copy()
|
|
1080
|
+
# Filter out cluster labels for ghost assets — assets whose betas
|
|
1081
|
+
# were zeroed above because they had fewer than ``warmup_period``
|
|
1082
|
+
# valid observations. Dropping them here keeps ``clusters_``
|
|
1083
|
+
# consistent with ``coef_`` (zeroed) and per-asset diagnostics
|
|
1084
|
+
# (NaN), so downstream consumers that count or analyse clusters
|
|
1085
|
+
# see only assets that actually contributed to the fit. Without
|
|
1086
|
+
# this, pre-launch / short-history assets receive placeholder
|
|
1087
|
+
# singleton labels that inflate ``n_clusters`` in early history
|
|
1088
|
+
# (observed: 83 raw vs 31 real on a 160-asset multi-asset
|
|
1089
|
+
# universe at 2002-12-31).
|
|
1090
|
+
if clusters is not None and short_assets is not None and len(short_assets) > 0:
|
|
1091
|
+
clusters = clusters.drop(short_assets, errors='ignore')
|
|
939
1092
|
self.clusters_ = clusters
|
|
940
1093
|
self.linkage_ = linkage
|
|
941
1094
|
self.cutoff_ = cutoff
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: factorlasso
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Sparse factor model estimation with sign-constrained LASSO, prior-centered regularisation, and hierarchical group LASSO (HCGL)
|
|
5
5
|
Author-email: Artur Sepp <artursepp@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -45,7 +45,7 @@ Dynamic: license-file
|
|
|
45
45
|
# factorlasso
|
|
46
46
|
|
|
47
47
|
**Sparse multi-output regression with sign constraints, prior-centered
|
|
48
|
-
regularisation, and
|
|
48
|
+
regularisation, hierarchical group LASSO, and sparse group LASSO — via CVXPY.**
|
|
49
49
|
|
|
50
50
|
[](https://pypi.org/project/factorlasso/)
|
|
51
51
|
[](https://pypi.org/project/factorlasso/)
|
|
@@ -59,7 +59,7 @@ Y = X\beta^\top + \varepsilon,
|
|
|
59
59
|
\qquad \beta \in \mathbb{R}^{N \times M}
|
|
60
60
|
$$
|
|
61
61
|
|
|
62
|
-
when
|
|
62
|
+
when four things matter:
|
|
63
63
|
|
|
64
64
|
- Some coefficients **must be zero, non-negative, or non-positive**, possibly by
|
|
65
65
|
asset, by factor, or both.
|
|
@@ -67,6 +67,9 @@ when three things matter:
|
|
|
67
67
|
- You want **structured sparsity** — groups of responses entering or leaving
|
|
68
68
|
the model together — where the groups are either user-supplied or discovered
|
|
69
69
|
by hierarchical clustering of the response correlation matrix (HCGL).
|
|
70
|
+
- You want to combine **group-level selection with within-group elementwise
|
|
71
|
+
sparsity** via a tunable mix of group L2 and L1 penalties (Sparse Group
|
|
72
|
+
LASSO).
|
|
70
73
|
|
|
71
74
|
It is written in pure numpy/pandas/scipy/cvxpy. No numba, no custom
|
|
72
75
|
coordinate descent. The solver is CVXPY (default `CLARABEL`), so problem
|
|
@@ -174,6 +177,49 @@ Useful when you suspect group structure in the responses but don't know the
|
|
|
174
177
|
partition — or when the correct partition drifts over time, so any manual
|
|
175
178
|
grouping would need to be refit anyway.
|
|
176
179
|
|
|
180
|
+
### 4. Sparse Group LASSO
|
|
181
|
+
|
|
182
|
+
Group LASSO selects whole groups in or out — every response inside an
|
|
183
|
+
"active" group gets a non-zero loading. When the discovered groups are
|
|
184
|
+
slightly heterogeneous (and HCGL clusters often are, especially at coarser
|
|
185
|
+
`cutoff_fraction`), this admits noisy within-group loadings on responses
|
|
186
|
+
that don't actually load on the factor.
|
|
187
|
+
|
|
188
|
+
The `l1_weight` mixing parameter α ∈ [0, 1] adds an elementwise L1 penalty
|
|
189
|
+
on top of the group L2 (Simon, Friedman, Hastie & Tibshirani 2013):
|
|
190
|
+
|
|
191
|
+
$$
|
|
192
|
+
\mathcal{P}(\beta) = (1 - \alpha)\,\lambda \sum_g w_g \, \|\beta_g - \beta_0\|_{2,1}
|
|
193
|
+
\;+\; \alpha\,\lambda \, \|\beta - \beta_0\|_1
|
|
194
|
+
$$
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
model = LassoModel(
|
|
198
|
+
model_type=LassoModelType.GROUP_LASSO_CLUSTERS,
|
|
199
|
+
reg_lambda=1e-4,
|
|
200
|
+
cutoff_fraction=0.65, # coarser clusters
|
|
201
|
+
l1_weight=0.10, # α — group L2 still primary, L1 corrects within-group
|
|
202
|
+
).fit(x=X, y=Y)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The interpretation is "group-then-prune": the group L2 term still drives
|
|
206
|
+
group-level selection, while the L1 term zeros individual asset-factor
|
|
207
|
+
coefficients within active groups whose contribution is noise. Setting
|
|
208
|
+
`l1_weight=0.0` (the default) reduces exactly to pure group LASSO and is
|
|
209
|
+
backward-compatible — the L1 term is dropped from the CVX problem entirely
|
|
210
|
+
when α = 0, with zero runtime cost.
|
|
211
|
+
|
|
212
|
+
Typical research range: α ∈ [0.05, 0.20]. Above ~0.30 the group structure
|
|
213
|
+
stops driving the model and the result reverts toward plain LASSO. The
|
|
214
|
+
penalty is centered on the same prior `β₀` as the group term, so the two
|
|
215
|
+
shrinkage mechanisms compose consistently.
|
|
216
|
+
|
|
217
|
+
The L1 term respects the same per-element sign constraints and the same
|
|
218
|
+
prior as the group term, so all four features in this section compose: a
|
|
219
|
+
single fit can simultaneously enforce sign constraints, shrink toward a
|
|
220
|
+
prior, group-select via HCGL clusters, and apply within-group elementwise
|
|
221
|
+
sparsity.
|
|
222
|
+
|
|
177
223
|
---
|
|
178
224
|
|
|
179
225
|
## When to use it — and when not
|
|
@@ -184,6 +230,8 @@ grouping would need to be refit anyway.
|
|
|
184
230
|
matrix.
|
|
185
231
|
- You have a prior `β₀` that should shrink the fit instead of zero.
|
|
186
232
|
- You need discovered-group structured sparsity (HCGL).
|
|
233
|
+
- You need group-level selection with within-group elementwise sparsity
|
|
234
|
+
(sparse group LASSO at small-to-moderate α).
|
|
187
235
|
- You want a small, auditable CVXPY-based tool rather than a coordinate-descent
|
|
188
236
|
library with opaque internals.
|
|
189
237
|
|
|
@@ -193,6 +241,11 @@ grouping would need to be refit anyway.
|
|
|
193
241
|
`celer`, or `skglm` will be faster and have years of battle-testing.
|
|
194
242
|
- You need fixed-group group LASSO at very large scale — `group-lasso` or
|
|
195
243
|
`asgl` are the standard tools.
|
|
244
|
+
- You need sparse group LASSO at large α (close to 1.0) or at very large
|
|
245
|
+
scale — specialised solvers like `asgl` or `SGL` handle proximal-operator
|
|
246
|
+
acceleration that the CVXPY formulation here does not. This package's
|
|
247
|
+
sparse group LASSO is intended for moderate α ∈ [0, 0.3] where the group
|
|
248
|
+
structure remains primary.
|
|
196
249
|
- You need non-linear models, random effects, or GLM link functions.
|
|
197
250
|
|
|
198
251
|
A feature-by-feature comparison matrix is in
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "factorlasso"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.4"
|
|
8
8
|
description = "Sparse factor model estimation with sign-constrained LASSO, prior-centered regularisation, and hierarchical group LASSO (HCGL)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the ``l1_weight`` / Sparse Group LASSO feature (v0.3.2).
|
|
3
|
+
|
|
4
|
+
Coverage:
|
|
5
|
+
- Backward compatibility at ``l1_weight=0.0`` (bit-identical to v0.3.1
|
|
6
|
+
pure group LASSO).
|
|
7
|
+
- LASSO mode ignores ``l1_weight`` (documented behaviour).
|
|
8
|
+
- Input validation via ``LassoModel.__post_init__`` and solver-level
|
|
9
|
+
range check.
|
|
10
|
+
- Mathematical limit at ``l1_weight=1.0``: group term is nuked, result
|
|
11
|
+
approximates pure LASSO.
|
|
12
|
+
- Interaction with ``group_penalty``: at α=1 group scheme is irrelevant;
|
|
13
|
+
at 0<α<1 both terms are active and the choice matters.
|
|
14
|
+
- Group-then-prune property: on a panel with noisy within-cluster
|
|
15
|
+
assets, increasing α progressively zeros the spurious loadings while
|
|
16
|
+
preserving genuine signal loadings.
|
|
17
|
+
- Sign constraints and prior centering compose with the L1 term.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
import pandas as pd
|
|
23
|
+
import pytest
|
|
24
|
+
|
|
25
|
+
from factorlasso import (
|
|
26
|
+
LassoModel,
|
|
27
|
+
LassoModelType,
|
|
28
|
+
compute_clusters_from_corr_matrix,
|
|
29
|
+
)
|
|
30
|
+
from factorlasso.ewm_utils import compute_ewm_covar, set_group_loadings
|
|
31
|
+
from factorlasso.lasso_estimator import (
|
|
32
|
+
get_x_y_np,
|
|
33
|
+
solve_group_lasso_cvx_problem,
|
|
34
|
+
solve_lasso_cvx_problem,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
38
|
+
# Fixtures
|
|
39
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.fixture
|
|
43
|
+
def hcgl_panel():
|
|
44
|
+
"""Multi-response panel with block correlation structure suitable for HCGL."""
|
|
45
|
+
rng = np.random.default_rng(42)
|
|
46
|
+
T, M, N, n_clusters = 300, 6, 30, 5
|
|
47
|
+
X = rng.standard_normal((T, M))
|
|
48
|
+
cluster_of = rng.integers(0, n_clusters, N)
|
|
49
|
+
beta_true = np.zeros((N, M))
|
|
50
|
+
for c in range(n_clusters):
|
|
51
|
+
mask = cluster_of == c
|
|
52
|
+
factors = rng.choice(M, size=2, replace=False)
|
|
53
|
+
for f in factors:
|
|
54
|
+
beta_true[mask, f] = rng.normal(0.5, 0.15, mask.sum())
|
|
55
|
+
Y = X @ beta_true.T + 0.1 * rng.standard_normal((T, N))
|
|
56
|
+
for c in range(n_clusters):
|
|
57
|
+
mask = cluster_of == c
|
|
58
|
+
shared = rng.standard_normal(T)
|
|
59
|
+
for i in np.where(mask)[0]:
|
|
60
|
+
Y[:, i] = 0.3 * shared + 0.7 * Y[:, i]
|
|
61
|
+
X_df = pd.DataFrame(X, columns=[f"f{i}" for i in range(M)])
|
|
62
|
+
Y_df = pd.DataFrame(Y, columns=[f"y{i}" for i in range(N)])
|
|
63
|
+
return X_df, Y_df
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.fixture
|
|
67
|
+
def hcgl_prepped(hcgl_panel):
|
|
68
|
+
"""Pre-computed HCGL inputs: demeaned x/y, valid_mask, clusters, group loadings."""
|
|
69
|
+
X, Y = hcgl_panel
|
|
70
|
+
x_np, y_np, mask = get_x_y_np(x=X, y=Y, span=60, demean=True)
|
|
71
|
+
corr = compute_ewm_covar(a=y_np, span=60, is_corr=True)
|
|
72
|
+
corr_df = pd.DataFrame(corr, index=Y.columns, columns=Y.columns)
|
|
73
|
+
clusters, _, _ = compute_clusters_from_corr_matrix(corr_df)
|
|
74
|
+
gl = set_group_loadings(group_data=clusters).to_numpy()
|
|
75
|
+
return {
|
|
76
|
+
"X": X, "Y": Y,
|
|
77
|
+
"x_np": x_np, "y_np": y_np, "mask": mask,
|
|
78
|
+
"clusters": clusters, "group_loadings": gl,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.fixture
|
|
83
|
+
def sparsity_panel():
|
|
84
|
+
"""
|
|
85
|
+
Adversarial panel: each HCGL cluster contains a few genuine-loading
|
|
86
|
+
assets and several pure-noise assets that cluster together via a
|
|
87
|
+
shared idiosyncratic shock. Tests whether l1_weight > 0 zeros out
|
|
88
|
+
the noise-asset loadings.
|
|
89
|
+
"""
|
|
90
|
+
rng = np.random.default_rng(0)
|
|
91
|
+
T, M, N = 400, 5, 20
|
|
92
|
+
X = rng.standard_normal((T, M))
|
|
93
|
+
beta_true = np.zeros((N, M))
|
|
94
|
+
# First 3 assets of each half load on a factor; rest are pure noise
|
|
95
|
+
beta_true[:3, 0] = rng.normal(0.8, 0.05, 3)
|
|
96
|
+
beta_true[10:13, 1] = rng.normal(0.8, 0.05, 3)
|
|
97
|
+
Y = X @ beta_true.T + 0.1 * rng.standard_normal((T, N))
|
|
98
|
+
# Shared idiosyncratic shock within each half drives clustering
|
|
99
|
+
shared0 = rng.standard_normal(T)
|
|
100
|
+
shared1 = rng.standard_normal(T)
|
|
101
|
+
for i in range(10):
|
|
102
|
+
Y[:, i] += 0.3 * shared0
|
|
103
|
+
for i in range(10, 20):
|
|
104
|
+
Y[:, i] += 0.3 * shared1
|
|
105
|
+
X_df = pd.DataFrame(X, columns=[f"f{i}" for i in range(M)])
|
|
106
|
+
Y_df = pd.DataFrame(Y, columns=[f"y{i}" for i in range(N)])
|
|
107
|
+
# True-zero mask (which cells SHOULD be zero in β)
|
|
108
|
+
noise_mask = np.ones((N, M), dtype=bool)
|
|
109
|
+
noise_mask[:3, 0] = False # signal
|
|
110
|
+
noise_mask[10:13, 1] = False # signal
|
|
111
|
+
return X_df, Y_df, noise_mask
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
115
|
+
# Backward compatibility
|
|
116
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestBackwardCompatibility:
|
|
120
|
+
"""Default behaviour must be bit-identical to pre-l1_weight code."""
|
|
121
|
+
|
|
122
|
+
def test_solver_default_vs_explicit_zero(self, hcgl_prepped):
|
|
123
|
+
"""solve_group_lasso_cvx_problem: no kwarg ≡ l1_weight=0.0."""
|
|
124
|
+
p = hcgl_prepped
|
|
125
|
+
res_default = solve_group_lasso_cvx_problem(
|
|
126
|
+
x=p["x_np"], y=p["y_np"], group_loadings=p["group_loadings"],
|
|
127
|
+
valid_mask=p["mask"], reg_lambda=1e-4, span=60,
|
|
128
|
+
)
|
|
129
|
+
res_zero = solve_group_lasso_cvx_problem(
|
|
130
|
+
x=p["x_np"], y=p["y_np"], group_loadings=p["group_loadings"],
|
|
131
|
+
valid_mask=p["mask"], reg_lambda=1e-4, span=60,
|
|
132
|
+
l1_weight=0.0,
|
|
133
|
+
)
|
|
134
|
+
delta = np.abs(res_default.estimated_beta - res_zero.estimated_beta)
|
|
135
|
+
assert delta.max() == 0.0, (
|
|
136
|
+
f"Backward compat broken at solver level: Max |Δβ|={delta.max():.2e}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def test_lassomodel_default_vs_explicit_zero(self, hcgl_panel):
|
|
140
|
+
"""LassoModel: no kwarg ≡ l1_weight=0.0."""
|
|
141
|
+
X, Y = hcgl_panel
|
|
142
|
+
m_default = LassoModel(
|
|
143
|
+
model_type=LassoModelType.GROUP_LASSO_CLUSTERS,
|
|
144
|
+
reg_lambda=1e-4, span=60,
|
|
145
|
+
).fit(x=X, y=Y)
|
|
146
|
+
m_zero = LassoModel(
|
|
147
|
+
model_type=LassoModelType.GROUP_LASSO_CLUSTERS,
|
|
148
|
+
reg_lambda=1e-4, span=60, l1_weight=0.0,
|
|
149
|
+
).fit(x=X, y=Y)
|
|
150
|
+
delta = (m_default.coef_ - m_zero.coef_).abs().values.max()
|
|
151
|
+
assert delta == 0.0, (
|
|
152
|
+
f"Backward compat broken at LassoModel level: Max |Δβ|={delta:.2e}"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def test_lasso_mode_ignores_l1_weight(self, hcgl_panel):
|
|
156
|
+
"""Docstring: 'ignored for pure LASSO since L1 is the only penalty already.'"""
|
|
157
|
+
X, Y = hcgl_panel
|
|
158
|
+
m0 = LassoModel(
|
|
159
|
+
model_type=LassoModelType.LASSO, reg_lambda=1e-4, span=60,
|
|
160
|
+
).fit(x=X, y=Y)
|
|
161
|
+
m5 = LassoModel(
|
|
162
|
+
model_type=LassoModelType.LASSO, reg_lambda=1e-4, span=60,
|
|
163
|
+
l1_weight=0.5,
|
|
164
|
+
).fit(x=X, y=Y)
|
|
165
|
+
delta = (m0.coef_ - m5.coef_).abs().values.max()
|
|
166
|
+
assert delta == 0.0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
170
|
+
# Input validation
|
|
171
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class TestValidation:
|
|
175
|
+
"""l1_weight must be rejected outside [0, 1] at construction and solver."""
|
|
176
|
+
|
|
177
|
+
@pytest.mark.parametrize("bad", [-0.1, -1e-9, 1.001, 2.0, np.nan, np.inf, -np.inf])
|
|
178
|
+
def test_lassomodel_rejects_bad_values(self, bad):
|
|
179
|
+
with pytest.raises(ValueError, match="l1_weight must lie in"):
|
|
180
|
+
LassoModel(l1_weight=bad)
|
|
181
|
+
|
|
182
|
+
@pytest.mark.parametrize("good", [0.0, 1e-9, 0.5, 1.0 - 1e-9, 1.0])
|
|
183
|
+
def test_lassomodel_accepts_good_values(self, good):
|
|
184
|
+
# Should not raise
|
|
185
|
+
m = LassoModel(l1_weight=good)
|
|
186
|
+
assert m.l1_weight == good
|
|
187
|
+
|
|
188
|
+
def test_solver_rejects_bad_values(self, hcgl_prepped):
|
|
189
|
+
p = hcgl_prepped
|
|
190
|
+
with pytest.raises(ValueError, match="l1_weight must lie in"):
|
|
191
|
+
solve_group_lasso_cvx_problem(
|
|
192
|
+
x=p["x_np"], y=p["y_np"], group_loadings=p["group_loadings"],
|
|
193
|
+
valid_mask=p["mask"], reg_lambda=1e-4, span=60,
|
|
194
|
+
l1_weight=1.5,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def test_get_params_includes_l1_weight(self):
|
|
198
|
+
params = LassoModel(l1_weight=0.25).get_params()
|
|
199
|
+
assert params["l1_weight"] == 0.25
|
|
200
|
+
|
|
201
|
+
def test_set_params_updates_l1_weight(self):
|
|
202
|
+
m = LassoModel()
|
|
203
|
+
assert m.l1_weight == 0.0
|
|
204
|
+
m.set_params(l1_weight=0.15)
|
|
205
|
+
assert m.l1_weight == 0.15
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
209
|
+
# Mathematical limits
|
|
210
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class TestLimits:
|
|
214
|
+
"""Verify α=0 and α=1 endpoints match their algebraic limits."""
|
|
215
|
+
|
|
216
|
+
def test_alpha_one_approximates_pure_lasso(self, hcgl_prepped):
|
|
217
|
+
"""
|
|
218
|
+
At α=1 the group term has coefficient 0, leaving only elementwise L1.
|
|
219
|
+
Result must match a pure-LASSO solve at the same λ, up to CVXPY
|
|
220
|
+
solver precision. The docstring claim 'Set α = 1 for pure LASSO
|
|
221
|
+
(no group structure)' is verified here.
|
|
222
|
+
"""
|
|
223
|
+
p = hcgl_prepped
|
|
224
|
+
res_alpha1 = solve_group_lasso_cvx_problem(
|
|
225
|
+
x=p["x_np"], y=p["y_np"], group_loadings=p["group_loadings"],
|
|
226
|
+
valid_mask=p["mask"], reg_lambda=3e-4, span=60,
|
|
227
|
+
l1_weight=1.0,
|
|
228
|
+
)
|
|
229
|
+
res_pure = solve_lasso_cvx_problem(
|
|
230
|
+
x=p["x_np"], y=p["y_np"], valid_mask=p["mask"],
|
|
231
|
+
reg_lambda=3e-4, span=60,
|
|
232
|
+
)
|
|
233
|
+
delta = np.abs(res_alpha1.estimated_beta - res_pure.estimated_beta)
|
|
234
|
+
# Solver-precision tolerance — CLARABEL interior-point typically
|
|
235
|
+
# converges to ~1e-6 relative accuracy. The two problems are
|
|
236
|
+
# algebraically identical; differences are pure numerical noise.
|
|
237
|
+
assert delta.max() < 1e-4, (
|
|
238
|
+
f"α=1 should match pure LASSO; got Max |Δβ|={delta.max():.3e}"
|
|
239
|
+
)
|
|
240
|
+
assert delta.mean() < 1e-5
|
|
241
|
+
|
|
242
|
+
def test_alpha_zero_ignores_group_penalty_choice_noop(self, hcgl_prepped):
|
|
243
|
+
"""
|
|
244
|
+
At α=0 only the group term is active. Verified by TestInteraction
|
|
245
|
+
below that the group_penalty choice matters here. Sanity check:
|
|
246
|
+
α=0 with either scheme still gives the expected normalization
|
|
247
|
+
default behaviour.
|
|
248
|
+
"""
|
|
249
|
+
p = hcgl_prepped
|
|
250
|
+
res_norm = solve_group_lasso_cvx_problem(
|
|
251
|
+
x=p["x_np"], y=p["y_np"], group_loadings=p["group_loadings"],
|
|
252
|
+
valid_mask=p["mask"], reg_lambda=1e-4, span=60,
|
|
253
|
+
l1_weight=0.0, group_penalty="normalized",
|
|
254
|
+
)
|
|
255
|
+
# β must be finite, non-trivial
|
|
256
|
+
assert np.isfinite(res_norm.estimated_beta).all()
|
|
257
|
+
assert np.linalg.norm(res_norm.estimated_beta) > 0.1
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
261
|
+
# Interaction with group_penalty
|
|
262
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class TestInteraction:
|
|
266
|
+
"""l1_weight × group_penalty: group-term scaling is irrelevant at α=1."""
|
|
267
|
+
|
|
268
|
+
def test_group_penalty_irrelevant_at_alpha_one(self, hcgl_prepped):
|
|
269
|
+
"""At α=1 the group term has coefficient 0 → group_penalty choice is moot."""
|
|
270
|
+
p = hcgl_prepped
|
|
271
|
+
res_norm = solve_group_lasso_cvx_problem(
|
|
272
|
+
x=p["x_np"], y=p["y_np"], group_loadings=p["group_loadings"],
|
|
273
|
+
valid_mask=p["mask"], reg_lambda=3e-4, span=60,
|
|
274
|
+
l1_weight=1.0, group_penalty="normalized",
|
|
275
|
+
)
|
|
276
|
+
res_yl = solve_group_lasso_cvx_problem(
|
|
277
|
+
x=p["x_np"], y=p["y_np"], group_loadings=p["group_loadings"],
|
|
278
|
+
valid_mask=p["mask"], reg_lambda=3e-4, span=60,
|
|
279
|
+
l1_weight=1.0, group_penalty="yuan_lin",
|
|
280
|
+
)
|
|
281
|
+
delta = np.abs(res_norm.estimated_beta - res_yl.estimated_beta)
|
|
282
|
+
assert delta.max() == 0.0, (
|
|
283
|
+
f"group_penalty should be no-op at α=1; Max |Δβ|={delta.max():.2e}"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def test_group_penalty_matters_in_middle(self, hcgl_prepped):
|
|
287
|
+
"""At 0<α<1 both terms active → normalized and yuan_lin diverge."""
|
|
288
|
+
p = hcgl_prepped
|
|
289
|
+
res_norm = solve_group_lasso_cvx_problem(
|
|
290
|
+
x=p["x_np"], y=p["y_np"], group_loadings=p["group_loadings"],
|
|
291
|
+
valid_mask=p["mask"], reg_lambda=3e-4, span=60,
|
|
292
|
+
l1_weight=0.5, group_penalty="normalized",
|
|
293
|
+
)
|
|
294
|
+
res_yl = solve_group_lasso_cvx_problem(
|
|
295
|
+
x=p["x_np"], y=p["y_np"], group_loadings=p["group_loadings"],
|
|
296
|
+
valid_mask=p["mask"], reg_lambda=3e-4, span=60,
|
|
297
|
+
l1_weight=0.5, group_penalty="yuan_lin",
|
|
298
|
+
)
|
|
299
|
+
delta = np.abs(res_norm.estimated_beta - res_yl.estimated_beta)
|
|
300
|
+
assert delta.max() > 1e-5, (
|
|
301
|
+
"group_penalty should matter when 0<α<1"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
306
|
+
# Group-then-prune — the whole point of the feature
|
|
307
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class TestSparsityProgression:
|
|
311
|
+
"""
|
|
312
|
+
The README's claim: increasing α zeros within-group noisy β.
|
|
313
|
+
This is the feature's value proposition; if it doesn't hold,
|
|
314
|
+
l1_weight is cosmetic.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
def test_spurious_mass_decreases_monotonically_with_alpha(
|
|
318
|
+
self, sparsity_panel,
|
|
319
|
+
):
|
|
320
|
+
X, Y, noise_mask = sparsity_panel
|
|
321
|
+
x_np, y_np, mask = get_x_y_np(x=X, y=Y, span=60, demean=True)
|
|
322
|
+
corr = compute_ewm_covar(a=y_np, span=60, is_corr=True)
|
|
323
|
+
clusters, _, _ = compute_clusters_from_corr_matrix(
|
|
324
|
+
pd.DataFrame(corr, index=Y.columns, columns=Y.columns)
|
|
325
|
+
)
|
|
326
|
+
gl = set_group_loadings(group_data=clusters).to_numpy()
|
|
327
|
+
|
|
328
|
+
alphas = [0.0, 0.1, 0.3, 0.5, 0.8]
|
|
329
|
+
spurious_masses = []
|
|
330
|
+
for a in alphas:
|
|
331
|
+
res = solve_group_lasso_cvx_problem(
|
|
332
|
+
x=x_np, y=y_np, group_loadings=gl,
|
|
333
|
+
valid_mask=mask, reg_lambda=5e-3, span=60,
|
|
334
|
+
l1_weight=a,
|
|
335
|
+
)
|
|
336
|
+
spurious = float(np.abs(res.estimated_beta[noise_mask]).sum())
|
|
337
|
+
spurious_masses.append(spurious)
|
|
338
|
+
|
|
339
|
+
# Non-increasing: L1 shrinkage should only reduce spurious loadings
|
|
340
|
+
for i in range(1, len(alphas)):
|
|
341
|
+
assert spurious_masses[i] <= spurious_masses[i - 1] + 1e-6, (
|
|
342
|
+
f"Spurious β mass increased from α={alphas[i-1]} "
|
|
343
|
+
f"({spurious_masses[i-1]:.4f}) to α={alphas[i]} "
|
|
344
|
+
f"({spurious_masses[i]:.4f})"
|
|
345
|
+
)
|
|
346
|
+
# And meaningfully so: at least 2x reduction from α=0 to α=0.8
|
|
347
|
+
assert spurious_masses[-1] < 0.5 * spurious_masses[0], (
|
|
348
|
+
f"L1 pruning did not fire: "
|
|
349
|
+
f"α=0 mass={spurious_masses[0]:.4f}, "
|
|
350
|
+
f"α=0.8 mass={spurious_masses[-1]:.4f}"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def test_signal_mass_preserved(self, sparsity_panel):
|
|
354
|
+
"""
|
|
355
|
+
At moderate α (research range up to 0.3) the genuine-signal β
|
|
356
|
+
must survive — otherwise L1 is destroying information, not
|
|
357
|
+
cleaning noise.
|
|
358
|
+
"""
|
|
359
|
+
X, Y, noise_mask = sparsity_panel
|
|
360
|
+
signal_mask = ~noise_mask
|
|
361
|
+
x_np, y_np, mask = get_x_y_np(x=X, y=Y, span=60, demean=True)
|
|
362
|
+
corr = compute_ewm_covar(a=y_np, span=60, is_corr=True)
|
|
363
|
+
clusters, _, _ = compute_clusters_from_corr_matrix(
|
|
364
|
+
pd.DataFrame(corr, index=Y.columns, columns=Y.columns)
|
|
365
|
+
)
|
|
366
|
+
gl = set_group_loadings(group_data=clusters).to_numpy()
|
|
367
|
+
|
|
368
|
+
signal_masses = {}
|
|
369
|
+
for a in [0.0, 0.1, 0.3]:
|
|
370
|
+
res = solve_group_lasso_cvx_problem(
|
|
371
|
+
x=x_np, y=y_np, group_loadings=gl,
|
|
372
|
+
valid_mask=mask, reg_lambda=5e-3, span=60,
|
|
373
|
+
l1_weight=a,
|
|
374
|
+
)
|
|
375
|
+
signal_masses[a] = float(np.abs(res.estimated_beta[signal_mask]).sum())
|
|
376
|
+
|
|
377
|
+
# Signal mass must stay within 10% of the α=0 baseline over the
|
|
378
|
+
# research range. If the L1 term hammers the signal too, the
|
|
379
|
+
# feature isn't useful.
|
|
380
|
+
baseline = signal_masses[0.0]
|
|
381
|
+
for a in [0.1, 0.3]:
|
|
382
|
+
ratio = signal_masses[a] / baseline
|
|
383
|
+
assert ratio > 0.9, (
|
|
384
|
+
f"Signal mass dropped too much at α={a}: "
|
|
385
|
+
f"{signal_masses[a]:.4f} / {baseline:.4f} = {ratio:.2f}"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
390
|
+
# Composition with other features
|
|
391
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class TestComposition:
|
|
395
|
+
"""l1_weight composes with sign constraints and prior centering."""
|
|
396
|
+
|
|
397
|
+
def test_sign_constraints_respected_at_all_alpha(self):
|
|
398
|
+
"""
|
|
399
|
+
Sign constraints must hold regardless of l1_weight. We only test
|
|
400
|
+
non-negative and zero constraints — the non-positive constraint
|
|
401
|
+
has a known fragility to solver precision when the optimum lies
|
|
402
|
+
at zero, which is orthogonal to l1_weight.
|
|
403
|
+
"""
|
|
404
|
+
rng = np.random.default_rng(7)
|
|
405
|
+
T, M, N = 200, 4, 10
|
|
406
|
+
X = pd.DataFrame(
|
|
407
|
+
rng.standard_normal((T, M)),
|
|
408
|
+
columns=[f"f{i}" for i in range(M)],
|
|
409
|
+
)
|
|
410
|
+
beta_true = rng.standard_normal((N, M))
|
|
411
|
+
# Make y0 strongly non-negative on f0
|
|
412
|
+
beta_true[0, 0] = 1.5
|
|
413
|
+
Y = pd.DataFrame(
|
|
414
|
+
X.values @ beta_true.T + 0.1 * rng.standard_normal((T, N)),
|
|
415
|
+
columns=[f"y{i}" for i in range(N)],
|
|
416
|
+
)
|
|
417
|
+
group_data = pd.Series(rng.integers(0, 3, N), index=Y.columns)
|
|
418
|
+
signs = pd.DataFrame(np.nan, index=Y.columns, columns=X.columns)
|
|
419
|
+
signs.loc["y0", "f0"] = 1 # non-negative
|
|
420
|
+
signs.loc["y2", "f0"] = 0 # zero
|
|
421
|
+
|
|
422
|
+
for alpha in [0.0, 0.1, 0.3, 0.8, 1.0]:
|
|
423
|
+
m = LassoModel(
|
|
424
|
+
model_type=LassoModelType.GROUP_LASSO,
|
|
425
|
+
group_data=group_data,
|
|
426
|
+
reg_lambda=3e-4, span=60,
|
|
427
|
+
factors_beta_loading_signs=signs,
|
|
428
|
+
l1_weight=alpha,
|
|
429
|
+
).fit(x=X, y=Y)
|
|
430
|
+
tol = 1e-6
|
|
431
|
+
assert m.coef_.loc["y0", "f0"] >= -tol, (
|
|
432
|
+
f"Non-negative violated at α={alpha}: "
|
|
433
|
+
f"β[y0,f0]={m.coef_.loc['y0', 'f0']}"
|
|
434
|
+
)
|
|
435
|
+
assert abs(m.coef_.loc["y2", "f0"]) < tol, (
|
|
436
|
+
f"Zero-constraint violated at α={alpha}: "
|
|
437
|
+
f"β[y2,f0]={m.coef_.loc['y2', 'f0']}"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
def test_prior_centering_applied_to_l1_term(self, hcgl_prepped):
|
|
441
|
+
"""
|
|
442
|
+
The L1 penalty is |β - β₀|, not |β|. With a large α and a
|
|
443
|
+
non-zero prior, β should shrink toward the prior, not toward
|
|
444
|
+
zero. Tests the '|β - β₀|_1' form from the docstring.
|
|
445
|
+
"""
|
|
446
|
+
p = hcgl_prepped
|
|
447
|
+
N, M = p["group_loadings"].shape[0], p["x_np"].shape[1]
|
|
448
|
+
# Pick a non-zero prior
|
|
449
|
+
prior = np.full((N, M), 0.3)
|
|
450
|
+
|
|
451
|
+
# Two solves: one with prior, one without, both at α=1 (pure L1)
|
|
452
|
+
# and huge λ (strong shrinkage).
|
|
453
|
+
res_with_prior = solve_group_lasso_cvx_problem(
|
|
454
|
+
x=p["x_np"], y=p["y_np"], group_loadings=p["group_loadings"],
|
|
455
|
+
valid_mask=p["mask"], reg_lambda=1.0, span=60,
|
|
456
|
+
l1_weight=1.0,
|
|
457
|
+
factors_beta_prior=prior,
|
|
458
|
+
)
|
|
459
|
+
res_no_prior = solve_group_lasso_cvx_problem(
|
|
460
|
+
x=p["x_np"], y=p["y_np"], group_loadings=p["group_loadings"],
|
|
461
|
+
valid_mask=p["mask"], reg_lambda=1.0, span=60,
|
|
462
|
+
l1_weight=1.0,
|
|
463
|
+
)
|
|
464
|
+
# With a non-zero prior, the shrinkage target is 0.3; without,
|
|
465
|
+
# it's 0. Mean β should be meaningfully closer to the prior.
|
|
466
|
+
mean_with = float(res_with_prior.estimated_beta.mean())
|
|
467
|
+
mean_no = float(res_no_prior.estimated_beta.mean())
|
|
468
|
+
# Prior=0.3, no-prior shrinks toward 0 → mean_no ~ 0.
|
|
469
|
+
# With prior, shrinks toward 0.3 → mean_with > mean_no.
|
|
470
|
+
assert mean_with > mean_no + 0.05, (
|
|
471
|
+
f"Prior centering not honoured by L1 term: "
|
|
472
|
+
f"mean_with_prior={mean_with:.3f} vs mean_no_prior={mean_no:.3f}"
|
|
473
|
+
)
|
|
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
|
|
File without changes
|