|
| 1 | +import networkx as nx |
1 | 2 | import numpy as np |
2 | 3 | import pytest |
3 | 4 | import scipy as sp |
@@ -55,6 +56,92 @@ def test_closed_form_matches_iterative_solver(): |
55 | 56 | np.testing.assert_allclose(diag_cf, diag_iter, rtol=1e-4) |
56 | 57 |
|
57 | 58 |
|
| 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 | + |
58 | 145 | if __name__ == "__main__": |
59 | 146 | import pytest |
60 | 147 |
|
|
0 commit comments