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+
110import logging
211import time
312
615import scipy .sparse as sp
716from numpy .typing import NDArray
817from scipy .sparse import csc_array , tril
9- from sksparse .cholmod import Factor , cholesky
18+ from sksparse .cholmod import cholesky
1019from tqdm import tqdm
1120
1221log = logging .getLogger (__name__ )
1322log .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
13779def objective_function (
@@ -429,12 +371,9 @@ def optimize_sparse_affine_kr_map(
429371def 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
483453def fit_precision_cholesky_approximate (
0 commit comments