pyclsp 1.0.0__py3-none-any.whl
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.
- clsp/__init__.py +8 -0
- clsp/clsp.py +232 -0
- clsp/errors.py +22 -0
- clsp/solver.py +274 -0
- clsp/utils.py +314 -0
- pyclsp-1.0.0.dist-info/METADATA +259 -0
- pyclsp-1.0.0.dist-info/RECORD +10 -0
- pyclsp-1.0.0.dist-info/WHEEL +5 -0
- pyclsp-1.0.0.dist-info/licenses/LICENSE +21 -0
- pyclsp-1.0.0.dist-info/top_level.txt +1 -0
clsp/__init__.py
ADDED
clsp/clsp.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
import copy
|
|
3
|
+
import numpy as np
|
|
4
|
+
import scipy.linalg as la
|
|
5
|
+
import scipy.stats as stats
|
|
6
|
+
import cvxpy as cp
|
|
7
|
+
from types import MethodType
|
|
8
|
+
from .errors import CLSPError
|
|
9
|
+
from .utils import CLSPCanonicalForm, CLSPCorrelogram, CLSPTTest
|
|
10
|
+
from .solver import CLSPSolve
|
|
11
|
+
|
|
12
|
+
class CLSP:
|
|
13
|
+
def __init__(self):
|
|
14
|
+
"""
|
|
15
|
+
Instantiate a CLSP (Convex Least Squares Programming) object.
|
|
16
|
+
|
|
17
|
+
This class initiates and stores the results of a two-stage estimation
|
|
18
|
+
process for constrained least-squares problems under a canonical matrix
|
|
19
|
+
structure. The approach is designed for underdetermined, ill-posed, or
|
|
20
|
+
structurally constrained systems.
|
|
21
|
+
|
|
22
|
+
Stage 1 estimates the solution z from the system A·z = b using a
|
|
23
|
+
pseudoinverse method. Depending on the projection matrix Z, this may
|
|
24
|
+
be the Moore–Penrose inverse (Z = I) or the Bott–Duffin inverse (Z ≠ I).
|
|
25
|
+
If r > 1, the pseudoinverse solution is iteratively refined using an
|
|
26
|
+
updated slack matrix Q to improve numerical stability and feasibility.
|
|
27
|
+
|
|
28
|
+
Stage 2 optionally refines the estimate using a convex optimization
|
|
29
|
+
step centered around the pseudoinverse estimate ẑ. Regularization is
|
|
30
|
+
governed by the user-specified parameter α:
|
|
31
|
+
• α = 0 → Lasso (ℓ₁ norm),
|
|
32
|
+
• α = 1 → Ridge (ℓ₂ norm),
|
|
33
|
+
• 0 < α < 1 → Elastic Net (ℓ₁ and ℓ₂ combined).
|
|
34
|
+
|
|
35
|
+
The class also supports diagnostic routines such as Monte Carlo
|
|
36
|
+
hypothesis testing and row-wise deletion sensitivity analysis.
|
|
37
|
+
|
|
38
|
+
Attributes
|
|
39
|
+
----------
|
|
40
|
+
A : np.ndarray or None
|
|
41
|
+
Canonical design matrix with block structure A = [C | S; M | Q].
|
|
42
|
+
|
|
43
|
+
C_idx : tuple
|
|
44
|
+
Pair of integers defining the row and column ranges of the
|
|
45
|
+
C block inside A. Used for matrix slicing and partitioning.
|
|
46
|
+
|
|
47
|
+
b : np.ndarray or None
|
|
48
|
+
Right-hand side vector for the linear system A·z = b.
|
|
49
|
+
|
|
50
|
+
Z : np.ndarray or None
|
|
51
|
+
Projection matrix for Bott–Duffin inversion. Must be symmetric
|
|
52
|
+
and idempotent. Defaults to identity for Moore–Penrose.
|
|
53
|
+
|
|
54
|
+
zhat : np.ndarray or None
|
|
55
|
+
Unregularized pseudoinverse estimate of z from Step 1.
|
|
56
|
+
|
|
57
|
+
final : bool
|
|
58
|
+
Whether to run the second-stage convex refinement. If True,
|
|
59
|
+
the estimate is regularized via convex programming.
|
|
60
|
+
|
|
61
|
+
alpha : float
|
|
62
|
+
Regularization parameter:
|
|
63
|
+
- α = 0: Lasso (L1 penalty),
|
|
64
|
+
- α = 1: Ridge (L2 penalty),
|
|
65
|
+
- 0 < α < 1: Elastic Net combination.
|
|
66
|
+
|
|
67
|
+
z : np.ndarray or None
|
|
68
|
+
Final estimate of z after regularization (if final is True).
|
|
69
|
+
If skipped, z = zhat.
|
|
70
|
+
|
|
71
|
+
x : np.ndarray or None
|
|
72
|
+
Variable component extracted from z, reshaped into m × p.
|
|
73
|
+
|
|
74
|
+
y : np.ndarray or None
|
|
75
|
+
Slack component of z representing inequality residuals.
|
|
76
|
+
|
|
77
|
+
r : int
|
|
78
|
+
Number of refinement iterations performed during Step 1.
|
|
79
|
+
Iteration stops when NRMSE stabilizes or exceeds the limit.
|
|
80
|
+
|
|
81
|
+
kappaC : float or None
|
|
82
|
+
Condition number of the constraint block C (upper-left block
|
|
83
|
+
in A).
|
|
84
|
+
|
|
85
|
+
kappaB : float or None
|
|
86
|
+
Condition number of the projected estimator B^(r) = pinv(C)·A,
|
|
87
|
+
calculated during refinement.
|
|
88
|
+
|
|
89
|
+
kappaA : float or None
|
|
90
|
+
Condition number of the full canonical matrix A^(r) after
|
|
91
|
+
refinement step r.
|
|
92
|
+
|
|
93
|
+
rmsa : float or None
|
|
94
|
+
Total RMSA (Root Mean Square Adjustment) over all rows.
|
|
95
|
+
|
|
96
|
+
rmsa_i : list of float
|
|
97
|
+
Change in RMSA caused by removing each row from [C | S]
|
|
98
|
+
and re-estimating the CLSP solution.
|
|
99
|
+
|
|
100
|
+
rmsa_dkappaC : list of float
|
|
101
|
+
Change in κ(C) caused by removing each row from [C | S]
|
|
102
|
+
and re-estimating the CLSP solution.
|
|
103
|
+
|
|
104
|
+
rmsa_dkappaB : list of float
|
|
105
|
+
Change in κ(B) caused by removing each row from [C | S]
|
|
106
|
+
and re-estimating the CLSP solution.
|
|
107
|
+
|
|
108
|
+
rmsa_dkappaA : list of float
|
|
109
|
+
Change in κ(A) caused by removing each row from [C | S]
|
|
110
|
+
and re-estimating the CLSP solution.
|
|
111
|
+
|
|
112
|
+
rmsa_dnrmse : list of float
|
|
113
|
+
Change in NRMSE caused by removing each row from [C | S]
|
|
114
|
+
and re-estimating the CLSP solution.
|
|
115
|
+
|
|
116
|
+
rmsa_dzhat : list of float
|
|
117
|
+
Change in zhat caused by removing each row from [C | S]
|
|
118
|
+
and re-estimating the CLSP solution.
|
|
119
|
+
|
|
120
|
+
rmsa_dz : list of float
|
|
121
|
+
Change in z caused by removing each row from [C | S]
|
|
122
|
+
and re-estimating the CLSP solution.
|
|
123
|
+
|
|
124
|
+
rmsa_dx : list of float
|
|
125
|
+
Change in x caused by removing each row from [C | S]
|
|
126
|
+
and re-estimating the CLSP solution.
|
|
127
|
+
|
|
128
|
+
r2_partial : float or None
|
|
129
|
+
R² statistic computed over the M block. Reflects partial
|
|
130
|
+
goodness-of-fit in structured systems.
|
|
131
|
+
|
|
132
|
+
nrmse : float or None
|
|
133
|
+
Normalized RMSE between A·z and b, computed over full system.
|
|
134
|
+
|
|
135
|
+
nrmse_partial : float or None
|
|
136
|
+
NRMSE computed only over M block rows.
|
|
137
|
+
|
|
138
|
+
nrmse_ttest : list of float
|
|
139
|
+
NRMSE samples generated via simulation under the null, used
|
|
140
|
+
for empirical t-testing.
|
|
141
|
+
|
|
142
|
+
z_lower : np.ndarray or None
|
|
143
|
+
Lower bound of confidence band on z. Computed from κ(A) and
|
|
144
|
+
residual norm.
|
|
145
|
+
|
|
146
|
+
z_upper : np.ndarray or None
|
|
147
|
+
Upper bound of confidence band on z. Symmetric to z_lower.
|
|
148
|
+
|
|
149
|
+
seed : int
|
|
150
|
+
Random seed used for reproducible Monte Carlo diagnostics.
|
|
151
|
+
|
|
152
|
+
rng : np.random.Generator
|
|
153
|
+
Random number generator initialized using `seed`.
|
|
154
|
+
|
|
155
|
+
distribution : callable
|
|
156
|
+
Function that generates random samples for simulation.
|
|
157
|
+
Must accept a single integer argument `n`.
|
|
158
|
+
|
|
159
|
+
Methods
|
|
160
|
+
-------
|
|
161
|
+
error : method
|
|
162
|
+
Raise a CLSPError with optional diagnostics or contextual details.
|
|
163
|
+
|
|
164
|
+
canonize : method
|
|
165
|
+
Construct matrix A = [C | S; M | Q] and define constraint partitions.
|
|
166
|
+
|
|
167
|
+
solve : method
|
|
168
|
+
Main estimation routine performing pseudoinverse and convex steps.
|
|
169
|
+
|
|
170
|
+
corr : method
|
|
171
|
+
Compute RMSA and condition diagnostics via leave-one-out row deletion
|
|
172
|
+
on [C | S]. Populates all `self.rmsa_*` lists. Output is external.
|
|
173
|
+
|
|
174
|
+
ttest : method
|
|
175
|
+
Monte Carlo t-test for NRMSE using user-defined distribution. Returns
|
|
176
|
+
one-sided and two-sided p-values based on simulated means.
|
|
177
|
+
"""
|
|
178
|
+
# Variables
|
|
179
|
+
self.A : np.ndarray | None = None # design matrix, [C|S;M|Q]
|
|
180
|
+
self.C_idx : tuple[int | None, int | None] \
|
|
181
|
+
= (None, None) # indices of the C block
|
|
182
|
+
self.b : np.ndarray | None = None # right-hand side
|
|
183
|
+
self.Z : np.ndarray | None = None # B-D subspace matrix
|
|
184
|
+
self.r : int = 0 # number of iterations
|
|
185
|
+
self.zhat : np.ndarray | None = None # first-step estimate
|
|
186
|
+
self.final : bool = True # inclusion of second step
|
|
187
|
+
self.alpha : float = 1.0 # regularization parameter
|
|
188
|
+
self.z : np.ndarray | None = None # final solution
|
|
189
|
+
self.x : np.ndarray | None = None # variable component of z
|
|
190
|
+
self.y : np.ndarray | None = None # slack component of z
|
|
191
|
+
self.kappaC : float | None = None # spectral κ() for C_canon
|
|
192
|
+
self.kappaB : float | None = None # spectral κ() for B^(r)
|
|
193
|
+
self.kappaA : float | None = None # spectral κ() for A^(r)
|
|
194
|
+
self.rmsa : float | None = None # total RMSA
|
|
195
|
+
self.rmsa_i : list[float] = [] # list of row RMSA
|
|
196
|
+
self.rmsa_dkappaC : list[float] = [] # list of Δκ(C)
|
|
197
|
+
self.rmsa_dkappaB : list[float] = [] # list of Δκ(B)
|
|
198
|
+
self.rmsa_dkappaA : list[float] = [] # list of Δκ(A)
|
|
199
|
+
self.rmsa_dnrmse : list[float] = [] # list of ΔNRMSE
|
|
200
|
+
self.rmsa_dzhat : list[float] = [] # list of Δzhat
|
|
201
|
+
self.rmsa_dz : list[float] = [] # list of Δz
|
|
202
|
+
self.rmsa_dx : list[float] = [] # list of Δx
|
|
203
|
+
self.r2_partial : float | None = None # R^2 for the M block
|
|
204
|
+
self.nrmse : float | None = None # NRMSE for A
|
|
205
|
+
self.nrmse_partial : float | None = None # NRMSE for the M block
|
|
206
|
+
self.nrmse_ttest : list[float] = [] # list of NRMSE
|
|
207
|
+
self.z_lower : np.ndarray | None = None # lower confidence band
|
|
208
|
+
self.z_upper : np.ndarray | None = None # upper confidence band
|
|
209
|
+
self.seed : int = 123456789 # Monte Carlo
|
|
210
|
+
self.rng = np.random.default_rng(self.seed)
|
|
211
|
+
self.distribution = lambda n: self.rng.normal(loc=0, scale=1, size=n)
|
|
212
|
+
|
|
213
|
+
# Methods
|
|
214
|
+
self.error = CLSPError
|
|
215
|
+
self.canonize = MethodType(CLSPCanonicalForm, self)
|
|
216
|
+
self.solve = MethodType(CLSPSolve, self)
|
|
217
|
+
self.corr = MethodType(CLSPCorrelogram, self)
|
|
218
|
+
self.ttest = MethodType(CLSPTTest, self)
|
|
219
|
+
|
|
220
|
+
def __repr__(self):
|
|
221
|
+
"""
|
|
222
|
+
Return a formatted string representation of the CLSP object
|
|
223
|
+
"""
|
|
224
|
+
if self.z is None:
|
|
225
|
+
return "<CLSP: z=None, x=None, y=None>"
|
|
226
|
+
|
|
227
|
+
z_str = (np.array2string(self.z, max_line_width=80))
|
|
228
|
+
x_str = (np.array2string(self.x, max_line_width=80)
|
|
229
|
+
if self.x is not None else "None")
|
|
230
|
+
y_str = (np.array2string(self.y, max_line_width=80)
|
|
231
|
+
if self.y is not None else "None")
|
|
232
|
+
return f"<CLSP:\n z={z_str},\n x={x_str},\n y={y_str}>"
|
clsp/errors.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class CLSPError(Exception):
|
|
2
|
+
"""
|
|
3
|
+
Exception class for CLSP-related errors.
|
|
4
|
+
|
|
5
|
+
Represents internal failures in Convex Least Squares Programming
|
|
6
|
+
routines. Supports structured messaging and optional diagnostic
|
|
7
|
+
augmentation.
|
|
8
|
+
|
|
9
|
+
Parameters
|
|
10
|
+
----------
|
|
11
|
+
message : str, optional
|
|
12
|
+
Description of the error. Defaults to a generic CLSP message.
|
|
13
|
+
|
|
14
|
+
code : int or str, optional
|
|
15
|
+
Optional error code or identifier for downstream handling.
|
|
16
|
+
|
|
17
|
+
Usage
|
|
18
|
+
-----
|
|
19
|
+
raise CLSPError("Matrix A and b are incompatible", code=101)
|
|
20
|
+
"""
|
|
21
|
+
def __init__(self, message: str = "An error occurred in CLSP"):
|
|
22
|
+
super().__init__(message)
|
clsp/solver.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
import numpy as np
|
|
3
|
+
import scipy.linalg as la
|
|
4
|
+
import cvxpy as cp
|
|
5
|
+
|
|
6
|
+
def CLSPSolve(
|
|
7
|
+
self, problem: str = "", C: np.ndarray | None = None,
|
|
8
|
+
S: np.ndarray | None = None, M: np.ndarray | None = None,
|
|
9
|
+
b: np.ndarray | None = None,
|
|
10
|
+
m: int | None = None, p: int | None = None,
|
|
11
|
+
i: int = 1, j: int = 1,
|
|
12
|
+
zero_diagonal: bool = False,
|
|
13
|
+
r: int = 1, Z: np.ndarray | None = None,
|
|
14
|
+
tolerance: float | None = None,
|
|
15
|
+
iteration_limit: int | None = None,
|
|
16
|
+
final: bool | None = None, alpha: float | None = None,
|
|
17
|
+
*args, **kwargs
|
|
18
|
+
) -> "CLSP":
|
|
19
|
+
"""
|
|
20
|
+
Solve the Convex Least Squares Programming (CLSP) problem.
|
|
21
|
+
|
|
22
|
+
This method performs a two-step estimation:
|
|
23
|
+
(1) a pseudoinverse-based solution using either the Moore–Penrose or
|
|
24
|
+
Bott–Duffin inverse, optionally iterated for convergence;
|
|
25
|
+
(2) a convex-programming correction using Lasso, Ridge, or Elastic Net
|
|
26
|
+
regularization (if enabled).
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
problem : str, optional
|
|
31
|
+
Structural template for matrix construction. One of:
|
|
32
|
+
- 'ap' or 'tm' : allocation or transaction matrix problem.
|
|
33
|
+
- 'cmls' or 'rp' : constrained modular least squares or RP-type.
|
|
34
|
+
- '' or other: General CLSP problems (user-defined C and/or M).
|
|
35
|
+
|
|
36
|
+
C, S, M : np.ndarray or None
|
|
37
|
+
Blocks of the constraint matrix A = [C | S; M | Q].
|
|
38
|
+
If `C` and/or `M` are provided, the matrix A is constructed
|
|
39
|
+
accordingly. If both are None and A is not yet defined, an error
|
|
40
|
+
is raised.
|
|
41
|
+
|
|
42
|
+
b : np.ndarray or None
|
|
43
|
+
Right-hand side vector. Must have as many rows as A. Required.
|
|
44
|
+
|
|
45
|
+
m, p : int or None
|
|
46
|
+
Dimensions of X ∈ ℝ^{m×p}, relevant for allocation problems ('ap').
|
|
47
|
+
|
|
48
|
+
i, j : int, default = 1
|
|
49
|
+
Grouping sizes for row and column sum constraints in AP problems.
|
|
50
|
+
|
|
51
|
+
zero_diagonal : bool, default = False
|
|
52
|
+
If True, enforces structural zero diagonals via identity truncation.
|
|
53
|
+
|
|
54
|
+
r : int, default = 1
|
|
55
|
+
Number of refinement iterations for the pseudoinverse-based estimator.
|
|
56
|
+
When `r > 1`, the slack block Q is updated iteratively to improve
|
|
57
|
+
feasibility in underdetermined or ill-posed systems.
|
|
58
|
+
|
|
59
|
+
Z : np.ndarray or None
|
|
60
|
+
A symmetric idempotent matrix (projector) defining the subspace for
|
|
61
|
+
Bott–Duffin pseudoinversion. If None, the identity matrix is used,
|
|
62
|
+
reducing to the Moore–Penrose case.
|
|
63
|
+
|
|
64
|
+
tolerance : float, optional
|
|
65
|
+
Convergence tolerance for NRMSE change between iterations. Default is
|
|
66
|
+
√(machine epsilon).
|
|
67
|
+
|
|
68
|
+
iteration_limit : int, default = 50
|
|
69
|
+
Maximum number of iterations allowed in the refinement loop.
|
|
70
|
+
|
|
71
|
+
final : bool, default = True
|
|
72
|
+
If True, a convex programming problem is solved to refine `zhat`.
|
|
73
|
+
The resulting solution `z` minimizes a weighted L1/L2 norm around
|
|
74
|
+
`zhat` subject to Az = b.
|
|
75
|
+
|
|
76
|
+
alpha : float, default = 1.0
|
|
77
|
+
Regularization weight in the final convex program:
|
|
78
|
+
- α = 0: Lasso (L1 norm)
|
|
79
|
+
- α = 1: Ridge (L2 norm)
|
|
80
|
+
- 0 < α < 1: Elastic Net
|
|
81
|
+
|
|
82
|
+
*args, **kwargs : optional
|
|
83
|
+
Additional arguments passed to the CVXPY solver backend.
|
|
84
|
+
|
|
85
|
+
Attributes Set
|
|
86
|
+
---------------
|
|
87
|
+
self.A : np.ndarray
|
|
88
|
+
Canonical design matrix constructed from (C, S, M, Q).
|
|
89
|
+
|
|
90
|
+
self.b : np.ndarray
|
|
91
|
+
Conformable right-hand side vector.
|
|
92
|
+
|
|
93
|
+
self.Z : np.ndarray
|
|
94
|
+
Projector matrix used for Bott–Duffin inversion.
|
|
95
|
+
|
|
96
|
+
self.zhat : np.ndarray
|
|
97
|
+
First-step solution (unregularized pseudoinverse estimate).
|
|
98
|
+
|
|
99
|
+
self.z : np.ndarray
|
|
100
|
+
Final estimate after optional convex refinement.
|
|
101
|
+
|
|
102
|
+
self.x, self.y : np.ndarray
|
|
103
|
+
Variable and slack components reshaped from `z`.
|
|
104
|
+
|
|
105
|
+
self.nrmse : float
|
|
106
|
+
Normalized root mean squared error over the full system.
|
|
107
|
+
|
|
108
|
+
self.r2_partial : float or np.nan
|
|
109
|
+
R² for the M block (if applicable), computed from partial residuals.
|
|
110
|
+
|
|
111
|
+
self.nrmse_partial : float
|
|
112
|
+
NRMSE over the M block, if defined.
|
|
113
|
+
|
|
114
|
+
self.kappaA, self.kappaB, self.kappaC : float
|
|
115
|
+
Condition numbers for the full, projected, and constrained system.
|
|
116
|
+
|
|
117
|
+
self.z_lower, self.z_upper : np.ndarray
|
|
118
|
+
Condition-weighted confidence band for z.
|
|
119
|
+
|
|
120
|
+
self.r : int
|
|
121
|
+
Number of refinement iterations performed.
|
|
122
|
+
|
|
123
|
+
Raises
|
|
124
|
+
------
|
|
125
|
+
CLSPError
|
|
126
|
+
If the design matrix A or right-hand side b is malformed, inconsistent,
|
|
127
|
+
or incompatible with the structural assumptions of the problem.
|
|
128
|
+
"""
|
|
129
|
+
# (A), (b) Construct a conformable canonical form for the CLSP estimator
|
|
130
|
+
if b is not None:
|
|
131
|
+
self.b = b
|
|
132
|
+
elif self.b is None:
|
|
133
|
+
raise self.error("Right-hand side vector b must be provided.")
|
|
134
|
+
if C is not None or M is not None:
|
|
135
|
+
self.canonize(problem, C, S, M, None, self.b.reshape(-1, 1),
|
|
136
|
+
m, p, i, j, zero_diagonal)
|
|
137
|
+
elif self.A is None:
|
|
138
|
+
raise self.error("At least one of C or M must be provided.")
|
|
139
|
+
if self.A.shape[0] != self.b.shape[0]:
|
|
140
|
+
raise self.error(f"The matrix A and vector b must have the same "
|
|
141
|
+
f"number of rows: A has {self.A.shape[0]}, but "
|
|
142
|
+
f"b has {self.b.shape[0]}")
|
|
143
|
+
|
|
144
|
+
# (zhat) (Iterated if r > 1) first-step estimate
|
|
145
|
+
if r < 1:
|
|
146
|
+
raise self.error("Number of refinement iterations r must be ≥ 1.")
|
|
147
|
+
if Z is not None:
|
|
148
|
+
self.Z = Z
|
|
149
|
+
elif self.Z is None:
|
|
150
|
+
self.Z = np.eye(self.A.shape[1])
|
|
151
|
+
if tolerance is not None:
|
|
152
|
+
self.tolerance = tolerance
|
|
153
|
+
if iteration_limit is not None:
|
|
154
|
+
self.iteration_limit = iteration_limit
|
|
155
|
+
try:
|
|
156
|
+
if (not np.allclose(self.Z, self.Z.T, atol=tolerance) or
|
|
157
|
+
not np.allclose(self.Z @ self.Z, self.Z, atol=tolerance) or
|
|
158
|
+
self.Z.shape[0] != self.A.shape[1]):
|
|
159
|
+
raise ValueError
|
|
160
|
+
except ValueError:
|
|
161
|
+
raise self.error(f"Matrix Z must be symmetric, idempotent and "
|
|
162
|
+
f"match the number of columns in A: expected "
|
|
163
|
+
f"({self.A.shape[1]}, {self.A.shape[1]}), "
|
|
164
|
+
f"got {self.Z.shape}")
|
|
165
|
+
for n_iter in range(1, 1 +
|
|
166
|
+
(r if self.A.shape[0] > self.C_idx[0] else 1)):
|
|
167
|
+
# save A, zhat, and NRMSE from the previous step, construct Q
|
|
168
|
+
if n_iter > 1:
|
|
169
|
+
A_prev = self.A.copy()
|
|
170
|
+
zhat_prev = self.zhat.copy()
|
|
171
|
+
nrmse_prev = (np.linalg.norm(self.b - self.A @ self.zhat) /
|
|
172
|
+
np.sqrt(self.b.shape[0]) / np.std(self.b))
|
|
173
|
+
Q = np.diagflat(-np.sign(self.b -
|
|
174
|
+
self.A @ self.zhat)[self.C_idx[0]:])
|
|
175
|
+
self.canonize(problem, C, S, M, Q, b.reshape(-1, 1),
|
|
176
|
+
m, p, i, j, zero_diagonal)
|
|
177
|
+
# solve via the Bott–Duffin inverse
|
|
178
|
+
self.zhat = (la.pinv(self.Z @ (self.A.T @ self.A) @ self.Z) @
|
|
179
|
+
self.Z @ self.A.T) @ self.b
|
|
180
|
+
self.nrmse = (lambda residuals, sd:
|
|
181
|
+
np.linalg.norm(residuals) / np.sqrt(sd.shape[0]) /
|
|
182
|
+
np.std(sd) if not np.isclose(np.std(sd), 0) else
|
|
183
|
+
np.inf)(self.b - self.A @ self.zhat, self.b)
|
|
184
|
+
# break on convergence
|
|
185
|
+
self.r = n_iter
|
|
186
|
+
if n_iter > 1:
|
|
187
|
+
if (abs(self.nrmse - nrmse_prev) < self.tolerance or
|
|
188
|
+
n_iter > self.iteration_limit):
|
|
189
|
+
del A_prev, zhat_prev, nrmse_prev, Q
|
|
190
|
+
break
|
|
191
|
+
if not np.all(np.isfinite(self.zhat)):
|
|
192
|
+
self.zhat = np.nan
|
|
193
|
+
raise self.error("Pseudoinverse estimate zhat failed")
|
|
194
|
+
|
|
195
|
+
# (z) Final solution (if available), or set self.z = self.zhat
|
|
196
|
+
if final is not None:
|
|
197
|
+
self.final = final
|
|
198
|
+
if alpha is not None:
|
|
199
|
+
self.alpha = alpha
|
|
200
|
+
self.alpha = max(0, min(1, self.alpha))
|
|
201
|
+
if self.final:
|
|
202
|
+
# build a convex problem (p_cvx) and its solver (c_cvx)
|
|
203
|
+
z_cvx = cp.Variable(self.A.shape[1])
|
|
204
|
+
d_cvx = z_cvx - self.zhat.flatten()
|
|
205
|
+
if np.isclose(self.alpha, 0): # Lasso
|
|
206
|
+
f_obj = cp.norm1(d_cvx)
|
|
207
|
+
s_cvx = cp.ECOS
|
|
208
|
+
elif np.isclose(self.alpha, 1): # Ridge
|
|
209
|
+
f_obj = cp.sum_squares(d_cvx)
|
|
210
|
+
s_cvx = cp.OSQP
|
|
211
|
+
else: # Elastic Net
|
|
212
|
+
f_obj = ((1 - self.alpha) * cp.norm1(d_cvx) +
|
|
213
|
+
self.alpha * cp.sum_squares(d_cvx))
|
|
214
|
+
s_cvx = cp.SCS
|
|
215
|
+
c_cvx = [self.A @ z_cvx == self.b.flatten()]
|
|
216
|
+
p_cvx = cp.Problem(cp.Minimize(f_obj), c_cvx)
|
|
217
|
+
# solve
|
|
218
|
+
try:
|
|
219
|
+
p_cvx.solve(solver=s_cvx, verbose=False,
|
|
220
|
+
*args, **kwargs) # pass arguments
|
|
221
|
+
if z_cvx.value is None:
|
|
222
|
+
warnings.warn(
|
|
223
|
+
f"Step 2 infeasible ({p_cvx.status}); falling back to Step 1.",
|
|
224
|
+
category=RuntimeWarning
|
|
225
|
+
)
|
|
226
|
+
self.z = self.zhat
|
|
227
|
+
else:
|
|
228
|
+
self.z = z_cvx.value
|
|
229
|
+
self.nrmse = (lambda residuals, sd:
|
|
230
|
+
np.linalg.norm(residuals) / np.sqrt(sd.shape[0]) /
|
|
231
|
+
np.std(sd) if not np.isclose(np.std(sd), 0) else
|
|
232
|
+
np.inf)(self.b - self.A @ self.z, self.b)
|
|
233
|
+
except (cp.SolverError, ValueError):
|
|
234
|
+
self.z = self.zhat
|
|
235
|
+
else:
|
|
236
|
+
self.z = self.zhat
|
|
237
|
+
|
|
238
|
+
# (x), (y) Variable and slack components of z
|
|
239
|
+
self.x = self.z[:self.C_idx[0]].reshape(m if m is not None else -1,
|
|
240
|
+
p if p is not None else 1)
|
|
241
|
+
self.y = self.z[self.C_idx[0]:]
|
|
242
|
+
|
|
243
|
+
# (kappaC), (kappaB), (kappaA) Condition numbers
|
|
244
|
+
self.kappaC = np.linalg.cond( self.A[:self.C_idx[0], :])
|
|
245
|
+
self.kappaB = np.linalg.cond(la.pinv(self.A[:self.C_idx[0], :]) @
|
|
246
|
+
self.A)
|
|
247
|
+
self.kappaA = np.linalg.cond( self.A)
|
|
248
|
+
|
|
249
|
+
# (r2_partial), (nrmse_partial) M-block-based statistics
|
|
250
|
+
if self.A.shape[0] > self.C_idx[0]:
|
|
251
|
+
M = self.A[self.C_idx[0]:, :self.C_idx[1]]
|
|
252
|
+
b_M = self.b[-M.shape[0]:]
|
|
253
|
+
residuals_M = b_M - M @ self.x.reshape(-1, 1)
|
|
254
|
+
self.r2_partial = (lambda residuals, sd:
|
|
255
|
+
1 - (np.linalg.norm(residuals) ** 2 /
|
|
256
|
+
np.linalg.norm(sd - np.mean(sd)) ** 2)
|
|
257
|
+
if not np.isclose(np.std(sd), 0) else
|
|
258
|
+
np.nan)(residuals_M, b_M)
|
|
259
|
+
self.nrmse_partial = (lambda residuals, sd:
|
|
260
|
+
np.linalg.norm(residuals) / np.sqrt(sd.shape[0]) /
|
|
261
|
+
np.std(sd) if not np.isclose(np.std(sd), 0) else
|
|
262
|
+
np.inf)(residuals_M, b_M)
|
|
263
|
+
del M, b_M, residuals_M
|
|
264
|
+
|
|
265
|
+
# (z_lower), (z_upper) Condition-weighted confidence band
|
|
266
|
+
dz = (self.kappaA *
|
|
267
|
+
np.linalg.norm(self.b - self.A @ self.z) /
|
|
268
|
+
np.linalg.norm(self.b)
|
|
269
|
+
if not np.isclose(np.linalg.norm(self.b), 0) else
|
|
270
|
+
np.inf)
|
|
271
|
+
self.z_lower = self.z * (1 - dz)
|
|
272
|
+
self.z_upper = self.z * (1 + dz)
|
|
273
|
+
|
|
274
|
+
return self
|
clsp/utils.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import numpy as np
|
|
3
|
+
import scipy.stats as stats
|
|
4
|
+
|
|
5
|
+
def CLSPCanonicalForm(
|
|
6
|
+
self, problem: str = "", C: np.ndarray | None = None,
|
|
7
|
+
S: np.ndarray | None = None, M: np.ndarray | None = None,
|
|
8
|
+
Q: np.ndarray | None = None, b: np.ndarray | None = None,
|
|
9
|
+
m: int | None = None, p: int | None = None,
|
|
10
|
+
i: int = 1, j: int = 1,
|
|
11
|
+
zero_diagonal: bool = False
|
|
12
|
+
) -> None:
|
|
13
|
+
"""
|
|
14
|
+
Construct the canonical design matrix A = [C | S; M | Q] for CLSP.
|
|
15
|
+
|
|
16
|
+
This method assembles the constraint matrix A from user-supplied or
|
|
17
|
+
internally generated components — C, S, M, and Q — and assigns the
|
|
18
|
+
corresponding right-hand side vector b. It is a required pre-step
|
|
19
|
+
before solving a Convex Least Squares Programming (CLSP) problem.
|
|
20
|
+
|
|
21
|
+
Depending on the specified problem type, it can generate allocation,
|
|
22
|
+
transaction, or modular constraints and enforce optional diagonal
|
|
23
|
+
exclusions. All missing blocks are padded to ensure conformability.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
problem : str, optional
|
|
28
|
+
Structural template for matrix construction. One of:
|
|
29
|
+
- 'ap' or 'tm' : allocation or transaction matrix problem.
|
|
30
|
+
- 'cmls' or 'rp' : constrained modular least squares or RP-type.
|
|
31
|
+
- '' or other: General CLSP problems (user-defined C and/or M).
|
|
32
|
+
|
|
33
|
+
C, S, M : np.ndarray or None
|
|
34
|
+
Blocks of the constraint matrix A = [C | S; M | Q].
|
|
35
|
+
If `C` and/or `M` are provided, the matrix A is constructed
|
|
36
|
+
accordingly. If both are None and A is not yet defined, an error
|
|
37
|
+
is raised.
|
|
38
|
+
|
|
39
|
+
Q : np.ndarray or None
|
|
40
|
+
Externally supplied residual slack matrix used to adjust inequality
|
|
41
|
+
constraints in M. Required only when r > 1. Encodes the sign pattern
|
|
42
|
+
of residuals from the previous iteration and is used to construct the
|
|
43
|
+
[C | S; M | Q] canonical form. Defaults to a conformable zero matrix
|
|
44
|
+
on the first iteration.
|
|
45
|
+
|
|
46
|
+
b : np.ndarray or None
|
|
47
|
+
Right-hand side vector. Must have as many rows as A. Required.
|
|
48
|
+
|
|
49
|
+
m, p : int or None
|
|
50
|
+
Dimensions of X ∈ ℝ^{m×p}, relevant for allocation problems ('ap').
|
|
51
|
+
|
|
52
|
+
i, j : int, default = 1
|
|
53
|
+
Grouping sizes for row and column sum constraints in AP problems.
|
|
54
|
+
|
|
55
|
+
zero_diagonal : bool, default = False
|
|
56
|
+
If True, enforces structural zero diagonals via identity truncation.
|
|
57
|
+
|
|
58
|
+
Attributes Set
|
|
59
|
+
---------------
|
|
60
|
+
self.A : np.ndarray
|
|
61
|
+
Canonical design matrix constructed from (C, S, M, Q).
|
|
62
|
+
|
|
63
|
+
self.C_idx : tuple
|
|
64
|
+
Tuple (rows, cols) indicating the size of the C block.
|
|
65
|
+
|
|
66
|
+
self.b : np.ndarray
|
|
67
|
+
Conformable right-hand side vector.
|
|
68
|
+
|
|
69
|
+
Raises
|
|
70
|
+
------
|
|
71
|
+
CLSPError
|
|
72
|
+
If the design matrix A or right-hand side b is malformed, inconsistent,
|
|
73
|
+
or incompatible with the structural assumptions of the problem.
|
|
74
|
+
"""
|
|
75
|
+
# (b) Ensure the right-hand side is defined and set `self.b`
|
|
76
|
+
if b is None:
|
|
77
|
+
raise self.error("Right-hand side vector b must be provided.")
|
|
78
|
+
self.b = b
|
|
79
|
+
|
|
80
|
+
# (A) Option 1. AP (TM) problems with an optional zero diagonal
|
|
81
|
+
if 'ap' in problem.lower() or 'tm' in problem.lower():
|
|
82
|
+
if m is None or p is None:
|
|
83
|
+
raise self.error("Both m and p must be specified.")
|
|
84
|
+
if m % i != 0:
|
|
85
|
+
raise self.error(f"m = {m} must be divisible by i = {i}")
|
|
86
|
+
if p % j != 0:
|
|
87
|
+
raise self.error(f"p = {p} must be divisible by j = {j}")
|
|
88
|
+
# construct the C block using Kronecker product
|
|
89
|
+
row_groups = np.kron(np.kron(np.eye(m // i), np.ones((1, i))),
|
|
90
|
+
np.ones((1, p)))
|
|
91
|
+
col_groups = np.kron(np.ones((1, m)), np.kron(np.eye(p // j),
|
|
92
|
+
np.ones((1, j))))
|
|
93
|
+
C = np.vstack([row_groups, col_groups])
|
|
94
|
+
# append an optional identity matrix to M, remove duplicates
|
|
95
|
+
if zero_diagonal:
|
|
96
|
+
M_diag = np.hstack([np.diagflat(np.eye(1, m, k))
|
|
97
|
+
for k in range(p)])
|
|
98
|
+
M = (M_diag.copy() if M is None or M.size == 0 else
|
|
99
|
+
np.unique(np.vstack([M, M_diag]), axis=0))
|
|
100
|
+
b = np.vstack([b, np.zeros((m, 1))])
|
|
101
|
+
del M_diag
|
|
102
|
+
|
|
103
|
+
# (A) Option 2. CMLS and RP problems
|
|
104
|
+
if 'cmls' in problem.lower() or 'rp' in problem.lower():
|
|
105
|
+
if C is None or M is None:
|
|
106
|
+
raise self.error("Both C and M must be provided.")
|
|
107
|
+
if C.shape[0] % M.shape[0] != 0:
|
|
108
|
+
raise self.error(f"Row mismatch: rows(C) = {C.shape[0]} must be "
|
|
109
|
+
f"divisible by rows(M) = {M.shape[0]}")
|
|
110
|
+
|
|
111
|
+
# (A) Option 3. General problems
|
|
112
|
+
if C is None and M is None:
|
|
113
|
+
raise self.error("At least one of C or M must be provided.")
|
|
114
|
+
|
|
115
|
+
# (A) Convert missing blocks to conformable zero matrices
|
|
116
|
+
n_col = C.shape[1] if C is not None else M.shape[1]
|
|
117
|
+
C = C if C is not None else np.zeros((0, n_col))
|
|
118
|
+
M = M if M is not None else np.zeros((0, n_col))
|
|
119
|
+
S = S if S is not None else np.zeros((C.shape[0], 0))
|
|
120
|
+
Q = Q if Q is not None else np.zeros((M.shape[0], 0))
|
|
121
|
+
if C.shape[0] != S.shape[0]:
|
|
122
|
+
raise self.error(f"C and S must have the same number of rows: "
|
|
123
|
+
f"{C.shape[0]} vs {S.shape[0]}")
|
|
124
|
+
if C.shape[1] != M.shape[1]:
|
|
125
|
+
raise self.error(f"C and M must have the same number of columns: "
|
|
126
|
+
f"{C.shape[1]} vs {M.shape[1]}")
|
|
127
|
+
|
|
128
|
+
# (A) Pad C and Q with zeros and set `self.A` and `self.C_idx`
|
|
129
|
+
self.A = np.vstack([
|
|
130
|
+
np.hstack([C, S, np.zeros((C.shape[0], Q.shape[1]))]),
|
|
131
|
+
np.hstack([M, np.zeros((M.shape[0], S.shape[1])), Q])
|
|
132
|
+
])
|
|
133
|
+
self.C_idx = C.shape
|
|
134
|
+
|
|
135
|
+
def CLSPCorrelogram(
|
|
136
|
+
self, reset: bool = False, threshold: float = 0,
|
|
137
|
+
) -> dict[str, list[float]]:
|
|
138
|
+
"""
|
|
139
|
+
Compute the structural correlogram of the CLSP constraint system.
|
|
140
|
+
|
|
141
|
+
This method performs a row-deletion sensitivity analysis on the canonical
|
|
142
|
+
constraint matrix [C | S], denoted as C_canon, and evaluates the marginal
|
|
143
|
+
effect of each constraint row on numerical stability, angular alignment,
|
|
144
|
+
and estimator sensitivity.
|
|
145
|
+
|
|
146
|
+
For each row i in C_canon, it computes:
|
|
147
|
+
- The Root Mean Square Alignment (RMSA_i) with all other rows j ≠ i.
|
|
148
|
+
- The change in condition numbers κ(C), κ(B), and κ(A) when row i is
|
|
149
|
+
deleted.
|
|
150
|
+
- The effect on estimation quality: changes in NRMSE, zhat, z, and x.
|
|
151
|
+
|
|
152
|
+
Additionally, it computes the total RMSA statistic across all rows,
|
|
153
|
+
summarizing the overall angular alignment of the constraint block.
|
|
154
|
+
|
|
155
|
+
Parameters
|
|
156
|
+
----------
|
|
157
|
+
reset : bool, default = False
|
|
158
|
+
If True, forces recomputation of all diagnostic values.
|
|
159
|
+
|
|
160
|
+
threshold : float, default = 0
|
|
161
|
+
If positive, limits the output to constraints with RMSA_i ≥ threshold.
|
|
162
|
+
|
|
163
|
+
Returns
|
|
164
|
+
-------
|
|
165
|
+
dict of list
|
|
166
|
+
A dictionary containing per-row diagnostic values:
|
|
167
|
+
{
|
|
168
|
+
"constraint" : [1, 2, ..., k], # 1-based indices
|
|
169
|
+
"rmsa_i" : list of RMSA_i values,
|
|
170
|
+
"rmsa_dkappaC" : list of Δκ(C) after deleting row i,
|
|
171
|
+
"rmsa_dkappaB" : list of Δκ(B) after deleting row i,
|
|
172
|
+
"rmsa_dkappaA" : list of Δκ(A) after deleting row i,
|
|
173
|
+
"rmsa_dnrmse" : list of ΔNRMSE after deleting row i,
|
|
174
|
+
"rmsa_dzhat" : list of Δzhat after deleting row i,
|
|
175
|
+
"rmsa_dz" : list of Δz after deleting row i,
|
|
176
|
+
"rmsa_dx" : list of Δx after deleting row i,
|
|
177
|
+
}
|
|
178
|
+
"""
|
|
179
|
+
# (RMSA) Total RMSA
|
|
180
|
+
if self.rmsa == None or reset:
|
|
181
|
+
k = self.C_idx[0]
|
|
182
|
+
p = self.C_idx[1]
|
|
183
|
+
C_canon = self.A[:k]
|
|
184
|
+
norms = np.linalg.norm(C_canon, axis=1)
|
|
185
|
+
self.rmsa = (lambda C: np.sqrt(np.sum([(np.dot(
|
|
186
|
+
C[i] / norms[i], C[j] / norms[j])
|
|
187
|
+
) ** 2 for i in range(0, k - 1)
|
|
188
|
+
for j in range(i + 1, k )
|
|
189
|
+
]) * 2 / k / (k - 1)))(C_canon)
|
|
190
|
+
|
|
191
|
+
# (RMSA) Constraint-wise RMSA, changes in condition numbers, and GoF
|
|
192
|
+
if len(self.rmsa_i) != self.C_idx[0] or reset:
|
|
193
|
+
tmp = copy.deepcopy(self)
|
|
194
|
+
k = self.C_idx[0]
|
|
195
|
+
p = self.C_idx[1]
|
|
196
|
+
C_canon = self.A[:k]
|
|
197
|
+
norms = np.linalg.norm(C_canon, axis=1)
|
|
198
|
+
self.rmsa_i = [None] * k
|
|
199
|
+
self.rmsa_dkappaC = [None] * k
|
|
200
|
+
self.rmsa_dkappaB = [None] * k
|
|
201
|
+
self.rmsa_dkappaA = [None] * k
|
|
202
|
+
self.rmsa_dnrmse = [None] * k
|
|
203
|
+
self.rmsa_dzhat = [None] * k
|
|
204
|
+
self.rmsa_dz = [None] * k
|
|
205
|
+
self.rmsa_dx = [None] * k
|
|
206
|
+
for i in range(k):
|
|
207
|
+
tmp.A = np.delete(self.A, i, axis=0)
|
|
208
|
+
tmp.b = np.delete(self.b, i, axis=0)
|
|
209
|
+
tmp.C_idx = (k - 1, p)
|
|
210
|
+
tmp.solve()
|
|
211
|
+
self.rmsa_i[i] = (lambda C, i: np.sqrt(np.sum([(np.dot(
|
|
212
|
+
C[i] / norms[i], C[j] / norms[j])
|
|
213
|
+
) ** 2 for j in range(k) if j != i
|
|
214
|
+
]) / (k - 1)))(C_canon, i)
|
|
215
|
+
self.rmsa_dkappaC[i] = tmp.kappaC - self.kappaC
|
|
216
|
+
self.rmsa_dkappaB[i] = tmp.kappaB - self.kappaB
|
|
217
|
+
self.rmsa_dkappaA[i] = tmp.kappaA - self.kappaA
|
|
218
|
+
self.rmsa_dnrmse[i] = tmp.nrmse - self.nrmse
|
|
219
|
+
self.rmsa_dzhat[i] = tmp.zhat - self.zhat
|
|
220
|
+
self.rmsa_dz[i] = tmp.z - self.z
|
|
221
|
+
self.rmsa_dx[i] = tmp.x - self.x
|
|
222
|
+
|
|
223
|
+
# Return the correlogram
|
|
224
|
+
indices = [i for i, r in enumerate(self.rmsa_i) if r >= threshold]
|
|
225
|
+
return {
|
|
226
|
+
"constraint" : [i + 1 for i in indices], # 1-based indexing
|
|
227
|
+
"rmsa_i" : [self.rmsa_i[i] for i in indices],
|
|
228
|
+
"rmsa_dkappaC": [self.rmsa_dkappaC[i] for i in indices],
|
|
229
|
+
"rmsa_dkappaB": [self.rmsa_dkappaB[i] for i in indices],
|
|
230
|
+
"rmsa_dkappaA": [self.rmsa_dkappaA[i] for i in indices],
|
|
231
|
+
"rmsa_dnrmse" : [self.rmsa_dnrmse[i] for i in indices],
|
|
232
|
+
"rmsa_dzhat" : [self.rmsa_dzhat[i] for i in indices],
|
|
233
|
+
"rmsa_dz" : [self.rmsa_dz[i] for i in indices],
|
|
234
|
+
"rmsa_dx" : [self.rmsa_dx[i] for i in indices],
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
def CLSPTTest(
|
|
238
|
+
self, reset: bool = False, sample_size: int = 50,
|
|
239
|
+
seed: int | None = None, distribution: str | None = None
|
|
240
|
+
) -> dict[str, float]:
|
|
241
|
+
"""
|
|
242
|
+
Perform Monte Carlo t-tests on the NRMSE statistic from the CLSP estimator.
|
|
243
|
+
|
|
244
|
+
This function generates synthetic right-hand side vectors `b` using a
|
|
245
|
+
user-defined or default distribution and recomputes the estimator. It
|
|
246
|
+
tests whether the observed NRMSE significantly deviates from the null
|
|
247
|
+
distribution of simulated NRMSE values.
|
|
248
|
+
|
|
249
|
+
Parameters
|
|
250
|
+
----------
|
|
251
|
+
reset : bool, default = False
|
|
252
|
+
If True, forces recomputation of the NRMSE null distribution.
|
|
253
|
+
|
|
254
|
+
sample_size : int, default = 50
|
|
255
|
+
Size of the Monte Carlo simulated sample under H₀.
|
|
256
|
+
|
|
257
|
+
seed : int or None, optional
|
|
258
|
+
Optional random seed to override the default.
|
|
259
|
+
|
|
260
|
+
distribution : str or None
|
|
261
|
+
Distribution for generating synthetic b vectors. One of:
|
|
262
|
+
'normal', 'uniform', 'laplace'. Defaults to standard normal.
|
|
263
|
+
|
|
264
|
+
Returns
|
|
265
|
+
-------
|
|
266
|
+
dict
|
|
267
|
+
Dictionary with test results and null distribution statistics:
|
|
268
|
+
{
|
|
269
|
+
'p_one_left' : P(nrmse ≤ null mean),
|
|
270
|
+
'p_one_right' : P(nrmse ≥ null mean),
|
|
271
|
+
'p_two_sided' : 2-sided t-test p-value,
|
|
272
|
+
'nrmse' : observed value,
|
|
273
|
+
'mean_null' : mean of null distribution,
|
|
274
|
+
'std_null' : std of null distribution
|
|
275
|
+
}
|
|
276
|
+
"""
|
|
277
|
+
# Set the seed, RNG configuration, and distribution
|
|
278
|
+
if seed is not None:
|
|
279
|
+
self.seed = seed
|
|
280
|
+
self.rng = np.random.default_rng(self.seed)
|
|
281
|
+
if distribution is not None:
|
|
282
|
+
dist_fn = {
|
|
283
|
+
"normal": lambda n: self.rng.normal(loc=0, scale=1, size=(n, 1)),
|
|
284
|
+
"uniform": lambda n: self.rng.uniform(-1, 1, size=(n, 1)),
|
|
285
|
+
"laplace": lambda n: self.rng.laplace(loc=0, scale=1, size=(n, 1))
|
|
286
|
+
}.get(distribution.lower())
|
|
287
|
+
if dist_fn is None:
|
|
288
|
+
raise self.error(f"Unsupported distribution: {distribution}")
|
|
289
|
+
|
|
290
|
+
# (t-test) Simulated NRMSE distribution under H0
|
|
291
|
+
if len(self.nrmse_ttest) != sample_size or reset:
|
|
292
|
+
tmp = copy.deepcopy(self)
|
|
293
|
+
self.nrmse_ttest = [None] * sample_size
|
|
294
|
+
for i in range(sample_size):
|
|
295
|
+
tmp.b = dist_fn(self.b.shape[0])
|
|
296
|
+
tmp.solve()
|
|
297
|
+
self.nrmse_ttest[i] = tmp.nrmse
|
|
298
|
+
|
|
299
|
+
# Return the t-test
|
|
300
|
+
nrmse_null = np.array(self.nrmse_ttest)
|
|
301
|
+
mean_null = np.mean(nrmse_null)
|
|
302
|
+
std_null = np.std(nrmse_null, ddof=1)
|
|
303
|
+
t_stat = (self.nrmse - mean_null) / (std_null / np.sqrt(sample_size))
|
|
304
|
+
p_left = stats.t.cdf(t_stat, df=sample_size - 1)
|
|
305
|
+
p_right = 1 - p_left
|
|
306
|
+
p_two = stats.t.sf(abs(t_stat), df=sample_size - 1) * 2
|
|
307
|
+
return {
|
|
308
|
+
"p_one_left" : p_left,
|
|
309
|
+
"p_one_right": p_right,
|
|
310
|
+
"p_two_sided": p_two,
|
|
311
|
+
"nrmse" : self.nrmse,
|
|
312
|
+
"mean_null" : mean_null,
|
|
313
|
+
"std_null" : std_null
|
|
314
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyclsp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Modular Two-Step Convex Optimization Estimator for Ill-Posed Problems
|
|
5
|
+
Author-email: The Economist <29724411+econcz@users.noreply.github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/econcz/clsp
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/econcz/clsp/issues
|
|
9
|
+
Keywords: estimators,convex-optimization,least-squares,generalized-inverse,regularization
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: numpy>=1.24
|
|
21
|
+
Requires-Dist: scipy>=1.10
|
|
22
|
+
Requires-Dist: cvxpy>=1.3
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# CLSP — Convex Least Squares Programming
|
|
26
|
+
|
|
27
|
+
The **Convex Least Squares Programming (CLSP)** estimator is a two-step method for solving underdetermined, ill-posed, or structurally constrained least-squares problems. It combines pseudoinverse-based estimation with convex-programming correction (e.g., Lasso, Ridge, Elastic Net) to ensure numerical stability, structural coherence, and enhanced interpretability.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install clsp
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick Example
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import numpy as np
|
|
39
|
+
from clsp import CLSP
|
|
40
|
+
|
|
41
|
+
# Example allocation problem
|
|
42
|
+
b = np.array([1, 2, 3, 7, 8, 9], dtype=float)
|
|
43
|
+
|
|
44
|
+
# Initialize estimator
|
|
45
|
+
model = CLSP()
|
|
46
|
+
|
|
47
|
+
# Solve the system
|
|
48
|
+
result = model.solve(problem='ap', b=b, m=3, p=3)
|
|
49
|
+
|
|
50
|
+
# Access diagnostics
|
|
51
|
+
print(model.nrmse)
|
|
52
|
+
print(model.ttest())
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## User Reference
|
|
57
|
+
|
|
58
|
+
For comprehensive information on the estimator’s capabilities, advanced configuration options, and implementation details, please refer to the docstrings provided in each of the individual .py source files. These docstrings contain complete descriptions of available methods, their parameters, expected input formats, and output structures.
|
|
59
|
+
|
|
60
|
+
### The `CLSP` Class
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
self.__init__()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Stores the solution, goodness-of-fit statistics, and ancillary parameters.
|
|
67
|
+
|
|
68
|
+
The class has three core methods: `solve()`, `corr()`, and `ttest()`.
|
|
69
|
+
|
|
70
|
+
**Selected attributes:**
|
|
71
|
+
`self.A` : *np.ndarray*
|
|
72
|
+
design matrix `A` = [`C` | `S`; `M` | `Q`], where `Q` is either a zero matrix or *S_residual*.
|
|
73
|
+
|
|
74
|
+
`self.b` : *np.ndarray*
|
|
75
|
+
vector of the right-hand side.
|
|
76
|
+
|
|
77
|
+
`self.zhat` : *np.ndarray*
|
|
78
|
+
vector of the first-step estimate.
|
|
79
|
+
|
|
80
|
+
`self.r` : *int*
|
|
81
|
+
number of refinement iterations performed in the first step.
|
|
82
|
+
|
|
83
|
+
`self.z` : *np.ndarray*
|
|
84
|
+
vector of the final solution. If the second step is disabled, it equals `self.zhat`.
|
|
85
|
+
|
|
86
|
+
`self.x` : *np.ndarray*
|
|
87
|
+
`m` x `p` matrix or vector containing the variable component of `z`.
|
|
88
|
+
|
|
89
|
+
`self.y` : *np.ndarray*
|
|
90
|
+
vector containing the slack component of `z`.
|
|
91
|
+
|
|
92
|
+
`self.kappaC` : *float*
|
|
93
|
+
spectral κ() for *C_canon*.
|
|
94
|
+
|
|
95
|
+
`self.kappaB` : *float*
|
|
96
|
+
spectral κ() for *B* = *C_canon^+*`A`.
|
|
97
|
+
|
|
98
|
+
`self.kappaA` : *float*
|
|
99
|
+
spectral κ() for `A`.
|
|
100
|
+
|
|
101
|
+
`self.rmsa` : *float*
|
|
102
|
+
total root mean square alignment (RMSA).
|
|
103
|
+
|
|
104
|
+
`self.r2_partial` : *float*
|
|
105
|
+
R^2 for the `M` block in `A`.
|
|
106
|
+
|
|
107
|
+
`self.nrmse` : *float*
|
|
108
|
+
mean square error calculated from `A` and normalized by standard deviation (NRMSE).
|
|
109
|
+
|
|
110
|
+
`self.nrmse_partial` : *float*
|
|
111
|
+
mean square error calculated from the `M` block in `A` and normalized by standard deviation (NRMSE).
|
|
112
|
+
|
|
113
|
+
`self.z_lower` : *np.ndarray*
|
|
114
|
+
lower bound of the diagnostic interval (confidence band) based on κ(`A`).
|
|
115
|
+
|
|
116
|
+
`self.z_upper` : *np.ndarray*
|
|
117
|
+
upper bound of the diagnostic interval (confidence band) based on κ(`A`).
|
|
118
|
+
|
|
119
|
+
### Solver Method: `solve()`
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
self.solve(problem, C, S, M, b, m, p, i, j, zero_diagonal, r, Z, tolerance, iteration_limit, final, alpha)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Solves the Convex Least Squares Programming (CLSP) problem.
|
|
126
|
+
|
|
127
|
+
This method performs a two-step estimation:
|
|
128
|
+
(1) a pseudoinverse-based solution using either the Moore–Penrose or Bott–Duffin inverse, optionally iterated for refinement;
|
|
129
|
+
(2) a convex-programming correction using Lasso, Ridge, or Elastic Net regularization (if enabled).
|
|
130
|
+
|
|
131
|
+
**Parameters:**
|
|
132
|
+
`problem` : *str*, optional
|
|
133
|
+
Structural template for matrix construction. One of:
|
|
134
|
+
- *'ap'* or *'tm'* : allocation (transaction) matrix problem (AP).
|
|
135
|
+
- *'cmls'* or *'rp'* : constrained-model least squares (regression) problem.
|
|
136
|
+
- anything else: general CLSP problem (user-defined `C` and/or `M`).
|
|
137
|
+
|
|
138
|
+
`C`, `S`, `M` : *np.ndarray* or *None*
|
|
139
|
+
Blocks of the design matrix `A` = [`C` | `S`; `M` | `Q`]. If `C` and/or `M` are provided, the matrix `A` is constructed accordingly (please note that for AP, `C` is constructed automatically and known values are specified in `M`).
|
|
140
|
+
|
|
141
|
+
`b` : *np.ndarray* or *None*
|
|
142
|
+
Right-hand side vector. Must have as many rows as `A` (please note that for AP, it should start with row sums). Required.
|
|
143
|
+
|
|
144
|
+
`m`, `p` : *int* or *None*
|
|
145
|
+
Dimensions of X ∈ ℝ^{m×p}, relevant for AP.
|
|
146
|
+
|
|
147
|
+
`i`, `j` : *int*, default = *1*
|
|
148
|
+
Grouping sizes for row and column sum constraints in AP.
|
|
149
|
+
|
|
150
|
+
`zero_diagonal` : *bool*, default = *False*
|
|
151
|
+
If *True*, enforces structural zero diagonals.
|
|
152
|
+
|
|
153
|
+
`r` : *int*, default = *1*
|
|
154
|
+
Number of refinement iterations for the pseudoinverse-based estimator.
|
|
155
|
+
|
|
156
|
+
`Z` : *np.ndarray* or *None*
|
|
157
|
+
A symmetric idempotent matrix (projector) defining the subspace for Bott–Duffin pseudoinversion. If *None*, the identity matrix is used, reducing the Bott–Duffin inverse to the Moore–Penrose case.
|
|
158
|
+
|
|
159
|
+
`tolerance` : *float*, default = *square root of machine epsilon*
|
|
160
|
+
Convergence tolerance for NRMSE change between refinement iterations.
|
|
161
|
+
|
|
162
|
+
`iteration_limit` : *int*, default = *50*
|
|
163
|
+
Maximum number of iterations allowed in the refinement loop.
|
|
164
|
+
|
|
165
|
+
`final` : *bool*, default = *True*
|
|
166
|
+
If *True*, a convex programming problem is solved to refine `zhat`. The resulting solution `z` minimizes a weighted L1/L2 norm around `zhat` subject to `Az` = `b`.
|
|
167
|
+
|
|
168
|
+
`alpha` : *float*, default = *1.0*
|
|
169
|
+
Regularization parameter (weight) in the final convex program:
|
|
170
|
+
- `α = 0`: Lasso (L1 norm)
|
|
171
|
+
- `α = 1`: Tikhonov Regularization/Ridge (L2 norm)
|
|
172
|
+
- `0 < α < 1`: Elastic Net
|
|
173
|
+
|
|
174
|
+
`*args`, `**kwargs` : optional
|
|
175
|
+
CVXPY arguments passed to the CVXPY solver.
|
|
176
|
+
|
|
177
|
+
**Returns:**
|
|
178
|
+
*self*
|
|
179
|
+
|
|
180
|
+
### Correlogram Method: `corr()`
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
self.corr(reset, threshold)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Computes the structural correlogram of the CLSP constraint part.
|
|
187
|
+
|
|
188
|
+
This method performs a row-deletion sensitivity analysis on the canonical constraint matrix `[C` | `S`], denoted as *C_canon*, and evaluates the marginal effect of each constraint row on numerical stability, angular alignment, and estimator sensitivity.
|
|
189
|
+
|
|
190
|
+
For each row `i` in `C_canon`, it computes:
|
|
191
|
+
- The Root Mean Square Alignment (`RMSA_i`) with all other rows `j` ≠ `i`.
|
|
192
|
+
- The change in condition numbers κ(`C`), κ(`B`), and κ(`A`) when row `i` is deleted.
|
|
193
|
+
- The effect on estimation quality: changes in `nrmse`, `zhat`, `z`, and `x` when row `i` is deleted.
|
|
194
|
+
|
|
195
|
+
Additionally, it computes the total `rmsa` statistic across all rows, summarizing the overall angular alignment of *C_canon*.
|
|
196
|
+
|
|
197
|
+
**Parameters:**
|
|
198
|
+
`reset` : *bool*, default = *False*
|
|
199
|
+
If *True*, forces recomputation of all diagnostic values (the results are preserved for eventual reproduction after the method is called).
|
|
200
|
+
|
|
201
|
+
`threshold` : *float*, default = *0*
|
|
202
|
+
If positive, limits the output to constraints with `RMSA_i` ≥ `threshold`.
|
|
203
|
+
|
|
204
|
+
**Returns:**
|
|
205
|
+
*dict* of *list*
|
|
206
|
+
A dictionary containing per-row diagnostic values:
|
|
207
|
+
{
|
|
208
|
+
`"constraint"` : `[1, 2, ..., k]`, # 1-based indices
|
|
209
|
+
`"rmsa_i"` : list of `RMSA_i` values,
|
|
210
|
+
`"rmsa_dkappaC"` : list of Δκ(`C`) after deleting row `i`,
|
|
211
|
+
`"rmsa_dkappaB"` : list of Δκ(`B`) after deleting row `i`,
|
|
212
|
+
`"rmsa_dkappaA"` : list of Δκ(`A`) after deleting row `i`,
|
|
213
|
+
`"rmsa_dnrmse"` : list of Δ`nrmse` after deleting row `i`,
|
|
214
|
+
`"rmsa_dzhat"` : list of Δ`zhat` after deleting row `i`,
|
|
215
|
+
`"rmsa_dz"` : list of Δ`z` after deleting row `i`,
|
|
216
|
+
`"rmsa_dx"` : list of Δ`x` after deleting row `i`,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
### T-Test Method: `ttest`
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
self.ttest(reset, sample_size, seed, distribution)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Performs a Monte Carlo-based one- or two-sided t-test on the NRMSE statistic.
|
|
226
|
+
|
|
227
|
+
This function simulates right-hand side vectors `b` using a user-defined or default distribution and recomputes the estimator for every new `b`. It
|
|
228
|
+
tests whether the observed NRMSE significantly deviates from the null distribution (under H₀) of simulated NRMSE values. The quality of the test depends on the size of the simulated sample.
|
|
229
|
+
|
|
230
|
+
**Parameters:**
|
|
231
|
+
`reset` : *bool*, default = *False*
|
|
232
|
+
If *True*, forces recomputation of the NRMSE null distribution (under H₀) (the results are preserved for eventual reproduction after the method is called).
|
|
233
|
+
|
|
234
|
+
`sample_size` : *int*, default = *50*
|
|
235
|
+
Size of the Monte Carlo simulated sample under H₀.
|
|
236
|
+
|
|
237
|
+
`seed` : *int* or *None*, optional
|
|
238
|
+
Optional random seed to override the default.
|
|
239
|
+
|
|
240
|
+
`distribution` : *str* or *None*, default = *’normal’*
|
|
241
|
+
Distribution for generating simulated `b` vectors. One of (standard): *'normal'*, *'uniform'*, or *'laplace'*.
|
|
242
|
+
|
|
243
|
+
**Returns:**
|
|
244
|
+
*dict*
|
|
245
|
+
Dictionary with test results and null distribution statistics:
|
|
246
|
+
{
|
|
247
|
+
`'p_one_left'` : P(nrmse ≤ null mean),
|
|
248
|
+
`'p_one_right'` : P(nrmse ≥ null mean),
|
|
249
|
+
`'p_two_sided'` : 2-sided t-test p-value,
|
|
250
|
+
`'nrmse'` : observed value,
|
|
251
|
+
`'mean_null'` : mean of the null distribution (under H₀),
|
|
252
|
+
`'std_null'` : standard deviation of the null distribution (under H₀)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
## Bibliography
|
|
256
|
+
To be added.
|
|
257
|
+
|
|
258
|
+
## License
|
|
259
|
+
MIT License — see the [LICENSE](LICENSE) file.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
clsp/__init__.py,sha256=ShvZmk9qOrWOlrm5WKHvEj4ubZGUwU4UBtSQuX7I7FU,91
|
|
2
|
+
clsp/clsp.py,sha256=P5LDe1h9jZ_tm3X8KjCd7zKafyIco1uJEq-6qVGDulc,10119
|
|
3
|
+
clsp/errors.py,sha256=mpFIYB7dumi1xtVBocZcq46W-9tktWSzWjCfuI9x7JU,662
|
|
4
|
+
clsp/solver.py,sha256=YD_ZsrQaOsyB7BVGaAZ9hfAUtxvrUyzGqEMldFZw6e8,11698
|
|
5
|
+
clsp/utils.py,sha256=FdyGlqNwwxZ2nFLXe2tdLb9JfTDJ4ZmRa3lPCBiuXCw,13673
|
|
6
|
+
pyclsp-1.0.0.dist-info/licenses/LICENSE,sha256=GBD85YooWzpjrnQL2N3DQat780nYLRoiJaGcAkv7Kl4,1070
|
|
7
|
+
pyclsp-1.0.0.dist-info/METADATA,sha256=i7wJD_-qnPal6PpdWiZ-czUZBBEe1o62snS1tioSzNY,10064
|
|
8
|
+
pyclsp-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
9
|
+
pyclsp-1.0.0.dist-info/top_level.txt,sha256=IalkNGMZVyzpHnuIj0ZFArDGGUrj4HnAnLFiGxKHEEI,5
|
|
10
|
+
pyclsp-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 The Economist
|
|
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 @@
|
|
|
1
|
+
clsp
|