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.
Files changed (24) hide show
  1. {factorlasso-0.3.2 → factorlasso-0.3.4}/PKG-INFO +56 -3
  2. {factorlasso-0.3.2 → factorlasso-0.3.4}/README.md +55 -2
  3. {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso/lasso_estimator.py +162 -9
  4. {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso.egg-info/PKG-INFO +56 -3
  5. {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso.egg-info/SOURCES.txt +1 -0
  6. {factorlasso-0.3.2 → factorlasso-0.3.4}/pyproject.toml +1 -1
  7. factorlasso-0.3.4/tests/test_l1_weight.py +473 -0
  8. {factorlasso-0.3.2 → factorlasso-0.3.4}/LICENSE +0 -0
  9. {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso/__init__.py +0 -0
  10. {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso/cluster_utils.py +0 -0
  11. {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso/cv.py +0 -0
  12. {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso/ewm_utils.py +0 -0
  13. {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso/factor_covar.py +0 -0
  14. {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso/py.typed +0 -0
  15. {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso.egg-info/dependency_links.txt +0 -0
  16. {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso.egg-info/requires.txt +0 -0
  17. {factorlasso-0.3.2 → factorlasso-0.3.4}/factorlasso.egg-info/top_level.txt +0 -0
  18. {factorlasso-0.3.2 → factorlasso-0.3.4}/setup.cfg +0 -0
  19. {factorlasso-0.3.2 → factorlasso-0.3.4}/tests/test_corr_zero_variance.py +0 -0
  20. {factorlasso-0.3.2 → factorlasso-0.3.4}/tests/test_coverage_extras.py +0 -0
  21. {factorlasso-0.3.2 → factorlasso-0.3.4}/tests/test_external_parity.py +0 -0
  22. {factorlasso-0.3.2 → factorlasso-0.3.4}/tests/test_factorlasso.py +0 -0
  23. {factorlasso-0.3.2 → factorlasso-0.3.4}/tests/test_integration.py +0 -0
  24. {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.2
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 hierarchical group LASSO — via CVXPY.**
48
+ regularisation, hierarchical group LASSO, and sparse group LASSO — via CVXPY.**
49
49
 
50
50
  [![PyPI](https://img.shields.io/pypi/v/factorlasso.svg)](https://pypi.org/project/factorlasso/)
51
51
  [![Python](https://img.shields.io/pypi/pyversions/factorlasso.svg)](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 three things matter:
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 hierarchical group LASSO — via CVXPY.**
4
+ regularisation, hierarchical group LASSO, and sparse group LASSO — via CVXPY.**
5
5
 
6
6
  [![PyPI](https://img.shields.io/pypi/v/factorlasso.svg)](https://pypi.org/project/factorlasso/)
7
7
  [![Python](https://img.shields.io/pypi/pyversions/factorlasso.svg)](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 three things matter:
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 residual per response variable.
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 submatrix —
392
- each response's loading vector is shrunk by an L2 norm, and block
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
- penalty = cvx.sum([
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
- Estimated intercept α.
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.2
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 hierarchical group LASSO — via CVXPY.**
48
+ regularisation, hierarchical group LASSO, and sparse group LASSO — via CVXPY.**
49
49
 
50
50
  [![PyPI](https://img.shields.io/pypi/v/factorlasso.svg)](https://pypi.org/project/factorlasso/)
51
51
  [![Python](https://img.shields.io/pypi/pyversions/factorlasso.svg)](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 three things matter:
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
@@ -18,4 +18,5 @@ tests/test_coverage_extras.py
18
18
  tests/test_external_parity.py
19
19
  tests/test_factorlasso.py
20
20
  tests/test_integration.py
21
+ tests/test_l1_weight.py
21
22
  tests/test_new_features.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "factorlasso"
7
- version = "0.3.2"
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