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.
Files changed (26) hide show
  1. factorlasso-0.3.0/PKG-INFO +269 -0
  2. factorlasso-0.3.0/README.md +225 -0
  3. {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso/cluster_utils.py +37 -4
  4. {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso/cv.py +38 -4
  5. {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso/ewm_utils.py +95 -10
  6. {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso/factor_covar.py +21 -1
  7. {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso/lasso_estimator.py +81 -21
  8. factorlasso-0.3.0/factorlasso.egg-info/PKG-INFO +269 -0
  9. {factorlasso-0.2.2 → factorlasso-0.3.0}/pyproject.toml +2 -1
  10. factorlasso-0.2.2/PKG-INFO +0 -147
  11. factorlasso-0.2.2/README.md +0 -104
  12. factorlasso-0.2.2/factorlasso.egg-info/PKG-INFO +0 -147
  13. {factorlasso-0.2.2 → factorlasso-0.3.0}/LICENSE +0 -0
  14. {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso/__init__.py +0 -0
  15. {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso/py.typed +0 -0
  16. {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso.egg-info/SOURCES.txt +0 -0
  17. {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso.egg-info/dependency_links.txt +0 -0
  18. {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso.egg-info/requires.txt +0 -0
  19. {factorlasso-0.2.2 → factorlasso-0.3.0}/factorlasso.egg-info/top_level.txt +0 -0
  20. {factorlasso-0.2.2 → factorlasso-0.3.0}/setup.cfg +0 -0
  21. {factorlasso-0.2.2 → factorlasso-0.3.0}/tests/test_corr_zero_variance.py +0 -0
  22. {factorlasso-0.2.2 → factorlasso-0.3.0}/tests/test_coverage_extras.py +0 -0
  23. {factorlasso-0.2.2 → factorlasso-0.3.0}/tests/test_external_parity.py +0 -0
  24. {factorlasso-0.2.2 → factorlasso-0.3.0}/tests/test_factorlasso.py +0 -0
  25. {factorlasso-0.2.2 → factorlasso-0.3.0}/tests/test_integration.py +0 -0
  26. {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
+ [![PyPI](https://img.shields.io/pypi/v/factorlasso.svg)](https://pypi.org/project/factorlasso/)
51
+ [![Python](https://img.shields.io/pypi/pyversions/factorlasso.svg)](https://pypi.org/project/factorlasso/)
52
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/factorlasso.svg)](https://pypi.org/project/factorlasso/)
7
+ [![Python](https://img.shields.io/pypi/pyversions/factorlasso.svg)](https://pypi.org/project/factorlasso/)
8
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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 50 % of the
58
- maximum pairwise distance.
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
- Distance threshold used for cutting.
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 = 0.5 * np.max(pdist)
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
- Forwarded to :meth:`LassoModel.fit`.
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 Exception:
200
- # Solver failure or degenerate fold — leave NaN
201
- pass
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,