allocation 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. allocation-0.0.1/LICENSE +21 -0
  2. allocation-0.0.1/PKG-INFO +89 -0
  3. allocation-0.0.1/README.md +68 -0
  4. allocation-0.0.1/allocation/__init__.py +30 -0
  5. allocation-0.0.1/allocation/_schur/__init__.py +0 -0
  6. allocation-0.0.1/allocation/_schur/coupling.py +199 -0
  7. allocation-0.0.1/allocation/_schur/seriation.py +127 -0
  8. allocation-0.0.1/allocation/_thurstone/__init__.py +0 -0
  9. allocation-0.0.1/allocation/_thurstone/ability.py +45 -0
  10. allocation-0.0.1/allocation/_thurstone/calibrate.py +105 -0
  11. allocation-0.0.1/allocation/_thurstone/covariance.py +91 -0
  12. allocation-0.0.1/allocation/_thurstone/diagonal.py +26 -0
  13. allocation-0.0.1/allocation/_thurstone/transport.py +53 -0
  14. allocation-0.0.1/allocation/base.py +141 -0
  15. allocation-0.0.1/allocation/keyed.py +223 -0
  16. allocation-0.0.1/allocation/moments.py +62 -0
  17. allocation-0.0.1/allocation/schur.py +101 -0
  18. allocation-0.0.1/allocation/thurstone.py +142 -0
  19. allocation-0.0.1/allocation/universe.py +22 -0
  20. allocation-0.0.1/allocation.egg-info/PKG-INFO +89 -0
  21. allocation-0.0.1/allocation.egg-info/SOURCES.txt +29 -0
  22. allocation-0.0.1/allocation.egg-info/dependency_links.txt +1 -0
  23. allocation-0.0.1/allocation.egg-info/requires.txt +12 -0
  24. allocation-0.0.1/allocation.egg-info/top_level.txt +1 -0
  25. allocation-0.0.1/pyproject.toml +32 -0
  26. allocation-0.0.1/setup.cfg +4 -0
  27. allocation-0.0.1/tests/test_schur.py +112 -0
  28. allocation-0.0.1/tests/test_schur_vs_skfolio.py +61 -0
  29. allocation-0.0.1/tests/test_skfolio.py +25 -0
  30. allocation-0.0.1/tests/test_streaming.py +70 -0
  31. allocation-0.0.1/tests/test_thurstone.py +56 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Peter Cotton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: allocation
