Skip to content

Commit 49df56c

Browse files
committed
Add support for positive semidefinite matrices to Iman-Conover
Fallback to SVD when Cholesky fails for semidefinite matrices, typically when correlation matrix contains perfect correlations
1 parent 5c8c309 commit 49df56c

2 files changed

Lines changed: 191 additions & 22 deletions

File tree

src/semeio/fmudesign/iman_conover.py

Lines changed: 92 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
An implementation of the Iman-Conover transformation.
2+
An implementation of the Iman-Conover transformation using hybrid Cholesky/SVD.
33
44
Using 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+
4497
class 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

tests/fmudesign/test_iman_conover.py

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import scipy as sp
44
from scipy.stats import spearmanr
55

6-
from semeio.fmudesign.iman_conover import ImanConover, decorrelate
6+
from semeio.fmudesign.iman_conover import (
7+
ImanConover,
8+
_matrix_sqrt_with_pinv,
9+
decorrelate,
10+
)
711

812

913
@pytest.fixture
@@ -206,9 +210,102 @@ def test_dataset_with_unity_correlation_in_ranks(self):
206210
desired_corr = np.identity(2)
207211

208212
transform = ImanConover(desired_corr)
209-
with pytest.raises(ValueError):
213+
with pytest.raises(
214+
ValueError,
215+
match="Input data has perfect rank correlations that conflict with target correlation structure.",
216+
):
210217
transform(X)
211218

219+
def test_perfect_correlation_target(self):
220+
"""Test that Iman-Conover can transform data to have perfect correlation."""
221+
# Use Latin Hypercube sampling for well-distributed, uncorrelated data
222+
sampler = sp.stats.qmc.LatinHypercube(d=2, seed=42, scramble=True)
223+
X = sampler.random(n=1000)
224+
225+
# Target: perfect positive correlation
226+
target_corr = np.array([[1, 1], [1, 1]])
227+
228+
ic = ImanConover(target_corr)
229+
X_transformed = ic(X)
230+
231+
# Calculate achieved Spearman correlation
232+
spearman_corr, _ = sp.stats.spearmanr(X_transformed[:, 0], X_transformed[:, 1])
233+
234+
# Should achieve perfect correlation
235+
assert np.isclose(spearman_corr, 1.0, atol=0.01), (
236+
f"Expected perfect correlation, got {spearman_corr}"
237+
)
238+
239+
# Marginal distributions should be preserved
240+
for k in range(X.shape[1]):
241+
assert np.allclose(np.sort(X[:, k]), np.sort(X_transformed[:, k]))
242+
243+
# With perfect correlation, the ranks should be identical
244+
ranks_col1 = sp.stats.rankdata(X_transformed[:, 0])
245+
ranks_col2 = sp.stats.rankdata(X_transformed[:, 1])
246+
assert np.allclose(ranks_col1, ranks_col2)
247+
248+
def test_that_the_matrix_square_root_has_the_expected_properties_with_perfect_correlations(
249+
self,
250+
):
251+
X = np.array(
252+
[
253+
[1.0, 0.0, 0.0],
254+
[0.0, 1.0, 1.0], # Perfect correlation between vars 2 & 3
255+
[0.0, 1.0, 1.0],
256+
]
257+
)
258+
P, P_pinv = _matrix_sqrt_with_pinv(X)
259+
260+
# Test reconstruction: P @ P.T should equal X
261+
reconstructed = P @ P.T
262+
reconstruction_error = np.linalg.norm(reconstructed - X, "fro")
263+
assert reconstruction_error < 1e-10
264+
265+
# Test pseudoinverse property: P @ P_pinv should be the projection onto
266+
# the column space of P (for rank-deficient matrices)
267+
projection = P @ P_pinv
268+
projection_error = np.linalg.norm(projection @ P - P, "fro")
269+
assert projection_error < 1e-10
270+
271+
def test_rank_deficient_correlation_matrix(self):
272+
"""Test handling of rank-deficient correlation matrices."""
273+
# Create a 3x3 rank-deficient matrix where X3 = X1 (perfect correlation)
274+
target_corr = np.array([[1.0, 0.3, 1.0], [0.3, 1.0, 0.3], [1.0, 0.3, 1.0]])
275+
276+
# Verify it's actually rank-deficient
277+
n_vars = target_corr.shape[0]
278+
n_dependencies = 1 # X3 depends on X1
279+
expected_rank = n_vars - n_dependencies
280+
assert np.linalg.matrix_rank(target_corr) == expected_rank
281+
282+
sampler = sp.stats.qmc.LatinHypercube(d=3, seed=42, scramble=True)
283+
X = sampler.random(n=1000)
284+
285+
ic = ImanConover(target_corr)
286+
X_transformed = ic(X)
287+
achieved_corr = sp.stats.spearmanr(X_transformed)[0]
288+
289+
# Should achieve near-perfect correlation between X1 and X3
290+
assert np.isclose(achieved_corr[0, 2], 1.0, atol=0.01), (
291+
f"Expected near-perfect correlation, got {achieved_corr[0, 2]:.6f}"
292+
)
293+
294+
# Other correlations should be close to target
295+
assert np.isclose(achieved_corr[0, 1], 0.3, atol=0.05), (
296+
f"Expected ~0.3 correlation, got {achieved_corr[0, 1]:.6f}"
297+
)
298+
299+
ranks_col1 = sp.stats.rankdata(X_transformed[:, 0])
300+
ranks_col3 = sp.stats.rankdata(X_transformed[:, 2])
301+
assert np.allclose(ranks_col1, ranks_col3), (
302+
"Ranks should be identical for perfectly correlated columns"
303+
)
304+
305+
# Marginal distributions should be preserved
306+
for k in range(X.shape[1]):
307+
assert np.allclose(np.sort(X[:, k]), np.sort(X_transformed[:, k]))
308+
212309

213310
if __name__ == "__main__":
214311
pytest.main(args=[__file__, "--doctest-modules", "-v", "-l"])

0 commit comments

Comments
 (0)