Skip to content

Commit a564c5a

Browse files
committed
Test test_fit_precision_cholesky_approximate
1 parent a9a14be commit a564c5a

1 file changed

Lines changed: 87 additions & 0 deletions

File tree

tests/test_precision_estimation.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import networkx as nx
12
import numpy as np
23
import pytest
34
import scipy as sp
@@ -55,6 +56,92 @@ def test_closed_form_matches_iterative_solver():
5556
np.testing.assert_allclose(diag_cf, diag_iter, rtol=1e-4)
5657

5758

59+
def test_fit_precision_cholesky_approximate_two_hop_recovers_fill_in():
60+
rng = np.random.default_rng(42)
61+
n = 50000
62+
63+
# The true precision used below is sparse on the 4-cycle graph: it only
64+
# couples immediate graph neighbors (0-1, 1-2, 2-3, 3-0), while the
65+
# opposite corners (0, 2) and (1, 3) are exactly zero.
66+
#
67+
# The method under test does not fit the precision entries directly. It
68+
# fits a lower-triangular factor C such that Prec ~= C.T @ C, and the
69+
# allowed nonzeros in each row of C come from the graph neighborhood.
70+
#
71+
# For this matrix, in the natural ordering, the exact factor used by this
72+
# code path is approximately
73+
#
74+
# [[ 1.3615, 0. , 0. , 0. ],
75+
# [-0.3112, 1.3648, 0. , 0. ],
76+
# [-0.0667, -0.3706, 1.3491, 0. ],
77+
# [-0.2121, 0. , -0.4243, 1.4142]]
78+
#
79+
# so C[2, 0] is a nonzero fill-in term even though Prec[0, 2] = 0.
80+
# Intuitively, row 3 already creates an indirect 0-2 coupling inside
81+
# C.T @ C, so row 2 needs a compensating C[2, 0] to cancel it. That
82+
# nonzero C[2, 0] is exactly the fill-in that the 2-hop expansion must
83+
# allow. Nodes 0 and 2 are not adjacent in the original cycle, but they
84+
# are two hops apart (0 -> 1 -> 2, or 0 -> 3 -> 2).
85+
#
86+
# Algebraically,
87+
#
88+
# (C.T @ C)[0, 2] = sum_r C[r, 0] * C[r, 2].
89+
#
90+
# Because C is lower triangular, only rows 2 and 3 can contribute, so
91+
#
92+
# (C.T @ C)[0, 2] = C[2, 0] * C[2, 2] + C[3, 0] * C[3, 2].
93+
#
94+
# Row 3 contributes
95+
#
96+
# C[3, 0] * C[3, 2] = (-0.3 / sqrt(2)) * (-0.6 / sqrt(2)) = 0.09.
97+
#
98+
# But the true precision entry (0, 2) is zero, so row 2 must cancel that:
99+
#
100+
# C[2, 0] * C[2, 2] = -0.09.
101+
#
102+
# The diagonal entry Prec[2, 2] = 2 fixes C[2, 2] through
103+
#
104+
# C[2, 2]^2 + C[3, 2]^2 = 2,
105+
#
106+
# giving C[2, 2] = sqrt(2 - (-0.6 / sqrt(2)) ** 2) = sqrt(1.82) ~= 1.3491,
107+
# and therefore C[2, 0] = -0.09 / 1.3491 ~= -0.0667.
108+
#
109+
# With only the 1-hop graph, row 2 can use column 1 but not column 0, so
110+
# the solver cannot represent the true Cholesky sparsity pattern. With a
111+
# 2-hop expansion, the missing 0-2 connection becomes available, so the
112+
# solver can include that fill-in term and recover the precision much more
113+
# accurately.
114+
prec_true = np.array(
115+
[
116+
[2.0, -0.4, 0.0, -0.3],
117+
[-0.4, 2.0, -0.5, 0.0],
118+
[0.0, -0.5, 2.0, -0.6],
119+
[-0.3, 0.0, -0.6, 2.0],
120+
]
121+
)
122+
cov_true = np.linalg.inv(prec_true)
123+
U = rng.multivariate_normal(np.zeros(4), cov_true, size=n)
124+
G = nx.cycle_graph(4)
125+
126+
prec_1_hop = precest.fit_precision_cholesky_approximate(
127+
U=U, G=G, neighbourhood_expansion=1, use_tqdm=False
128+
).toarray()
129+
prec_2_hop = precest.fit_precision_cholesky_approximate(
130+
U=U, G=G, neighbourhood_expansion=2, use_tqdm=False
131+
).toarray()
132+
133+
# Without the 2-hop expansion, the solver cannot represent the fill-in and
134+
# leaks a spurious (0, 2) precision entry.
135+
assert abs(prec_1_hop[0, 2]) > 0.07
136+
assert abs(prec_2_hop[0, 2]) < 0.02
137+
138+
err_1_hop = np.max(np.abs(prec_1_hop - prec_true))
139+
err_2_hop = np.max(np.abs(prec_2_hop - prec_true))
140+
assert err_1_hop > err_2_hop
141+
assert err_1_hop > 0.07
142+
assert err_2_hop < 0.03
143+
144+
58145
if __name__ == "__main__":
59146
import pytest
60147

0 commit comments

Comments
 (0)