3
+ Version: 0.0.1
4
+ Summary: Online (streaming) portfolio construction: scikit-learn / skfolio-compatible estimators over a keyed dynamic universe
5
+ Author-email: Peter Cotton <peter.cotton@microprediction.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/microprediction/allocation
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: numpy>=1.23
12
+ Requires-Dist: thurstone>=0.1.0
13
+ Provides-Extra: scale
14
+ Requires-Dist: scipy>=1.10; extra == "scale"
15
+ Provides-Extra: skfolio
16
+ Requires-Dist: skfolio>=0.5; extra == "skfolio"
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=7.0; extra == "test"
19
+ Requires-Dist: scipy>=1.10; extra == "test"
20
+ Dynamic: license-file
21
+
22
+ # allocation
23
+
24
+ **Online (streaming) portfolio construction.** A home for portfolio methods as
25
+ scikit-learn / [skfolio](https://skfolio.org)-compatible estimators
26
+ (`fit` / `partial_fit` / `predict` / `weights_`), on top of a keyed
27
+ dynamic-universe layer so they survive reconstituting universes.
28
+
29
+ ```python
30
+ from allocation import ThurstonePortfolio
31
+
32
+ est = ThurstonePortfolio(calib="market", phi=1.0)
33
+ est.fit(returns) # returns: (n_obs, n_assets)
34
+ est.weights_ # long-only weights on the simplex
35
+
36
+ for batch in stream: # streaming / rebalancing
37
+ est.partial_fit(batch) # transports a fixed seed ensemble -> low turnover
38
+ w = est.weights_
39
+ ```
40
+
41
+ `score(X)` returns the portfolio's Sharpe ratio (skfolio's scoring convention),
42
+ and `to_portfolio(X)` wraps the fitted weights as a skfolio `Portfolio`
43
+ (`pip install allocation[skfolio]`) so the output drops into skfolio's metrics,
44
+ `Population`, and cross-validation.
45
+
46
+ ## Why a separate package
47
+
48
+ skfolio is the scikit-learn-native portfolio library, but it is *batch*: a
49
+ changing universe is handled by NaN/incomplete-data mechanics, not by carrying
50
+ per-asset state across streaming updates. `allocation` is the **online**
51
+ complement — estimators with `partial_fit`, a keyed dynamic universe (in the
52
+ spirit of [`precise`](https://github.com/microprediction/precise)), and turnover
53
+ control by construction — while staying API-compatible so each estimator can also
54
+ be contributed upstream to skfolio.
55
+
56
+ ## Methods
57
+
58
+ | Estimator | Status | Notes |
59
+ |-----------|--------|-------|
60
+ | `ThurstonePortfolio` | working | Ability tilt: weights are winning probabilities of a correlated race; calibrate to a benchmark under a reference correlation, tilt under the estimate; smooth common-seed transport for `partial_fit`. Built on [`thurstone`](https://github.com/microprediction/thurstone). |
61
+ | `SchurComplementary` | planned | Online version of the Schur-complementary construction (the batch version is in skfolio). |
62
+ | baselines | planned | Online equal / cap / minimum-variance / risk-parity for benchmarking. |
63
+
64
+ ## Design
65
+
66
+ ```
67
+ allocation/
68
+ base.py # BaseOnlinePortfolio: fit / partial_fit / predict / weights_
69
+ moments.py # EwmaCovariance (default); any partial_fit/covariance_ estimator plugs in
70
+ universe.py # keyed dynamic-universe state (planned)
71
+ thurstone.py # ThurstonePortfolio
72
+ _thurstone/ # calibration + transport engine
73
+ ```
74
+
75
+ Covariance is pluggable: the default is a light EWMA, but any online estimator
76
+ exposing `partial_fit` and `covariance_` (e.g. a `precise` skater) can be passed
77
+ via `covariance=`.
78
+
79
+ ## Status
80
+
81
+ Early. The Thurstone estimator and the streaming transport work and are tested;
82
+ the keyed dynamic-universe layer and the Schur estimator are next. The theory
83
+ behind the Thurstone method (feasibility, redundancy consistency, the implied
84
+ regularized objective, smoothness/turnover) is written up in the accompanying
85
+ paper.
86
+
87
+ ## License
88
+
89
+ MIT © Peter Cotton
@@ -0,0 +1,68 @@
1
+ # allocation
2
+
3
+ **Online (streaming) portfolio construction.** A home for portfolio methods as
4
+ scikit-learn / [skfolio](https://skfolio.org)-compatible estimators
5
+ (`fit` / `partial_fit` / `predict` / `weights_`), on top of a keyed
6
+ dynamic-universe layer so they survive reconstituting universes.
7
+
8
+ ```python
9
+ from allocation import ThurstonePortfolio
10
+
11
+ est = ThurstonePortfolio(calib="market", phi=1.0)
12
+ est.fit(returns) # returns: (n_obs, n_assets)
13
+ est.weights_ # long-only weights on the simplex
14
+
15
+ for batch in stream: # streaming / rebalancing
16
+ est.partial_fit(batch) # transports a fixed seed ensemble -> low turnover
17
+ w = est.weights_
18
+ ```
19
+
20
+ `score(X)` returns the portfolio's Sharpe ratio (skfolio's scoring convention),
21
+ and `to_portfolio(X)` wraps the fitted weights as a skfolio `Portfolio`
22
+ (`pip install allocation[skfolio]`) so the output drops into skfolio's metrics,
23
+ `Population`, and cross-validation.
24
+
25
+ ## Why a separate package
26
+
27
+ skfolio is the scikit-learn-native portfolio library, but it is *batch*: a
28
+ changing universe is handled by NaN/incomplete-data mechanics, not by carrying
29
+ per-asset state across streaming updates. `allocation` is the **online**
30
+ complement — estimators with `partial_fit`, a keyed dynamic universe (in the
31
+ spirit of [`precise`](https://github.com/microprediction/precise)), and turnover
32
+ control by construction — while staying API-compatible so each estimator can also
33
+ be contributed upstream to skfolio.
34
+
35
+ ## Methods
36
+
37
+ | Estimator | Status | Notes |
38
+ |-----------|--------|-------|
39
+ | `ThurstonePortfolio` | working | Ability tilt: weights are winning probabilities of a correlated race; calibrate to a benchmark under a reference correlation, tilt under the estimate; smooth common-seed transport for `partial_fit`. Built on [`thurstone`](https://github.com/microprediction/thurstone). |
40
+ | `SchurComplementary` | planned | Online version of the Schur-complementary construction (the batch version is in skfolio). |
41
+ | baselines | planned | Online equal / cap / minimum-variance / risk-parity for benchmarking. |
42
+
43
+ ## Design
44
+
45
+ ```
46
+ allocation/
47
+ base.py # BaseOnlinePortfolio: fit / partial_fit / predict / weights_
48
+ moments.py # EwmaCovariance (default); any partial_fit/covariance_ estimator plugs in
49
+ universe.py # keyed dynamic-universe state (planned)
50
+ thurstone.py # ThurstonePortfolio
51
+ _thurstone/ # calibration + transport engine
52
+ ```
53
+
54
+ Covariance is pluggable: the default is a light EWMA, but any online estimator
55
+ exposing `partial_fit` and `covariance_` (e.g. a `precise` skater) can be passed
56
+ via `covariance=`.
57
+
58
+ ## Status
59
+
60
+ Early. The Thurstone estimator and the streaming transport work and are tested;
61
+ the keyed dynamic-universe layer and the Schur estimator are next. The theory
62
+ behind the Thurstone method (feasibility, redundancy consistency, the implied
63
+ regularized objective, smoothness/turnover) is written up in the accompanying
64
+ paper.
65
+
66
+ ## License
67
+
68
+ MIT © Peter Cotton
@@ -0,0 +1,30 @@
1
+ """allocation -- online (streaming) portfolio construction.
2
+
3
+ A home for online/streaming portfolio estimators that follow scikit-learn /
4
+ skfolio conventions (``fit`` / ``partial_fit`` / ``weights_`` / ``predict``) on
5
+ top of a keyed dynamic-universe layer, so they survive reconstituting universes.
6
+
7
+ Estimators:
8
+ from allocation import ThurstonePortfolio
9
+ w = ThurstonePortfolio(calib="market", phi=1.0).fit(returns).weights_
10
+ """
11
+
12
+ from .base import BaseOnlinePortfolio
13
+ from .keyed import KeyedEwmaCovariance, StreamingSchur, StreamingThurstone
14
+ from .moments import EwmaCovariance
15
+ from .schur import SchurComplementary
16
+ from .thurstone import ThurstonePortfolio
17
+
18
+ __version__ = "0.0.1"
19
+
20
+ __all__ = [
21
+ # batch / skfolio-style
22
+ "BaseOnlinePortfolio",
23
+ "ThurstonePortfolio",
24
+ "SchurComplementary",
25
+ "EwmaCovariance",
26
+ # streaming / river-style (changing universe)
27
+ "StreamingThurstone",
28
+ "StreamingSchur",
29
+ "KeyedEwmaCovariance",
30
+ ]
File without changes
@@ -0,0 +1,199 @@
1
+ """Schur-complementary allocation: the gamma cross-block coupling + recursive bisection.
2
+
3
+ Faithful port of the Schur Complementary algorithm (Peter Cotton, 2022; in skfolio
4
+ as ``SchurComplementary``, BSD-3, derived from ``precise``). Given a seriation
5
+ (asset order) and a covariance, recursive median bisection allocates between halves
6
+ in inverse proportion to each half's variance -- but each half's covariance is first
7
+ *augmented* by the Schur complement of the other half, scaled by ``gamma``:
8
+ ``gamma = 0`` ignores the cross-block (Hierarchical Risk Parity); ``gamma -> 1`` tends
9
+ to the minimum-variance portfolio.
10
+
11
+ Only the *order* comes from clustering; everything here is a continuous function of
12
+ the covariance, so a smooth order (Fiedler seriation) yields smooth weights.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+
19
+ import numpy as np
20
+
21
+ __all__ = ["compute_weights", "compute_monotonic_weights", "schur_augmentation"]
22
+
23
+
24
+ def _inverse_multiply(a: np.ndarray, b: np.ndarray) -> np.ndarray:
25
+ """a^-1 @ b via a solve (more accurate than forming the inverse)."""
26
+ return np.linalg.solve(a, b)
27
+
28
+
29
+ def _multiply_by_inverse(a: np.ndarray, b: np.ndarray) -> np.ndarray:
30
+ """a @ b^-1."""
31
+ return np.linalg.solve(b.T, a.T).T
32
+
33
+
34
+ def _symmetrize(m: np.ndarray) -> np.ndarray:
35
+ return 0.5 * (m + m.T)
36
+
37
+
38
+ def _is_spd(a: np.ndarray) -> bool:
39
+ try:
40
+ np.linalg.cholesky(a)
41
+ return True
42
+ except np.linalg.LinAlgError:
43
+ return False
44
+
45
+
46
+ def _nearest_spd(a: np.ndarray) -> np.ndarray:
47
+ a = _symmetrize(a)
48
+ vals, vecs = np.linalg.eigh(a)
49
+ vals = np.clip(vals, 1e-10, None)
50
+ return _symmetrize((vecs * vals) @ vecs.T)
51
+
52
+
53
+ def _symmetric_step_up_matrix(n1: int, n2: int) -> np.ndarray:
54
+ """M with ``M @ ones(n2) == ones(n1)`` (|n1 - n2| <= 1)."""
55
+ if n1 == n2:
56
+ return np.eye(n1)
57
+ if n1 < n2:
58
+ return _symmetric_step_up_matrix(n2, n1).T * n1 / n2
59
+ m = np.zeros((n1, n2))
60
+ j_row = np.ones((1, n2)) / n2
61
+ e = np.eye(n2)
62
+ for j in range(n1):
63
+ mj = np.concatenate([e[:j], j_row, e[j:]], axis=0)
64
+ m += mj / n1
65
+ return m
66
+
67
+
68
+ def schur_augmentation(a: np.ndarray, b: np.ndarray, d: np.ndarray, gamma: float) -> np.ndarray:
69
+ """Augment block ``a`` with the gamma-scaled Schur complement of ``d``."""
70
+ n_a, n_d = a.shape[0], d.shape[0]
71
+ if gamma == 0 or n_a == 1 or n_d == 1:
72
+ return a
73
+ a_aug = a - gamma * b @ _inverse_multiply(d, b.T)
74
+ m = _symmetric_step_up_matrix(n1=n_a, n2=n_d)
75
+ r = np.eye(n_a) - gamma * _multiply_by_inverse(b, d) @ m.T
76
+ return _symmetrize(_inverse_multiply(r, a_aug))
77
+
78
+
79
+ def _bisection(items):
80
+ for e in items:
81
+ n = len(e)
82
+ if n > 1:
83
+ mid = n // 2
84
+ yield e[:mid], e[mid:]
85
+
86
+
87
+ def _naive_portfolio_variance(cov: np.ndarray) -> float:
88
+ w = 1.0 / np.diag(cov)
89
+ w = w / w.sum()
90
+ return float(w @ cov @ w.T)
91
+
92
+
93
+ def compute_weights(
94
+ order: np.ndarray, covariance: np.ndarray, gamma: float, force_spd: bool = True
95
+ ) -> np.ndarray:
96
+ """Schur-complementary weights for a given seriation ``order`` and covariance.
97
+
98
+ Weights are returned in the original asset order (not the seriation order),
99
+ are long-only, and sum to one.
100
+ """
101
+ cov = np.array(covariance, dtype=float, copy=True)
102
+ n = len(cov)
103
+ weights = np.ones(n)
104
+ items = [np.asarray(order, dtype=int)]
105
+ while items:
106
+ new_items = []
107
+ for left, right in _bisection(items):
108
+ new_items += [left, right]
109
+ a = cov[np.ix_(left, left)]
110
+ d = cov[np.ix_(right, right)]
111
+ if len(left) <= 1 or len(right) <= 1:
112
+ a_aug, d_aug = a, d
113
+ else:
114
+ b = cov[np.ix_(left, right)]
115
+ a_aug = schur_augmentation(a, b, d, gamma=gamma)
116
+ d_aug = schur_augmentation(d, b.T, a, gamma=gamma)
117
+ if force_spd:
118
+ if not _is_spd(a_aug):
119
+ a_aug = _nearest_spd(a_aug)
120
+ if not _is_spd(d_aug):
121
+ d_aug = _nearest_spd(d_aug)
122
+ elif not _is_spd(a_aug) or not _is_spd(d_aug):
123
+ return None # signal infeasible gamma to the monotonic sweep
124
+ cov[np.ix_(left, left)] = a_aug
125
+ cov[np.ix_(right, right)] = d_aug
126
+ lv = _naive_portfolio_variance(a_aug)
127
+ rv = _naive_portfolio_variance(d_aug)
128
+ alpha = 1.0 - lv / (lv + rv)
129
+ weights[left] *= alpha
130
+ weights[right] *= 1.0 - alpha
131
+ items = new_items
132
+ s = weights.sum()
133
+ return weights / s if s > 0 else np.full(n, 1.0 / n)
134
+
135
+
136
+ def _binary_search(objective, low_gamma, high_gamma, low_variance, tol=1e-4):
137
+ """Locate the gamma turning point where portfolio variance stops decreasing."""
138
+ max_iter = math.ceil(math.log2(max(high_gamma - low_gamma, tol) / tol) * 2 + 1)
139
+ is_decreasing = False
140
+ best = None
141
+ for _ in range(max_iter):
142
+ mid = 0.5 * (low_gamma + high_gamma)
143
+ variance, weights = objective(mid)
144
+ variance_h = objective(mid - tol)[0]
145
+ if variance <= low_variance and variance <= variance_h:
146
+ is_decreasing = True
147
+ low_gamma, low_variance, best = mid, variance, weights
148
+ else:
149
+ high_gamma = mid
150
+ if is_decreasing and best is not None and (high_gamma - low_gamma) <= tol:
151
+ return best, low_gamma
152
+ raise RuntimeError("no permissible gamma with monotonically decreasing variance")
153
+
154
+
155
+ def compute_monotonic_weights(
156
+ order: np.ndarray,
157
+ covariance: np.ndarray,
158
+ max_gamma: float,
159
+ step: float = 0.1,
160
+ tol: float = 1e-4,
161
+ ) -> tuple[np.ndarray, float]:
162
+ """Weights at the largest gamma (<= ``max_gamma``) for which variance still falls.
163
+
164
+ Caps gamma at the turning point ``effective_gamma`` so that
165
+ ``variance(Schur) <= variance(HRP)`` even for ill-conditioned covariances
166
+ (skfolio's ``keep_monotonic=True`` behaviour). Returns ``(weights, effective_gamma)``.
167
+ """
168
+ if max_gamma == 0:
169
+ return compute_weights(order, covariance, 0.0, force_spd=True), 0.0
170
+ cov = np.asarray(covariance, dtype=float)
171
+
172
+ def objective(x):
173
+ w = compute_weights(order, cov, x, force_spd=False)
174
+ risk = np.inf if w is None else float(w @ cov @ w.T)
175
+ return risk, w
176
+
177
+ n = int(np.ceil(max_gamma / step)) + 1
178
+ gammas = np.linspace(0.0, max_gamma, n)
179
+ variances = np.full(n, np.nan)
180
+ variance, weights_0 = objective(gammas[0])
181
+ variances[0] = variance
182
+ weights = weights_0
183
+ for i in range(1, n):
184
+ variance, weights = objective(gammas[i])
185
+ variances[i] = variance
186
+ if variance >= variances[i - 1]:
187
+ lo = gammas[0] if i == 1 else gammas[i - 2]
188
+ lo_var = variances[0] if i == 1 else variances[i - 2]
189
+ try:
190
+ return _binary_search(objective, lo, gammas[i], lo_var, tol)
191
+ except RuntimeError:
192
+ return weights_0, 0.0
193
+ # monotonically decreasing up to max_gamma
194
+ if variance <= objective(max_gamma - tol)[0]:
195
+ return weights, max_gamma
196
+ try:
197
+ return _binary_search(objective, gammas[-2], max_gamma, variances[-2], tol)
198
+ except RuntimeError:
199
+ return weights, max_gamma
@@ -0,0 +1,127 @@
1
+ """Fiedler (spectral) seriation -- a smooth ordering of assets.
2
+
3
+ Standard HRP/Schur take the asset order from agglomerative clustering, whose
4
+ dendrogram reorders discontinuously as the covariance drifts -- the source of
5
+ turnover. We replace it with the Fiedler vector (the eigenvector of the second
6
+ smallest Laplacian eigenvalue of the similarity graph): it varies *continuously*
7
+ with the covariance, so the order changes only when two assets' Fiedler
8
+ coordinates cross. Feeding this order into the Schur recursion makes the whole
9
+ allocation a smooth function of the covariance.
10
+
11
+ Scale: the similarity graph can be sparsified to k nearest neighbours so the
12
+ Fiedler vector is a single eigenpair of a sparse Laplacian (the dense ``eigh``
13
+ here is fine to a few thousand assets; an iterative, warm-started solver is the
14
+ drop-in for larger universes). The graph is a blend of realized correlation and
15
+ an optional prior (sectors / fundamentals / an LLM similarity) -- the seam that
16
+ lets a prior place cold-start names the realized correlation cannot.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import numpy as np
22
+
23
+ from .._thurstone.covariance import cov_to_corr
24
+
25
+ __all__ = ["affinity", "fiedler_vector", "seriate"]
26
+
27
+
28
+ def affinity(
29
+ cov: np.ndarray,
30
+ knn: int | None = None,
31
+ prior: np.ndarray | None = None,
32
+ prior_weight: float = 0.0,
33
+ ) -> np.ndarray:
34
+ """Similarity (affinity) graph in [0, 1], optionally kNN-sparsified.
35
+
36
+ Realized similarity is ``(corr + 1) / 2``; blended with an optional ``prior``
37
+ similarity by ``prior_weight``. With ``knn`` set, each row keeps only its k
38
+ largest off-diagonal affinities (symmetrized).
39
+ """
40
+ A = 0.5 * (cov_to_corr(cov) + 1.0)
41
+ if prior is not None and prior_weight > 0.0:
42
+ A = (1.0 - prior_weight) * A + prior_weight * np.asarray(prior, dtype=float)
43
+ np.fill_diagonal(A, 0.0)
44
+ if knn is not None and knn < A.shape[0] - 1:
45
+ n = A.shape[0]
46
+ mask = np.zeros_like(A, dtype=bool)
47
+ keep = np.argsort(-A, axis=1)[:, :knn]
48
+ rows = np.repeat(np.arange(n), knn)
49
+ mask[rows, keep.ravel()] = True
50
+ mask |= mask.T # symmetric kNN graph
51
+ A = np.where(mask, A, 0.0)
52
+ return A
53
+
54
+
55
+ def _fiedler_dense(A: np.ndarray) -> np.ndarray:
56
+ L = np.diag(A.sum(axis=1)) - A
57
+ _, vecs = np.linalg.eigh(L)
58
+ return vecs[:, 1] # 2nd-smallest eigenvalue -> Fiedler vector
59
+
60
+
61
+ def _fiedler_sparse(A: np.ndarray, previous: np.ndarray | None) -> np.ndarray:
62
+ """Fiedler vector via LOBPCG on a sparse Laplacian (only one eigenpair).
63
+
64
+ We deflate the constant null vector (`Y = 1`, the eigenvalue-0 eigenvector of a
65
+ connected Laplacian) and ask for the smallest remaining eigenpair -- the
66
+ Fiedler vector. Warm-started from ``previous``. This is what scales: an
67
+ `O(nnz)` matvec on a kNN-sparsified graph rather than a dense `O(n^3)` eigh.
68
+ """
69
+ import scipy.sparse as sp
70
+ import scipy.sparse.linalg as spla
71
+
72
+ n = A.shape[0]
73
+ Asp = sp.csr_matrix(A)
74
+ deg = np.asarray(Asp.sum(axis=1)).ravel()
75
+ L = sp.diags(deg) - Asp
76
+ if previous is not None and len(previous) == n:
77
+ X = previous.reshape(n, 1).copy()
78
+ else:
79
+ rng = np.random.default_rng(0)
80
+ X = rng.standard_normal((n, 1))
81
+ Y = np.ones((n, 1)) # deflate the constant (lambda=0) eigenvector
82
+ _, vecs = spla.lobpcg(L, X, Y=Y, largest=False, maxiter=200, tol=1e-7)
83
+ return np.asarray(vecs)[:, 0]
84
+
85
+
86
+ def fiedler_vector(
87
+ A: np.ndarray, previous: np.ndarray | None = None, sparse: bool = False
88
+ ) -> np.ndarray:
89
+ """Fiedler vector of the graph Laplacian ``L = D - A``.
90
+
91
+ With ``sparse=True`` an iterative single-eigenpair LOBPCG solve is used
92
+ (scales to large kNN graphs); otherwise a dense ``eigh``. The sign is fixed by
93
+ alignment with ``previous`` -- the Fiedler vector is defined up to sign, and
94
+ without this the order would flip arbitrarily between streaming updates.
95
+ """
96
+ if sparse and A.shape[0] > 3:
97
+ try:
98
+ v = _fiedler_sparse(A, previous)
99
+ except Exception:
100
+ v = _fiedler_dense(A)
101
+ else:
102
+ v = _fiedler_dense(A)
103
+ if previous is not None and len(previous) == len(v) and float(v @ previous) < 0.0:
104
+ v = -v
105
+ return v
106
+
107
+
108
+ def seriate(
109
+ cov: np.ndarray,
110
+ previous: np.ndarray | None = None,
111
+ knn: int | None = None,
112
+ prior: np.ndarray | None = None,
113
+ prior_weight: float = 0.0,
114
+ sparse: bool | None = None,
115
+ ) -> tuple[np.ndarray, np.ndarray]:
116
+ """Return ``(order, fiedler)``: the seriation order and its Fiedler vector.
117
+
118
+ ``previous`` is last period's Fiedler vector, used to keep the sign (and hence
119
+ the order) stable across streaming updates. ``sparse`` selects the iterative
120
+ LOBPCG solver; if ``None`` it defaults to sparse whenever ``knn`` is set.
121
+ """
122
+ A = affinity(cov, knn=knn, prior=prior, prior_weight=prior_weight)
123
+ if sparse is None:
124
+ sparse = knn is not None
125
+ v = fiedler_vector(A, previous=previous, sparse=sparse)
126
+ order = np.argsort(v)
127
+ return order, v
File without changes
@@ -0,0 +1,45 @@
1
+ """Bridge to the :mod:`thurstone` calibration maps.
2
+
3
+ These thin wrappers turn portfolio weights (treated as state prices / winning
4
+ probabilities) into latent Thurstone abilities and back, using the lattice
5
+ machinery in the ``thurstone`` package. Smaller ability == stronger competitor,
6
+ because the winner is the *minimum* performance.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+ from thurstone import AbilityCalibrator, Density, UniformLattice
13
+ from thurstone.conventions import STD_L, STD_SCALE, STD_UNIT
14
+
15
+ __all__ = [
16
+ "base_density",
17
+ "state_price_implied_ability",
18
+ "ability_implied_state_prices",
19
+ ]
20
+
21
+
22
+ def base_density(L: int = STD_L, unit: float = STD_UNIT, scale: float = STD_SCALE) -> Density:
23
+ """Standard symmetric performance density on the default lattice."""
24
+ lat = UniformLattice(L, unit)
25
+ return Density.skew_normal(lat, loc=0.0, scale=scale, a=0.0)
26
+
27
+
28
+ def state_price_implied_ability(
29
+ weights, *, base: Density | None = None, n_iter: int = 4
30
+ ) -> np.ndarray:
31
+ """Invert weights (as winning probabilities) to abilities, up to a constant."""
32
+ base = base if base is not None else base_density()
33
+ cal = AbilityCalibrator(base=base, n_iter=n_iter)
34
+ a = cal.solve_from_prices([float(w) for w in weights])
35
+ return np.asarray(a, dtype=float)
36
+
37
+
38
+ def ability_implied_state_prices(ability, *, base: Density | None = None) -> np.ndarray:
39
+ """Forward map: abilities to winning probabilities (the field's state prices)."""
40
+ base = base if base is not None else base_density()
41
+ cal = AbilityCalibrator(base=base)
42
+ p = cal.state_prices_from_ability([float(a) for a in ability])
43
+ p = np.asarray(p, dtype=float)
44
+ s = p.sum()
45
+ return p / s if s > 0 else p