factorlasso 0.2.2__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- factorlasso-0.3.0/PKG-INFO +269 -0
- factorlasso-0.3.0/README.md +225 -0
- {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso/cluster_utils.py +37 -4
- {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso/cv.py +38 -4
- {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso/ewm_utils.py +95 -10
- {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso/factor_covar.py +21 -1
- {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso/lasso_estimator.py +81 -21
- factorlasso-0.3.0/factorlasso.egg-info/PKG-INFO +269 -0
- {factorlasso-0.2.2 → factorlasso-0.3.0}/pyproject.toml +2 -1
- factorlasso-0.2.2/PKG-INFO +0 -147
- factorlasso-0.2.2/README.md +0 -104
- factorlasso-0.2.2/factorlasso.egg-info/PKG-INFO +0 -147
- {factorlasso-0.2.2 → factorlasso-0.3.0}/LICENSE +0 -0
- {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso/__init__.py +0 -0
- {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso/py.typed +0 -0
- {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso.egg-info/SOURCES.txt +0 -0
- {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso.egg-info/dependency_links.txt +0 -0
- {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso.egg-info/requires.txt +0 -0
- {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso.egg-info/top_level.txt +0 -0
- {factorlasso-0.2.2 → factorlasso-0.3.0}/setup.cfg +0 -0
- {factorlasso-0.2.2 → factorlasso-0.3.0}/tests/test_corr_zero_variance.py +0 -0
- {factorlasso-0.2.2 → factorlasso-0.3.0}/tests/test_coverage_extras.py +0 -0
- {factorlasso-0.2.2 → factorlasso-0.3.0}/tests/test_external_parity.py +0 -0
- {factorlasso-0.2.2 → factorlasso-0.3.0}/tests/test_factorlasso.py +0 -0
- {factorlasso-0.2.2 → factorlasso-0.3.0}/tests/test_integration.py +0 -0
- {factorlasso-0.2.2 → factorlasso-0.3.0}/tests/test_new_features.py +0 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: factorlasso
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Sparse factor model estimation with sign-constrained LASSO, prior-centered regularisation, and hierarchical group LASSO (HCGL)
|
|
5
|
+
Author-email: Artur Sepp <artursepp@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ArturSepp/factorlasso
|
|
8
|
+
Project-URL: Documentation, https://factorlasso.readthedocs.io
|
|
9
|
+
Project-URL: Repository, https://github.com/ArturSepp/factorlasso
|
|
10
|
+
Project-URL: Issues, https://github.com/ArturSepp/factorlasso/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/ArturSepp/factorlasso/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: lasso,group-lasso,factor-model,penalized-regression,sparse-regression,sign-constraints,covariance-estimation,cvxpy,multi-output-regression
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
25
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
26
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
27
|
+
Requires-Python: >=3.9
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: numpy>=1.22
|
|
31
|
+
Requires-Dist: pandas>=1.4
|
|
32
|
+
Requires-Dist: scipy>=1.9
|
|
33
|
+
Requires-Dist: cvxpy>=1.3
|
|
34
|
+
Requires-Dist: openpyxl>=3.1.0
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
37
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
38
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
39
|
+
Provides-Extra: docs
|
|
40
|
+
Requires-Dist: sphinx>=6.0; extra == "docs"
|
|
41
|
+
Requires-Dist: sphinx-rtd-theme>=1.0; extra == "docs"
|
|
42
|
+
Requires-Dist: numpydoc>=1.5; extra == "docs"
|
|
43
|
+
Dynamic: license-file
|
|
44
|
+
|
|
45
|
+
# factorlasso
|
|
46
|
+
|
|
47
|
+
**Sparse multi-output regression with sign constraints, prior-centered
|
|
48
|
+
regularisation, and hierarchical group LASSO — via CVXPY.**
|
|
49
|
+
|
|
50
|
+
[](https://pypi.org/project/factorlasso/)
|
|
51
|
+
[](https://pypi.org/project/factorlasso/)
|
|
52
|
+
[](LICENSE)
|
|
53
|
+
|
|
54
|
+
`factorlasso` is a small, dependency-light Python package for fitting sparse
|
|
55
|
+
multi-output linear models
|
|
56
|
+
|
|
57
|
+
$$
|
|
58
|
+
Y = X\beta^\top + \varepsilon,
|
|
59
|
+
\qquad \beta \in \mathbb{R}^{N \times M}
|
|
60
|
+
$$
|
|
61
|
+
|
|
62
|
+
when three things matter:
|
|
63
|
+
|
|
64
|
+
- Some coefficients **must be zero, non-negative, or non-positive**, possibly by
|
|
65
|
+
asset, by factor, or both.
|
|
66
|
+
- You have a **prior** β₀ and want to penalise `‖β − β₀‖`, not `‖β‖`.
|
|
67
|
+
- You want **structured sparsity** — groups of responses entering or leaving
|
|
68
|
+
the model together — where the groups are either user-supplied or discovered
|
|
69
|
+
by hierarchical clustering of the response correlation matrix (HCGL).
|
|
70
|
+
|
|
71
|
+
It is written in pure numpy/pandas/scipy/cvxpy. No numba, no custom
|
|
72
|
+
coordinate descent. The solver is CVXPY (default `CLARABEL`), so problem
|
|
73
|
+
formulation is explicit and auditable.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Installation
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install factorlasso
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Requires Python ≥ 3.9, CVXPY ≥ 1.3, and numpy / pandas / scipy / openpyxl.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Quickstart
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
import numpy as np
|
|
91
|
+
import pandas as pd
|
|
92
|
+
from factorlasso import LassoModel, LassoModelType
|
|
93
|
+
|
|
94
|
+
rng = np.random.default_rng(0)
|
|
95
|
+
T, M, N = 200, 4, 10
|
|
96
|
+
X = pd.DataFrame(rng.standard_normal((T, M)), columns=[f"f{i}" for i in range(M)])
|
|
97
|
+
Y = pd.DataFrame(rng.standard_normal((T, N)), columns=[f"y{i}" for i in range(N)])
|
|
98
|
+
|
|
99
|
+
model = LassoModel(model_type=LassoModelType.LASSO, reg_lambda=1e-3).fit(x=X, y=Y)
|
|
100
|
+
|
|
101
|
+
model.coef_ # (N, M) estimated β
|
|
102
|
+
model.intercept_ # (N,) estimated α
|
|
103
|
+
model.predict(X) # Ŷ
|
|
104
|
+
model.score(X, Y) # mean R²
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The API mirrors scikit-learn: `fit(x, y)`, `predict(x)`, `score(x, y)`,
|
|
108
|
+
`get_params()`, `set_params()`. Fitted attributes carry a trailing underscore.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## What makes it different
|
|
113
|
+
|
|
114
|
+
### 1. Per-element sign constraints
|
|
115
|
+
|
|
116
|
+
A `(N × M)` matrix drives the constraints. Each entry is one of
|
|
117
|
+
`{0, 1, -1, NaN}`: equality-to-zero, non-negative, non-positive, or free.
|
|
118
|
+
This lets a single fit encode structural knowledge that spans multiple
|
|
119
|
+
responses.
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
signs = pd.DataFrame(np.nan, index=Y.columns, columns=X.columns)
|
|
123
|
+
signs.loc["y0", "f0"] = 1 # β[y0, f0] ≥ 0
|
|
124
|
+
signs.loc["y0", "f1"] = 0 # β[y0, f1] == 0
|
|
125
|
+
signs.loc["y1", "f0"] = -1 # β[y1, f0] ≤ 0
|
|
126
|
+
|
|
127
|
+
model = LassoModel(
|
|
128
|
+
reg_lambda=1e-3,
|
|
129
|
+
factors_beta_loading_signs=signs,
|
|
130
|
+
).fit(x=X, y=Y)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Scikit-learn's `Lasso` supports only a single `positive` flag across the whole
|
|
134
|
+
coefficient matrix. Arbitrary per-element sign constraints are not expressible
|
|
135
|
+
without a custom CVXPY problem; this is that custom problem, packaged.
|
|
136
|
+
|
|
137
|
+
### 2. Prior-centered regularisation
|
|
138
|
+
|
|
139
|
+
Pass a `(N × M)` DataFrame `factors_beta_prior` to penalise `‖β − β₀‖` instead
|
|
140
|
+
of `‖β‖`. The prior is a soft target, not a hard constraint — the penalty
|
|
141
|
+
tension between data fit and prior is still controlled by `reg_lambda`.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
prior = 0.5 * np.sign(X.corrwith(Y["y0"]).to_numpy())
|
|
145
|
+
# ... build an (N, M) DataFrame `prior_df` with that structure ...
|
|
146
|
+
|
|
147
|
+
model = LassoModel(
|
|
148
|
+
reg_lambda=1e-3,
|
|
149
|
+
factors_beta_prior=prior_df,
|
|
150
|
+
).fit(x=X, y=Y)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### 3. Hierarchical Clustering Group LASSO (HCGL)
|
|
154
|
+
|
|
155
|
+
The groups in classical group LASSO are user-specified. HCGL discovers them
|
|
156
|
+
from the data: EWMA correlation of the response matrix → Ward's linkage →
|
|
157
|
+
dendrogram cut at `cutoff_fraction × max(pdist)` → block-sparse penalty on
|
|
158
|
+
the resulting clusters.
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
model = LassoModel(
|
|
162
|
+
model_type=LassoModelType.GROUP_LASSO_CLUSTERS,
|
|
163
|
+
reg_lambda=1e-4,
|
|
164
|
+
cutoff_fraction=0.5, # tune granularity; smaller → tighter clusters
|
|
165
|
+
span=60, # EWMA span for correlation estimate
|
|
166
|
+
).fit(x=X, y=Y)
|
|
167
|
+
|
|
168
|
+
model.coef_ # (N, M)
|
|
169
|
+
model.clusters_ # pd.Series of cluster labels per response
|
|
170
|
+
model.linkage_ # scipy linkage matrix
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Useful when you suspect group structure in the responses but don't know the
|
|
174
|
+
partition — or when the correct partition drifts over time, so any manual
|
|
175
|
+
grouping would need to be refit anyway.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## When to use it — and when not
|
|
180
|
+
|
|
181
|
+
**Use it when:**
|
|
182
|
+
|
|
183
|
+
- Multi-output LASSO with heterogeneous sign constraints across the coefficient
|
|
184
|
+
matrix.
|
|
185
|
+
- You have a prior `β₀` that should shrink the fit instead of zero.
|
|
186
|
+
- You need discovered-group structured sparsity (HCGL).
|
|
187
|
+
- You want a small, auditable CVXPY-based tool rather than a coordinate-descent
|
|
188
|
+
library with opaque internals.
|
|
189
|
+
|
|
190
|
+
**Reach for something else when:**
|
|
191
|
+
|
|
192
|
+
- Your problem is single-output elastic-net at large scale — `scikit-learn`,
|
|
193
|
+
`celer`, or `skglm` will be faster and have years of battle-testing.
|
|
194
|
+
- You need fixed-group group LASSO at very large scale — `group-lasso` or
|
|
195
|
+
`asgl` are the standard tools.
|
|
196
|
+
- You need non-linear models, random effects, or GLM link functions.
|
|
197
|
+
|
|
198
|
+
A feature-by-feature comparison matrix is in
|
|
199
|
+
[`COMPARISON.md`](COMPARISON.md).
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Examples
|
|
204
|
+
|
|
205
|
+
Three runnable examples in [`examples/`](examples/):
|
|
206
|
+
|
|
207
|
+
- [`genomics_factor_model.py`](examples/genomics_factor_model.py) —
|
|
208
|
+
QTL-style multi-response LASSO: genotype matrix → expression panel, with
|
|
209
|
+
sign constraints derived from biological priors.
|
|
210
|
+
- [`finance_factor_model.py`](examples/finance_factor_model.py) —
|
|
211
|
+
Multi-asset factor decomposition with sign constraints and HCGL clustering.
|
|
212
|
+
- [`cv_lambda_selection.py`](examples/cv_lambda_selection.py) —
|
|
213
|
+
Time-series cross-validated `reg_lambda` selection via `LassoModelCV` with
|
|
214
|
+
expanding-window splits.
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Testing
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
pip install -e ".[dev]"
|
|
222
|
+
pytest
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The suite currently has 201 tests at 98%+ coverage, including numerical parity
|
|
226
|
+
tests against `qis` for the EWMA primitives and against `scikit-learn` for the
|
|
227
|
+
LASSO path.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Citation
|
|
232
|
+
|
|
233
|
+
If you use `factorlasso` in academic work, please cite:
|
|
234
|
+
|
|
235
|
+
```bibtex
|
|
236
|
+
@article{SeppOssaKastenholz2026,
|
|
237
|
+
author = {Sepp, Artur and Ossa, Ivan and Kastenholz, Mika},
|
|
238
|
+
title = {Robust Optimization of Strategic and Tactical Asset Allocation
|
|
239
|
+
for Multi-Asset Portfolios},
|
|
240
|
+
journal = {The Journal of Portfolio Management},
|
|
241
|
+
year = {2026},
|
|
242
|
+
volume = {52},
|
|
243
|
+
number = {4},
|
|
244
|
+
pages = {86--120},
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
@software{factorlasso,
|
|
248
|
+
author = {Sepp, Artur},
|
|
249
|
+
title = {factorlasso: Sparse Factor Model Estimation with Constrained LASSO
|
|
250
|
+
in Python},
|
|
251
|
+
year = {2026},
|
|
252
|
+
url = {https://github.com/ArturSepp/factorlasso},
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Contributing & feedback
|
|
259
|
+
|
|
260
|
+
Issues and pull requests welcome at
|
|
261
|
+
<https://github.com/ArturSepp/factorlasso>.
|
|
262
|
+
|
|
263
|
+
See [`CHANGELOG.md`](CHANGELOG.md) for release history.
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## License
|
|
268
|
+
|
|
269
|
+
MIT — see [`LICENSE`](LICENSE).
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# factorlasso
|
|
2
|
+
|
|
3
|
+
**Sparse multi-output regression with sign constraints, prior-centered
|
|
4
|
+
regularisation, and hierarchical group LASSO — via CVXPY.**
|
|
5
|
+
|
|
6
|
+
[](https://pypi.org/project/factorlasso/)
|
|
7
|
+
[](https://pypi.org/project/factorlasso/)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
|
|
10
|
+
`factorlasso` is a small, dependency-light Python package for fitting sparse
|
|
11
|
+
multi-output linear models
|
|
12
|
+
|
|
13
|
+
$$
|
|
14
|
+
Y = X\beta^\top + \varepsilon,
|
|
15
|
+
\qquad \beta \in \mathbb{R}^{N \times M}
|
|
16
|
+
$$
|
|
17
|
+
|
|
18
|
+
when three things matter:
|
|
19
|
+
|
|
20
|
+
- Some coefficients **must be zero, non-negative, or non-positive**, possibly by
|
|
21
|
+
asset, by factor, or both.
|
|
22
|
+
- You have a **prior** β₀ and want to penalise `‖β − β₀‖`, not `‖β‖`.
|
|
23
|
+
- You want **structured sparsity** — groups of responses entering or leaving
|
|
24
|
+
the model together — where the groups are either user-supplied or discovered
|
|
25
|
+
by hierarchical clustering of the response correlation matrix (HCGL).
|
|
26
|
+
|
|
27
|
+
It is written in pure numpy/pandas/scipy/cvxpy. No numba, no custom
|
|
28
|
+
coordinate descent. The solver is CVXPY (default `CLARABEL`), so problem
|
|
29
|
+
formulation is explicit and auditable.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install factorlasso
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Requires Python ≥ 3.9, CVXPY ≥ 1.3, and numpy / pandas / scipy / openpyxl.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Quickstart
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import numpy as np
|
|
47
|
+
import pandas as pd
|
|
48
|
+
from factorlasso import LassoModel, LassoModelType
|
|
49
|
+
|
|
50
|
+
rng = np.random.default_rng(0)
|
|
51
|
+
T, M, N = 200, 4, 10
|
|
52
|
+
X = pd.DataFrame(rng.standard_normal((T, M)), columns=[f"f{i}" for i in range(M)])
|
|
53
|
+
Y = pd.DataFrame(rng.standard_normal((T, N)), columns=[f"y{i}" for i in range(N)])
|
|
54
|
+
|
|
55
|
+
model = LassoModel(model_type=LassoModelType.LASSO, reg_lambda=1e-3).fit(x=X, y=Y)
|
|
56
|
+
|
|
57
|
+
model.coef_ # (N, M) estimated β
|
|
58
|
+
model.intercept_ # (N,) estimated α
|
|
59
|
+
model.predict(X) # Ŷ
|
|
60
|
+
model.score(X, Y) # mean R²
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The API mirrors scikit-learn: `fit(x, y)`, `predict(x)`, `score(x, y)`,
|
|
64
|
+
`get_params()`, `set_params()`. Fitted attributes carry a trailing underscore.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## What makes it different
|
|
69
|
+
|
|
70
|
+
### 1. Per-element sign constraints
|
|
71
|
+
|
|
72
|
+
A `(N × M)` matrix drives the constraints. Each entry is one of
|
|
73
|
+
`{0, 1, -1, NaN}`: equality-to-zero, non-negative, non-positive, or free.
|
|
74
|
+
This lets a single fit encode structural knowledge that spans multiple
|
|
75
|
+
responses.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
signs = pd.DataFrame(np.nan, index=Y.columns, columns=X.columns)
|
|
79
|
+
signs.loc["y0", "f0"] = 1 # β[y0, f0] ≥ 0
|
|
80
|
+
signs.loc["y0", "f1"] = 0 # β[y0, f1] == 0
|
|
81
|
+
signs.loc["y1", "f0"] = -1 # β[y1, f0] ≤ 0
|
|
82
|
+
|
|
83
|
+
model = LassoModel(
|
|
84
|
+
reg_lambda=1e-3,
|
|
85
|
+
factors_beta_loading_signs=signs,
|
|
86
|
+
).fit(x=X, y=Y)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Scikit-learn's `Lasso` supports only a single `positive` flag across the whole
|
|
90
|
+
coefficient matrix. Arbitrary per-element sign constraints are not expressible
|
|
91
|
+
without a custom CVXPY problem; this is that custom problem, packaged.
|
|
92
|
+
|
|
93
|
+
### 2. Prior-centered regularisation
|
|
94
|
+
|
|
95
|
+
Pass a `(N × M)` DataFrame `factors_beta_prior` to penalise `‖β − β₀‖` instead
|
|
96
|
+
of `‖β‖`. The prior is a soft target, not a hard constraint — the penalty
|
|
97
|
+
tension between data fit and prior is still controlled by `reg_lambda`.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
prior = 0.5 * np.sign(X.corrwith(Y["y0"]).to_numpy())
|
|
101
|
+
# ... build an (N, M) DataFrame `prior_df` with that structure ...
|
|
102
|
+
|
|
103
|
+
model = LassoModel(
|
|
104
|
+
reg_lambda=1e-3,
|
|
105
|
+
factors_beta_prior=prior_df,
|
|
106
|
+
).fit(x=X, y=Y)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 3. Hierarchical Clustering Group LASSO (HCGL)
|
|
110
|
+
|
|
111
|
+
The groups in classical group LASSO are user-specified. HCGL discovers them
|
|
112
|
+
from the data: EWMA correlation of the response matrix → Ward's linkage →
|
|
113
|
+
dendrogram cut at `cutoff_fraction × max(pdist)` → block-sparse penalty on
|
|
114
|
+
the resulting clusters.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
model = LassoModel(
|
|
118
|
+
model_type=LassoModelType.GROUP_LASSO_CLUSTERS,
|
|
119
|
+
reg_lambda=1e-4,
|
|
120
|
+
cutoff_fraction=0.5, # tune granularity; smaller → tighter clusters
|
|
121
|
+
span=60, # EWMA span for correlation estimate
|
|
122
|
+
).fit(x=X, y=Y)
|
|
123
|
+
|
|
124
|
+
model.coef_ # (N, M)
|
|
125
|
+
model.clusters_ # pd.Series of cluster labels per response
|
|
126
|
+
model.linkage_ # scipy linkage matrix
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Useful when you suspect group structure in the responses but don't know the
|
|
130
|
+
partition — or when the correct partition drifts over time, so any manual
|
|
131
|
+
grouping would need to be refit anyway.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## When to use it — and when not
|
|
136
|
+
|
|
137
|
+
**Use it when:**
|
|
138
|
+
|
|
139
|
+
- Multi-output LASSO with heterogeneous sign constraints across the coefficient
|
|
140
|
+
matrix.
|
|
141
|
+
- You have a prior `β₀` that should shrink the fit instead of zero.
|
|
142
|
+
- You need discovered-group structured sparsity (HCGL).
|
|
143
|
+
- You want a small, auditable CVXPY-based tool rather than a coordinate-descent
|
|
144
|
+
library with opaque internals.
|
|
145
|
+
|
|
146
|
+
**Reach for something else when:**
|
|
147
|
+
|
|
148
|
+
- Your problem is single-output elastic-net at large scale — `scikit-learn`,
|
|
149
|
+
`celer`, or `skglm` will be faster and have years of battle-testing.
|
|
150
|
+
- You need fixed-group group LASSO at very large scale — `group-lasso` or
|
|
151
|
+
`asgl` are the standard tools.
|
|
152
|
+
- You need non-linear models, random effects, or GLM link functions.
|
|
153
|
+
|
|
154
|
+
A feature-by-feature comparison matrix is in
|
|
155
|
+
[`COMPARISON.md`](COMPARISON.md).
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Examples
|
|
160
|
+
|
|
161
|
+
Three runnable examples in [`examples/`](examples/):
|
|
162
|
+
|
|
163
|
+
- [`genomics_factor_model.py`](examples/genomics_factor_model.py) —
|
|
164
|
+
QTL-style multi-response LASSO: genotype matrix → expression panel, with
|
|
165
|
+
sign constraints derived from biological priors.
|
|
166
|
+
- [`finance_factor_model.py`](examples/finance_factor_model.py) —
|
|
167
|
+
Multi-asset factor decomposition with sign constraints and HCGL clustering.
|
|
168
|
+
- [`cv_lambda_selection.py`](examples/cv_lambda_selection.py) —
|
|
169
|
+
Time-series cross-validated `reg_lambda` selection via `LassoModelCV` with
|
|
170
|
+
expanding-window splits.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Testing
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
pip install -e ".[dev]"
|
|
178
|
+
pytest
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The suite currently has 201 tests at 98%+ coverage, including numerical parity
|
|
182
|
+
tests against `qis` for the EWMA primitives and against `scikit-learn` for the
|
|
183
|
+
LASSO path.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Citation
|
|
188
|
+
|
|
189
|
+
If you use `factorlasso` in academic work, please cite:
|
|
190
|
+
|
|
191
|
+
```bibtex
|
|
192
|
+
@article{SeppOssaKastenholz2026,
|
|
193
|
+
author = {Sepp, Artur and Ossa, Ivan and Kastenholz, Mika},
|
|
194
|
+
title = {Robust Optimization of Strategic and Tactical Asset Allocation
|
|
195
|
+
for Multi-Asset Portfolios},
|
|
196
|
+
journal = {The Journal of Portfolio Management},
|
|
197
|
+
year = {2026},
|
|
198
|
+
volume = {52},
|
|
199
|
+
number = {4},
|
|
200
|
+
pages = {86--120},
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
@software{factorlasso,
|
|
204
|
+
author = {Sepp, Artur},
|
|
205
|
+
title = {factorlasso: Sparse Factor Model Estimation with Constrained LASSO
|
|
206
|
+
in Python},
|
|
207
|
+
year = {2026},
|
|
208
|
+
url = {https://github.com/ArturSepp/factorlasso},
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Contributing & feedback
|
|
215
|
+
|
|
216
|
+
Issues and pull requests welcome at
|
|
217
|
+
<https://github.com/ArturSepp/factorlasso>.
|
|
218
|
+
|
|
219
|
+
See [`CHANGELOG.md`](CHANGELOG.md) for release history.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## License
|
|
224
|
+
|
|
225
|
+
MIT — see [`LICENSE`](LICENSE).
|
|
@@ -46,21 +46,36 @@ import scipy.cluster.hierarchy as spc
|
|
|
46
46
|
# Clustering from correlation
|
|
47
47
|
# ═══════════════════════════════════════════════════════════════════════
|
|
48
48
|
|
|
49
|
+
# Default fraction of ``max(pdist)`` at which to cut the dendrogram.
|
|
50
|
+
# 0.5 is the package-wide convention — half the maximum pairwise
|
|
51
|
+
# correlation-distance — and empirically produces ~15–25 clusters on
|
|
52
|
+
# a 150-asset multi-asset universe. Callers that need a different
|
|
53
|
+
# granularity can override via ``cutoff_fraction``.
|
|
54
|
+
DEFAULT_CUTOFF_FRACTION: float = 0.5
|
|
55
|
+
|
|
49
56
|
|
|
50
57
|
def compute_clusters_from_corr_matrix(
|
|
51
58
|
corr_matrix: pd.DataFrame,
|
|
59
|
+
cutoff_fraction: float = DEFAULT_CUTOFF_FRACTION,
|
|
52
60
|
) -> Tuple[pd.Series, np.ndarray, float]:
|
|
53
61
|
"""
|
|
54
62
|
Hierarchical clustering from a correlation matrix (Ward's method).
|
|
55
63
|
|
|
56
64
|
Converts correlation to distance ``(1 − corr)``, applies Ward's
|
|
57
|
-
agglomerative clustering, and cuts the dendrogram at
|
|
58
|
-
|
|
65
|
+
agglomerative clustering, and cuts the dendrogram at
|
|
66
|
+
``cutoff_fraction × max(pairwise distance)``.
|
|
59
67
|
|
|
60
68
|
Parameters
|
|
61
69
|
----------
|
|
62
70
|
corr_matrix : pd.DataFrame, shape (N, N)
|
|
63
71
|
Square correlation matrix.
|
|
72
|
+
cutoff_fraction : float, default 0.5
|
|
73
|
+
Fraction of ``max(pdist)`` at which to cut the dendrogram.
|
|
74
|
+
Must lie in ``(0, 1]``. Smaller values produce more, tighter
|
|
75
|
+
clusters; larger values produce fewer, looser clusters. The
|
|
76
|
+
default ``0.5`` is the canonical setting used throughout the
|
|
77
|
+
MATF-CMA pipeline and typically yields ~15–25 clusters on a
|
|
78
|
+
150-asset multi-asset universe.
|
|
64
79
|
|
|
65
80
|
Returns
|
|
66
81
|
-------
|
|
@@ -69,7 +84,8 @@ def compute_clusters_from_corr_matrix(
|
|
|
69
84
|
linkage : np.ndarray
|
|
70
85
|
Scipy linkage matrix.
|
|
71
86
|
cutoff : float
|
|
72
|
-
|
|
87
|
+
Absolute distance threshold used for cutting
|
|
88
|
+
(``cutoff_fraction × max(pdist)``).
|
|
73
89
|
|
|
74
90
|
Notes
|
|
75
91
|
-----
|
|
@@ -81,7 +97,24 @@ def compute_clusters_from_corr_matrix(
|
|
|
81
97
|
and computed Euclidean distances between those rows — a different
|
|
82
98
|
(non-semantic) metric that conflated correlation structure with
|
|
83
99
|
higher-order geometric relationships.
|
|
100
|
+
|
|
101
|
+
Examples
|
|
102
|
+
--------
|
|
103
|
+
>>> import numpy as np, pandas as pd
|
|
104
|
+
>>> from factorlasso import compute_clusters_from_corr_matrix
|
|
105
|
+
>>> rng = np.random.default_rng(0)
|
|
106
|
+
>>> X = rng.standard_normal((200, 6))
|
|
107
|
+
>>> X[:, 1] = X[:, 0] + 0.1 * rng.standard_normal(200) # 0 & 1 correlated
|
|
108
|
+
>>> C = pd.DataFrame(np.corrcoef(X, rowvar=False),
|
|
109
|
+
... columns=list("abcdef"), index=list("abcdef"))
|
|
110
|
+
>>> clusters, _, cutoff = compute_clusters_from_corr_matrix(C)
|
|
111
|
+
>>> clusters.loc['a'] == clusters.loc['b']
|
|
112
|
+
True
|
|
84
113
|
"""
|
|
114
|
+
if not (0.0 < cutoff_fraction <= 1.0):
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"cutoff_fraction must lie in (0, 1], got {cutoff_fraction!r}"
|
|
117
|
+
)
|
|
85
118
|
corr_matrix = corr_matrix.fillna(0.0)
|
|
86
119
|
# squareform(1 - C) is the correct conversion from a correlation matrix
|
|
87
120
|
# to scipy's condensed pairwise-distance vector. Clip guards against
|
|
@@ -91,7 +124,7 @@ def compute_clusters_from_corr_matrix(
|
|
|
91
124
|
np.fill_diagonal(dist_square, 0.0)
|
|
92
125
|
pdist = spc.distance.squareform(dist_square, checks=False)
|
|
93
126
|
linkage = spc.linkage(pdist, method='ward')
|
|
94
|
-
cutoff =
|
|
127
|
+
cutoff = cutoff_fraction * np.max(pdist)
|
|
95
128
|
idx = spc.fcluster(linkage, cutoff, 'distance')
|
|
96
129
|
clusters = pd.Series(idx, index=corr_matrix.columns)
|
|
97
130
|
return clusters, linkage, cutoff
|
|
@@ -31,14 +31,28 @@ True
|
|
|
31
31
|
|
|
32
32
|
from __future__ import annotations
|
|
33
33
|
|
|
34
|
+
import sys
|
|
35
|
+
import warnings
|
|
34
36
|
from dataclasses import dataclass, field
|
|
35
37
|
from typing import Iterator, Optional, Sequence, Tuple
|
|
36
38
|
|
|
39
|
+
import cvxpy as cvx
|
|
37
40
|
import numpy as np
|
|
38
41
|
import pandas as pd
|
|
39
42
|
|
|
40
43
|
from factorlasso.lasso_estimator import LassoModel
|
|
41
44
|
|
|
45
|
+
# Errors we treat as "this fold failed, record NaN and continue".
|
|
46
|
+
# Anything else (KeyboardInterrupt, MemoryError, attribute errors from
|
|
47
|
+
# bad user kwargs) should propagate to the caller.
|
|
48
|
+
_FOLD_ERRORS: Tuple[type, ...] = (
|
|
49
|
+
cvx.error.SolverError,
|
|
50
|
+
cvx.error.DCPError,
|
|
51
|
+
ValueError,
|
|
52
|
+
np.linalg.LinAlgError,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
42
56
|
# ═══════════════════════════════════════════════════════════════════════
|
|
43
57
|
# Time-series splits
|
|
44
58
|
# ═══════════════════════════════════════════════════════════════════════
|
|
@@ -134,6 +148,13 @@ class LassoModelCV:
|
|
|
134
148
|
-----
|
|
135
149
|
Scoring uses :meth:`LassoModel.score`, which is mean R² across
|
|
136
150
|
response variables. Higher is better.
|
|
151
|
+
|
|
152
|
+
Fold failures in the solver (``cvx.error.SolverError``,
|
|
153
|
+
``LinAlgError``, ``ValueError``, ``cvx.error.DCPError``) are caught
|
|
154
|
+
and recorded as NaN in ``cv_scores_``. Any other exception —
|
|
155
|
+
``KeyboardInterrupt``, ``MemoryError``, ``AttributeError`` from
|
|
156
|
+
an unexpected kwarg — propagates so that real bugs are not silently
|
|
157
|
+
swallowed by the CV loop.
|
|
137
158
|
"""
|
|
138
159
|
|
|
139
160
|
lambdas: Optional[Sequence[float]] = None
|
|
@@ -165,7 +186,8 @@ class LassoModelCV:
|
|
|
165
186
|
y : pd.DataFrame, shape (T, N)
|
|
166
187
|
Response (asset) returns. May contain NaNs.
|
|
167
188
|
verbose : bool, default False
|
|
168
|
-
|
|
189
|
+
If True, forwarded to :meth:`LassoModel.fit` and a warning
|
|
190
|
+
is emitted for every fold that fails.
|
|
169
191
|
|
|
170
192
|
Returns
|
|
171
193
|
-------
|
|
@@ -196,9 +218,21 @@ class LassoModelCV:
|
|
|
196
218
|
x=x.iloc[tr], y=y.iloc[tr], verbose=verbose,
|
|
197
219
|
)
|
|
198
220
|
scores[i, j] = model.score(x.iloc[te], y.iloc[te])
|
|
199
|
-
except
|
|
200
|
-
|
|
201
|
-
|
|
221
|
+
except _FOLD_ERRORS as err:
|
|
222
|
+
if verbose:
|
|
223
|
+
warnings.warn(
|
|
224
|
+
f"CV fold (lambda={lam:.2e}, split={j}) failed: "
|
|
225
|
+
f"{type(err).__name__}: {err}",
|
|
226
|
+
RuntimeWarning,
|
|
227
|
+
stacklevel=2,
|
|
228
|
+
)
|
|
229
|
+
print(
|
|
230
|
+
f"[LassoModelCV] fold failed "
|
|
231
|
+
f"(lambda={lam:.2e}, split={j}): "
|
|
232
|
+
f"{type(err).__name__}: {err}",
|
|
233
|
+
file=sys.stderr,
|
|
234
|
+
)
|
|
235
|
+
# Fall through with NaN score
|
|
202
236
|
|
|
203
237
|
cv_scores = pd.DataFrame(
|
|
204
238
|
scores,
|