11"""
2- An implementation of the Iman-Conover transformation.
2+ An implementation of the Iman-Conover transformation using hybrid Cholesky/SVD .
33
44Using Iman-Conover with Latin Hybercube sampling
55------------------------------------------------
@@ -41,16 +41,71 @@ def _is_positive_definite(X: npt.NDArray[Any]) -> bool:
4141 return False
4242
4343
44+ def _is_positive_semidefinite (X : npt .NDArray [np .float64 ]) -> bool :
45+ """Check if matrix is positive semidefinite using eigenvalue decomposition."""
46+ try :
47+ # PSD matrices must be square and symmetric
48+ if X .shape [0 ] != X .shape [1 ]:
49+ return False
50+ if not np .allclose (X , X .T , rtol = 1e-12 , atol = 1e-12 ):
51+ return False
52+
53+ eigenvals = np .linalg .eigvals (X )
54+ tol = 1e-12
55+ # Even though the eigenvalues of symmetric real matrices are guaranteed
56+ # to be real, they might turn out complex due to numerics.
57+ # Hence, the usage of np.real.
58+ return bool (np .all (np .real (eigenvals ) >= - tol ))
59+ except np .linalg .LinAlgError :
60+ return False
61+
62+
63+ def _matrix_sqrt_with_pinv (
64+ X : npt .NDArray [np .float64 ],
65+ ) -> tuple [npt .NDArray [np .floating [Any ]], npt .NDArray [np .floating [Any ]]]:
66+ """Compute matrix square root and its pseudoinverse using Cholesky if possible, otherwise SVD."""
67+ if _is_positive_definite (X ):
68+ # Use Cholesky for positive definite matrices (faster and more stable)
69+ L = np .linalg .cholesky (X )
70+ # For Cholesky decomposition, inv(L) can be computed efficiently
71+ L_inv = sp .linalg .solve_triangular (L , np .eye (L .shape [0 ]), lower = True )
72+ return L , L_inv .T
73+ else :
74+ # Fall back to SVD for positive semidefinite matrices
75+ U , s , _ = np .linalg .svd (X , hermitian = True )
76+
77+ # Threshold small eigenvalues for numerical stability
78+ s_clipped = np .maximum (s , 0 )
79+ s_sqrt = np .sqrt (s_clipped )
80+
81+ # Keep all dimensions but zero out small eigenvalues
82+ tol = 1e-12
83+ mask = s_clipped >= tol
84+ s_sqrt [~ mask ] = 0
85+
86+ # Square root matrix
87+ L = U * s_sqrt
88+
89+ # Pseudoinverse of square root
90+ s_inv = np .zeros_like (s_sqrt )
91+ s_inv [mask ] = 1.0 / s_sqrt [mask ]
92+ L_pinv = (U * s_inv ).T
93+
94+ return L , L_pinv
95+
96+
4497class ImanConover :
4598 def __init__ (self , correlation_matrix : npt .NDArray [Any ]) -> None :
4699 """Create an Iman-Conover transform.
47100
48101 Parameters
49102 ----------
50103 correlation_matrix : ndarray
51- Target correlation matrix of shape (K, K). The Iman-Conover will
52- try to induce a correlation on the data set X so that corr(X) is
53- as close to `correlation_matrix` as possible.
104+ Target correlation matrix of shape (K, K).
105+ The Iman-Conover will try to induce a correlation on
106+ the data set X so that corr(X) is as close
107+ to `correlation_matrix` as possible.
108+ Can be positive definite or positive semidefinite.
54109
55110 Notes
56111 -----
@@ -127,11 +182,13 @@ def __init__(self, correlation_matrix: npt.NDArray[Any]) -> None:
127182 raise ValueError ("Correlation matrix must have 1.0 on diagonal." )
128183 if not np .allclose (correlation_matrix .T , correlation_matrix ):
129184 raise ValueError ("Correlation matrix must be symmetric." )
130- if not _is_positive_definite (correlation_matrix ):
131- raise ValueError ("Correlation matrix must be positive definite ." )
185+ if not _is_positive_semidefinite (correlation_matrix ):
186+ raise ValueError ("Correlation matrix must be positive semidefinite ." )
132187
133188 self .C = correlation_matrix .copy ()
134- self .P = np .linalg .cholesky (self .C )
189+
190+ # Use hybrid approach: Cholesky if possible, SVD otherwise
191+ self .P , _ = _matrix_sqrt_with_pinv (self .C )
135192
136193 def __call__ (self , X : npt .NDArray [Any ]) -> npt .NDArray [Any ]:
137194 """Transform an input matrix X.
@@ -156,13 +213,13 @@ def __call__(self, X: npt.NDArray[Any]) -> npt.NDArray[Any]:
156213 if not isinstance (X , np .ndarray ):
157214 raise TypeError ("Input argument `X` must be NumPy array." )
158215 if not X .ndim == 2 :
159- raise ValueError ("Correlation matrix must be square ." )
216+ raise ValueError ("Input matrix must be 2D ." )
160217
161218 N , K = X .shape
162219
163220 if self .P .shape [0 ] != K :
164221 msg = f"Shape of `X` ({ X .shape } ) does not match shape of "
165- msg += f"correlation matrix ({ self .P .shape } )"
222+ msg += f"correlation matrix ({ self .C .shape } )"
166223 raise ValueError (msg )
167224
168225 if N <= K :
@@ -173,23 +230,38 @@ def __call__(self, X: npt.NDArray[Any]) -> npt.NDArray[Any]:
173230 # approximately multivariate normal (but with correlations).
174231 # The new data has the same rank correlation as the original data.
175232 ranks = sp .stats .rankdata (X , axis = 0 ) / (N + 1 )
176- normal_scores = sp .stats .norm .ppf (ranks ) # + np.random.randn(N, K) * epsilon
233+ normal_scores = sp .stats .norm .ppf (ranks )
177234
178235 # STEP TWO - Remove correlations from the transformed data
179236 empirical_correlation = np .corrcoef (normal_scores , rowvar = False )
180- if not _is_positive_definite (empirical_correlation ):
181- msg = "Rank data correlation not positive definite."
182- msg += "There are perfect correlations in the ranked data."
183- msg += "Supply more data (rows in X) or sample differently."
237+ if not _is_positive_semidefinite (empirical_correlation ):
238+ msg = "Rank data correlation not positive semidefinite."
184239 raise ValueError (msg )
185240
186- decorrelation_matrix = np .linalg .cholesky (empirical_correlation )
241+ # Fail if input has perfect rank correlations that differ from target
242+ # (impossible to change perfect correlations via permutation)
243+ off_diagonal = empirical_correlation [~ np .eye (K , dtype = bool )]
244+ if np .any (
245+ np .isclose (np .abs (off_diagonal ), 1.0 , rtol = 1e-10 )
246+ ) and not np .allclose (empirical_correlation , self .C , rtol = 1e-6 , atol = 1e-6 ):
247+ msg = "Input data has perfect rank correlations that conflict with target correlation structure."
248+ raise ValueError (msg )
187249
188- # We exploit the fact that Q is lower-triangular and avoid the inverse.
189- # X = N @ inv(Q)^T => X @ Q^T = N => (Q @ X^T)^T = N
190- decorrelated_scores = sp .linalg .solve_triangular (
191- decorrelation_matrix , normal_scores .T , lower = True
192- ).T
250+ # Use same hybrid approach for decorrelation
251+ decorrelation_matrix , decorrelation_pinv = _matrix_sqrt_with_pinv (
252+ empirical_correlation
253+ )
254+
255+ if _is_positive_definite (empirical_correlation ):
256+ # Use efficient triangular solve for Cholesky case
257+ # We exploit the fact that Q is lower-triangular and avoid the inverse.
258+ # X = N @ inv(Q)^T => X @ Q^T = N => (Q @ X^T)^T = N
259+ decorrelated_scores = sp .linalg .solve_triangular (
260+ decorrelation_matrix , normal_scores .T , lower = True
261+ ).T
262+ else :
263+ # Use pseudoinverse for SVD case
264+ decorrelated_scores = normal_scores @ decorrelation_pinv
193265
194266 # STEP THREE - Induce correlations in transformed space
195267 correlated_scores = decorrelated_scores @ self .P .T
0 commit comments