Skip to content

Commit b20c7a1

Browse files
authored
Clean up code for sparsity, permutations, etc (#162)
- Clean up functions related to precision estimation: fewer functions, state, etc. - Wrote more docstrings and doctests.
1 parent 9076791 commit b20c7a1

3 files changed

Lines changed: 105 additions & 136 deletions

File tree

graphite_maps/enif.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ def fit_precision(
201201
Estimate self.Prec_u from data U w.r.t. graph self.Graph_u
202202
"""
203203
assert self.Graph_u is not None, "Graph_u must be set to fit precision"
204-
(self.Prec_u, *_) = fit_precision_cholesky(
204+
self.Prec_u = fit_precision_cholesky(
205205
U=U,
206206
Graph_u=self.Graph_u,
207207
ordering_method=ordering_method,

graphite_maps/precision_estimation.py

Lines changed: 101 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
"""
2+
Precision estimation
3+
--------------------
4+
5+
This module contains functions for solving the following problem:
6+
Given a known sparsity pattern in a precision matrix,
7+
as well as a data set, how can we estimate the precision matrix values?
8+
"""
9+
110
import logging
211
import time
312

@@ -6,132 +15,65 @@
615
import scipy.sparse as sp
716
from numpy.typing import NDArray
817
from scipy.sparse import csc_array, tril
9-
from sksparse.cholmod import Factor, cholesky
18+
from sksparse.cholmod import cholesky
1019
from tqdm import tqdm
1120

1221
log = logging.getLogger(__name__)
1322
log.addHandler(logging.NullHandler())
1423

1524

16-
def find_sparsity_structure_from_chol(
17-
chol_LLT: Factor,
18-
) -> tuple[nx.Graph, NDArray[np.integer], csc_array, csc_array]:
19-
L = chol_LLT.L()
20-
p = L.shape[0]
21-
22-
# Get the in-fill reducing permutation vector
23-
perm_order = chol_LLT.P()
24-
25-
# Create the permutation matrix P
26-
P_order = sp.csc_array(
27-
(np.ones(len(perm_order)), (perm_order, np.arange(len(perm_order)))),
28-
shape=(p, p),
29-
)
30-
31-
# Create the reverse order permutation array
32-
perm_reverse = np.arange(p - 1, -1, -1)
33-
# Create the reverse permutation matrix
34-
P_rev = sp.csc_array((np.ones(p), (perm_reverse, np.arange(p))), shape=(p, p))
35-
36-
# Apply in-fill reducing ordering permutation to reverse permutation
37-
perm_compose = perm_order[perm_reverse]
25+
def reverse_cholesky(
26+
A: csc_array, *args: object, **kwargs: object
27+
) -> tuple[csc_array, NDArray[np.integer]]:
28+
"""Given a sparse pos. def. matrix A, compute C and P such that
29+
C.T @ C == P @ A @ P.T, where C is lower-triangular and P is a permutation.
3830
39-
# Extract the lower triangular Cholesky factor
40-
L = chol_LLT.L()
31+
This differs from the standard Cholesky factorization, which computes
32+
L @ L.T = P @ A @ P.T, where L is lower-triangular.
4133
42-
# Create matrix of non-zeroes equalling C: L @ L.T=C.T @ C unique when SPD
43-
C_pattern = (P_rev @ L @ P_rev).T
44-
45-
# Extract structure into a graph for C
46-
Graph_C = nx.from_scipy_sparse_array(C_pattern)
47-
Graph_C.remove_edges_from(nx.selfloop_edges(Graph_C))
48-
49-
# Return the results
50-
return Graph_C, perm_compose, P_rev, P_order
51-
52-
53-
def find_sparsity_structure_from_graph(
54-
Graph_u: nx.Graph,
55-
ordering_method: str = "metis",
56-
) -> tuple[nx.Graph, NDArray[np.integer], csc_array, csc_array]:
57-
"""
58-
Finds sparsity structure for lower triangular C so that
59-
C.T @ C = L @ L.T = P.T @ A @ P.
60-
61-
The permutation P is optimized and returned, so is the non-zero structure
62-
of C. For convenience the permutation so that data for A can be arranged
63-
according to C is also returned.
64-
65-
Parameters
66-
----------
67-
Graph_u : nx.Graph
68-
The graph representing the non-zero structure in the precision matrix.
69-
70-
Returns
71-
-------
72-
Graph_C : nx.Graph
73-
The graph representing the non-zero structure in C.
74-
perm_compose : NDArray[np.integer]
75-
The composed permutation array.
76-
P_rev : scipy.sparse.csc_array
77-
The reverse permutation matrix.
78-
P_order : scipy.sparse.csc_array
79-
The in-fill reducing ordering permutation matrix.
34+
Returns (C: csc_array, permutation_idx: array). The permutation index
35+
acts like P on the left when indexing the rows. See examples.
8036
8137
Examples
8238
--------
83-
>>> import networkx as nx
84-
>>> Graph_u = nx.Graph([(0, 1), (0, 3), (0, 4), (1, 4), (3, 4)])
85-
86-
The "metis" ordering method is not deterministic, so we use "natural" here:
87-
88-
>>> result = find_sparsity_structure_from_graph(Graph_u, ordering_method="natural")
89-
>>> Graph_C, perm_compose, P_rev, P_order = result
90-
>>> nx.to_scipy_sparse_array(Graph_C).todense().round(1)
91-
array([[ 0. , 0.4, 0.4, 0.5],
92-
[ 0.4, 0. , -0.1, 0.5],
93-
[ 0.4, -0.1, 0. , 0.5],
94-
[ 0.5, 0.5, 0.5, 0. ]])
95-
>>> perm_compose
96-
array([3, 2, 1, 0])
97-
>>> P_rev.todense()
98-
array([[0., 0., 0., 1.],
99-
[0., 0., 1., 0.],
100-
[0., 1., 0., 0.],
101-
[1., 0., 0., 0.]])
102-
>>> P_order.todense()
103-
array([[1., 0., 0., 0.],
104-
[0., 1., 0., 0.],
105-
[0., 0., 1., 0.],
106-
[0., 0., 0., 1.]])
39+
>>> F = sp.random_array(shape=(10, 10), density=0.2, rng=42, format='csc')
40+
>>> A = F @ F.T
41+
>>> A.setdiag(10)
42+
>>> C, permutation_idx = reverse_cholesky(A)
43+
>>> diff = C.T @ C - A[np.ix_(permutation_idx, permutation_idx)]
44+
>>> float(np.mean(np.abs(diff)))
45+
9.7705...e-17
46+
47+
Or, equivalently (C @ P).T @ (C @ P) = A.
48+
Here P acts on the right, so we must use its inverse:
49+
50+
>>> inverse_perm = np.empty_like(permutation_idx)
51+
>>> inverse_perm[permutation_idx] = np.arange(len(permutation_idx))
52+
53+
>>> F = C[:, inverse_perm]
54+
>>> diff = F.T @ F - A
55+
>>> float(np.mean(np.abs(diff)))
56+
9.7705...e-17
57+
58+
The usage of the permutation index (expressing the matrix P), follows the
59+
convention established by standard Cholesky. This function is a thin
60+
wrapper around this kind of code (but this function computes C, not L):
61+
62+
>>> from sksparse.cholmod import cholesky
63+
>>> factor = cholesky(A)
64+
>>> L, permutation_idx = factor.L(), factor.P()
65+
>>> diff = L @ L.T - A[np.ix_(permutation_idx, permutation_idx)]
66+
>>> float(np.mean(np.abs(diff)))
67+
1.3323...e-16
10768
"""
69+
cholesky_factor = cholesky(A, *args, **kwargs)
70+
L = cholesky_factor.L()
71+
permutation_idx = cholesky_factor.P()
10872

109-
# Create SPD matrix with same sparsity structure as Prec
110-
SPD_Prec = nx.to_scipy_sparse_array(
111-
Graph_u, weight=None, dtype=np.float64, format="csc"
112-
)
113-
# Use Gershgorin circle theorem to ensure SP
114-
# This ensures all eigenvalues are in a circle centered at max_degree+1.0
115-
# and radius < (max_degree+1.0), so guaranteed > 0
116-
max_degree = max(dict(Graph_u.degree()).values())
117-
log.info("max degree of graph is: %s", max_degree)
118-
SPD_Prec.setdiag(max_degree + 1.0)
119-
120-
# PT prec P = LLT
121-
start = time.perf_counter()
122-
chol_LLT = cholesky(SPD_Prec, ordering_method=ordering_method)
123-
end = time.perf_counter()
124-
log.info("Permutation optimization took %.2f seconds", end - start)
125-
126-
Graph_C, perm_compose, P_rev, P_order = find_sparsity_structure_from_chol(
127-
chol_LLT=chol_LLT
128-
)
129-
130-
log.info("Parameters in precision: %s\n", tril(SPD_Prec).nnz)
131-
log.info("Parameters in Cholesky factor: %s", Graph_C.number_of_edges())
132-
133-
# Return the results
134-
return Graph_C, perm_compose, P_rev, P_order
73+
# If we ignore the permutation, then we have the relationship:
74+
# C = cholesky(A[::-1, ::-1]).T[::-1, ::-1]
75+
C = L[::-1, ::-1].T
76+
return C, permutation_idx[::-1]
13577

13678

13779
def objective_function(
@@ -429,12 +371,9 @@ def optimize_sparse_affine_kr_map(
429371
def fit_precision_cholesky(
430372
U: NDArray[np.floating],
431373
Graph_u: nx.Graph,
374+
*,
432375
ordering_method: str = "metis",
433376
use_tqdm: bool = True,
434-
Graph_C: nx.Graph | None = None,
435-
perm_compose: NDArray[np.integer] | None = None,
436-
P_rev: csc_array | None = None,
437-
P_order: csc_array | None = None,
438377
) -> tuple[csc_array, nx.Graph, NDArray[np.integer], csc_array, csc_array]:
439378
"""
440379
Estimate the precision matrix using Cholesky decomposition.
@@ -455,29 +394,60 @@ def fit_precision_cholesky(
455394
_, p = U.shape
456395
assert len(Graph_u.nodes) == p, "nodes in graph equals columns of data"
457396

458-
if Graph_C is None or perm_compose is None or P_rev is None or P_order is None:
459-
# 1. Find in-fill reducing ordering of cholesky factor C
460-
Graph_C, perm_compose, P_rev, P_order = find_sparsity_structure_from_graph(
461-
Graph_u,
462-
ordering_method=ordering_method,
463-
)
397+
# Step 1: Cholesky factor of the dependency graph Graph_u
398+
# -------------------------------------------------------
464399

465-
# 2. Estimate non-zeroes of C
466-
U_perm = U[:, perm_compose]
400+
# Create pos. def. matrix with same sparsity structure as Prec
401+
SPD_Prec = nx.to_scipy_sparse_array(
402+
Graph_u, weight=None, dtype=np.float64, format="csc"
403+
)
404+
# Use Gershgorin circle theorem to ensure positive definite
405+
# All eigenvalues are in a circle centered at max_degree+1.0
406+
# and radius < (max_degree+1.0), so guaranteed > 0
407+
SPD_Prec.setdiag(max(dict(Graph_u.degree()).values()) + 1.0)
408+
409+
# Compute Cholesky of the sparsity pattern (a symbolic cholesky)
410+
start = time.perf_counter()
411+
C_pattern, permutation_idx = reverse_cholesky(
412+
SPD_Prec, ordering_method=ordering_method
413+
)
414+
end = time.perf_counter()
415+
log.info("Cholesky of precision matrix took %.2f seconds", end - start)
416+
Graph_C = nx.from_scipy_sparse_array(C_pattern)
417+
log.info("Parameters in precision: %s\n", tril(SPD_Prec).nnz)
418+
log.info("Parameters in Cholesky factor: %s", Graph_C.number_of_edges())
419+
420+
inverse_perm = np.empty_like(permutation_idx)
421+
inverse_perm[permutation_idx] = np.arange(len(permutation_idx))
422+
423+
# Step 2: Estimate non-zeros of C, the (reverse) Cholesky factor
424+
# --------------------------------------------------------------
425+
426+
# The function 'reverse_cholesky' above has found a C that is a factor
427+
# of a permuted Graph_u (not the original one), so we must permute U too.
428+
# Why permute at all? To have as many zeroes as possible in C.
429+
U_perm = U[:, permutation_idx]
467430
C = optimize_sparse_affine_kr_map(
468431
U=U_perm,
469432
G=Graph_C,
470433
use_tqdm=use_tqdm,
471434
)
472435

473-
# 2.b Compute log-determinant of estimate, for logging
474-
L_r = P_rev @ C.T @ P_rev # Factor of reverse precision
475-
prec_logdet = 2.0 * np.sum(np.log(L_r.diagonal()))
436+
# Compute log-determinant of estimate
437+
prec_logdet = 2.0 * np.sum(np.log(C.diagonal()))
476438
log.info("Precision has log-determinant: %.3f", prec_logdet)
477439

478-
# 3. Unwrap C to yield precision (Eqn 73 in paper)
479-
Prec = P_order @ P_rev @ (C.T @ C) @ P_rev @ P_order.T
480-
return Prec, Graph_C, perm_compose, P_rev, P_order
440+
# Step 3: Compute precision matrix from C and invert permutation
441+
# --------------------------------------------------------------
442+
443+
# Inverse permutation
444+
inverse_perm = np.empty_like(permutation_idx)
445+
inverse_perm[permutation_idx] = np.arange(len(permutation_idx))
446+
447+
# Unwrap C to yield precision (Eqn 73 in paper)
448+
# Equivalent to: (C.T @ C)[np.ix_(inverse_perm, inverse_perm)]
449+
C_orig = C[:, inverse_perm]
450+
return C_orig.T @ C_orig
481451

482452

483453
def fit_precision_cholesky_approximate(

tests/test_precision_estimation.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def test_snapshot_fit_precision_cholesky():
3030

3131
# Estimate precision with fit_precision_cholesky.
3232
# Cannot use METIS (not reproducible across OSes), use 'natural'
33-
Prec_est, *_ = precest.fit_precision_cholesky(
33+
Prec_est = precest.fit_precision_cholesky(
3434
U=U, Graph_u=Graph_u, ordering_method="natural"
3535
)
3636
Prec_est = Prec_est.todense()
@@ -89,10 +89,9 @@ def test_precision_cholesky_roundtrip(seed):
8989
U = rng.multivariate_normal(mean=np.zeros(n), cov=Cov, size=99)
9090

9191
# Estimate precision using known structure
92-
Prec_est, *_ = precest.fit_precision_cholesky(
92+
Prec_est = precest.fit_precision_cholesky(
9393
U=U, Graph_u=Graph_u, ordering_method="amd"
94-
)
95-
Prec_est = Prec_est.todense()
94+
).todense()
9695

9796
RMSE = np.sqrt(np.mean((Prec - Prec_est) ** 2))
9897

0 commit comments

Comments
 (0)