Last active
May 19, 2025 21:34
-
-
Save fabian-paul/90a91b6810106bb70f070a96cb19cc91 to your computer and use it in GitHub Desktop.
PCCA
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import typing | |
| import numpy as np | |
| import scipy.optimize | |
| def _pcca_plus_score(partial_coarse_graining_matrix_flat: np.ndarray, eigenvectors: np.ndarray, n: int) -> float: | |
| """Implementation of the PCCA+ score. | |
| Parameters | |
| ---------- | |
| partial_coarse_graining_matrix_flat : np.ndarray of shape n*n | |
| Flattened version of the (current guess of the) coarse-graining matrix with the zeroth row and column removed. | |
| eigenvectors : np.ndarray of shape (N, n + 1) | |
| the n + 1 dominant eigenvectors of the transition matrix or generalized singular vectors of the count matrix | |
| describing the dynamics | |
| n: int | |
| the dimension n of partial_coarse_graining_matrix_flat before flattening | |
| Returns | |
| ------- | |
| float | |
| the PCCA+ score for the input coarse-graining matrix | |
| Notes | |
| ----- | |
| Refer to section 4.3.1 (trace(S)) in the publication by S. Roeblitz and M. Weber, Fuzzy spectral clustering by PCCA+: | |
| application to Markov state models and data classification. Adv Data Anal Classif 7, 147-179 (2013). | |
| The implementation of the score assumes that the constant eigenvector (or singular vector) is in position zero and is represented as a | |
| column of ones. | |
| The implementation is agnostic to the value of the stationary distribution. In fact the stationary distribution can be replaced by | |
| any vector of weights (as long as the eigenvectors/ singular vectors are properly orthonormalized). This function can be used for VAMP, | |
| for G-PCCA or for kinetic coarse-graining of a reversible transition matrix as described in the original PCCA+ publication. | |
| """ | |
| partial_coarse_graining_matrix = np.reshape(partial_coarse_graining_matrix_flat, (n, n)) | |
| coarse_graining_matrix = _feasible_coarse_graining_matrix(partial_coarse_graining_matrix=partial_coarse_graining_matrix, | |
| eigenvectors=eigenvectors) | |
| return -np.sum(coarse_graining_matrix ** 2 / coarse_graining_matrix[0, np.newaxis, :]) | |
| def _pcca_plus_matrix(eigenvectors: np.ndarray, coarse_graining_matrix: np.ndarray, n_clusters: int, opt_method: str = "Nelder-Mead", | |
| opt_options: typing.Optional[typing.Mapping[str, typing.Any]] = None) -> np.ndarray: | |
| """Find the PCCA+ coarse-graining matrix by optimizing the PCCA+ score. | |
| Parameters | |
| ---------- | |
| eigenvectors : np.ndarray of shape (N, n) | |
| the dominant eigenvectors or (possibly generalized) singular vectors of a transition matrix describing the dynamics | |
| coarse_graining_matrix : np.ndarray of shape (n_clusters, n_clusters) | |
| initial guess for the coarse_graining_matrix, e.g. as computed with :meth:`_inner_simplex` | |
| n_clusters: int | |
| the number of desired clusters, must be compatible with the dimensions of `coarse_graining_matrix` | |
| opt_method: str, default = "Nelder-Mead" | |
| Passed as the argument `method` in :meth:`scipy.optimize.minimize`. | |
| opt_options: Mapping[str, Any] or None, default = None | |
| Passed as the argument `options` in :meth:`scipy.optimize.minimize`. | |
| Returns | |
| ------- | |
| np.ndarray of shape (n, n) | |
| the optimal coarse graining matrix according to the PCCA+ score | |
| Right-multiplying the dominant eigenvectors by this returned matrix yields an approximation of the cluster membership vectors. | |
| The membership vectors `membership[i, j]` describe how each grid point i relates to each kinetic cluster j. | |
| Memberships sum to one along the cluster dimension (j). | |
| Notes | |
| ----- | |
| Refer to the publication by S. Roeblitz and M. Weber, Fuzzy spectral clustering by PCCA+: | |
| application to Markov state models and data classification. Adv Data Anal Classif 7, 147-179 (2013). | |
| """ | |
| if opt_options is None: | |
| opt_options = {} | |
| if "disp" not in opt_options: | |
| opt_options["disp"] = False | |
| if not np.allclose(eigenvectors[:, 0], 1.): | |
| raise ValueError("Zeroth eigenvector must consist of ones.") | |
| eigenvectors = eigenvectors[:, 0:n_clusters] | |
| partial_coarse_graining_matrix = coarse_graining_matrix[1:, 1:] | |
| n = partial_coarse_graining_matrix.shape[0] | |
| if n != partial_coarse_graining_matrix.shape[1]: | |
| raise ValueError("partial_coarse_graining_matrix must be a square matrix") | |
| partial_coarse_graining_matrix_flat = np.reshape(partial_coarse_graining_matrix, n * n) | |
| partial_coarse_graining_matrix_flat_opt = scipy.optimize.minimize(fun=_pcca_plus_score, x0=partial_coarse_graining_matrix_flat, | |
| args=(eigenvectors, n), method=opt_method, options=opt_options).x | |
| partial_coarse_graining_matrix_opt = np.reshape(partial_coarse_graining_matrix_flat_opt, (n, n)) | |
| return _feasible_coarse_graining_matrix(partial_coarse_graining_matrix=partial_coarse_graining_matrix_opt, eigenvectors=eigenvectors) | |
| def _feasible_coarse_graining_matrix(partial_coarse_graining_matrix: np.ndarray, eigenvectors: np.ndarray) -> np.ndarray: | |
| r"""Convert an incomplete coarse-graining matrix into a full matrix by adding the missing parts such that constrains are fulfilled. | |
| Parameters | |
| ---------- | |
| partial_coarse_graining_matrix : np.ndarray of shape (n-1, n-1) | |
| the coarse-graining matrix with the zeroth row and column removed | |
| eigenvectors : np.ndarray of shape (N, n) | |
| the n dominant eigenvectors of the transition matrix or generalized singular vectors of the count matrix describing the dynamics | |
| Returns | |
| ------- | |
| np.ndarray of shape (n, n) | |
| the complete coarse-graining matrix with all constraints fulfilled | |
| Notes | |
| ----- | |
| The first row of the coarse-graining matrix A is computed by fulfilling the inequality | |
| \sum_{i=0} V_{k,i} A_{i,j} = A_{0,j} + \sum_{i=1} V_{k,i} A_{i,j} \ge 0 for all k, j | |
| where V is the matrix of eigenvectors and A is the coarse-graining matrix. This inequality is required to have non-negative cluster | |
| memberships. The inequality is used to set A_{0,j} = \max_k \left( -\sum_{i=1} V_{k,i} A_{i,j} \right) | |
| The first column of the coarse-graining matrix A is computed from the identity \sum_j A_{i,j} = \delta_{i,0} which is | |
| required to obtain a normalized transition matrix (probability conservation) after coarse-graining. The identity is used to set | |
| A_{i,0} = \delta_{i,0} - \sum{j=1} A_{i,j} | |
| The implementation of the score assumes that the constant eigenvector (or singular vectors) is in position zero and is represented as a | |
| column of ones. Furthermore the implementation assumes that the eigenvectors (or singular vectors) are mutually orthogonal with | |
| respect to some implicit weighting vector. | |
| Refer to section 4.3 in the publication by S. Roeblitz and M. Weber, Fuzzy spectral clustering by PCCA+: | |
| application to Markov state models and data classification. Adv Data Anal Classif 7, 147-179 (2013). | |
| """ | |
| n = partial_coarse_graining_matrix.shape[0] | |
| coarse_graining_matrix = np.zeros((n + 1, n + 1)) | |
| coarse_graining_matrix[1:, 1:] = partial_coarse_graining_matrix | |
| coarse_graining_matrix[1:, 0] = -np.sum(partial_coarse_graining_matrix, axis=1) | |
| memberships_partial = np.dot(eigenvectors[:, 1:], coarse_graining_matrix[1:, :]) | |
| memberships_partial_max = np.max(-memberships_partial, axis=0) | |
| coarse_graining_matrix[0, :] = memberships_partial_max | |
| return coarse_graining_matrix / np.sum(memberships_partial_max) | |
| def _orthonormalize(vertices: np.ndarray) -> typing.Tuple[np.ndarray, np.ndarray]: | |
| """Convert a set of points to the pair (origin, orthonormal vectors) that span the affine space of the original points. | |
| Parameters | |
| ---------- | |
| vertices : np.ndarray of shape (n, n) | |
| some sets of points | |
| Returns | |
| ------- | |
| np.ndarray of shape n | |
| the point at the origin | |
| np.ndarray of shape (n, n) | |
| the orthonormal unit vectors | |
| The point of origin and the the unit vectors span the same affine space as the set of input points. | |
| """ | |
| # pick vertex that is closest to zero as the origin | |
| i0 = np.argmin(np.linalg.norm(vertices, axis=1)) | |
| v0 = vertices[i0, :] | |
| # for the remaining vertices, subtract v0 and orthonormalize | |
| a = np.concatenate((vertices[0:i0, :], vertices[i0 + 1:, :])) - v0[np.newaxis, :] | |
| q, _ = np.linalg.qr(a.T) | |
| return v0, q.T | |
| def _pcca_plus_matrix_inner_simplex(eigenvectors: np.ndarray) -> np.ndarray: | |
| """Compute a good initial guess of the PCCA+ coarse-graining matrix using the "inner simplex" algorithm. | |
| Parameters | |
| ---------- | |
| eigenvectors: np.ndarray of shape (N, n) | |
| the n dominant eigenvectors of the transition matrix or generalized singular vectors of the count matrix describing the dynamics | |
| Returns | |
| ------- | |
| np.ndarray of shape (n, n) | |
| The map from the eigenvector space to the simplex. Right-multiplying the dominant eigenvectors by this returned matrix yields an | |
| approximation of the cluster membership vectors. The membership vectors `membership[i, j]` describe how each grid point i relates | |
| to each kinetic cluster j. Memberships sum to one along the cluster dimension (j). Memberships computed with this method can be | |
| negative. Use :meth:`_pcca_plus_matrix` for non-negative memberships. | |
| Notes | |
| ----- | |
| The inner simplex algorithm finds a large n-simplex contained in the point cloud formed by the n dominant eigenfunctions defined on the | |
| grid points (or k-means clusters centers). This algorithm might not find the larget possible n-simplex. | |
| The initial guess of the returned PCCA+ coarse-graining matrix maps the n vertices of the simplex to the n canonical Euclidean unit | |
| vectors. | |
| Refer to the publication by Weber and Galliat: Characterization of Transition States in Conformational Dynamics using Fuzzy Sets | |
| Technical Report 02-12, Zuse-Institut Takustr. 7, 14195 Berlin (2002) | |
| """ | |
| dim, n_clusters = eigenvectors.shape | |
| vertices = np.empty((2, n_clusters)) | |
| vertices[0, :] = eigenvectors[np.argmax(np.linalg.norm(eigenvectors, axis=1)), :] | |
| vertices[1, :] = eigenvectors[np.argmax(np.linalg.norm(eigenvectors - vertices[0, np.newaxis, :], axis=1)), :] | |
| # further passes, follow the algorithm form Weber & Galliat (similar to quick hull) | |
| for k in range(vertices.shape[0], n_clusters): | |
| v0, w = _orthonormalize(vertices) | |
| projector = np.einsum('ij,ik->jk', w, w) - np.eye(n_clusters) | |
| dists_candidate = np.linalg.norm(np.dot(eigenvectors - v0[np.newaxis, :], projector), axis=1) | |
| vertices = np.vstack((vertices, eigenvectors[np.argmax(dists_candidate), :])) | |
| return np.linalg.inv(vertices) | |
| def pcca_plus_for_transition_matrix(transition_matrix: np.ndarray, n_clusters: int) -> typing.Tuple[ | |
| np.ndarray, np.ndarray, np.ndarray, np.ndarray]: | |
| """Compute the membership vectors for fuzzy kinetic clusters of a transition matrix. | |
| Parameters | |
| ---------- | |
| transition_matrix : np.ndarray of shape (N, N) | |
| The grid point-to-grid point (or k-means cluster to k-means cluster) transition matrix. | |
| n_clusters : int | |
| The number of states to cluster state space into. Must be 2 or larger. | |
| Returns | |
| ------- | |
| np.ndarray of shape (N, n_clusters) | |
| the cluster memberships; `membership[i, j]` is the membership of grid point i (or k-mean cluster i) in kinetic cluster j. | |
| Memberships sum to one along the cluster dimension (j). | |
| np.ndarray of shape (N, n_clusters) | |
| the right dominant eigenvectors of the transition matrix, column-wise, ordered descendingly by eigenvalue; the constant | |
| eigenvector is in zeroth position and is encoded as a column of ones. | |
| np.ndarray of shape N | |
| the stationary distribution of the transition matrix | |
| np.ndarray of shape n_clusters | |
| the dominant eigenvalues of the transition matrix, ordered descendingly by magnitude | |
| Notes | |
| ----- | |
| Refer to the publication by S. Roeblitz and M. Weber, Fuzzy spectral clustering by PCCA+: | |
| application to Markov state models and data classification. Adv Data Anal Classif 7, 147-179 (2013). | |
| """ | |
| eigenvalues_left, eigenvectors_left = np.linalg.eig(transition_matrix.T) | |
| stationary_distribution = eigenvectors_left[:, np.argmax(eigenvalues_left)] | |
| stationary_distribution = stationary_distribution / np.sum(stationary_distribution) | |
| eigenvalues, eigenvectors = np.linalg.eig(T) | |
| order = np.argsort(np.abs(eigenvalues))[::-1] | |
| eigenvectors = eigenvectors[:, order] | |
| norm_0 = eigenvectors[:, 0].sum() / eigenvectors.shape[0] | |
| eigenvectors[:, 0] = eigenvectors[:, 0] / norm_0 | |
| norm_rest = np.sum(eigenvectors[:, 1:] ** 2 * stationary_distribution[:, np.newaxis], axis=0) ** 0.5 | |
| eigenvectors[:, 1:] = eigenvectors[:, 1:] / norm_rest[np.newaxis, :] | |
| coarse_graining_matrix = _pcca_plus_matrix_inner_simplex(eigenvectors=eigenvectors[:, 0:n_clusters]) | |
| coarse_graining_matrix = _pcca_plus_matrix(eigenvectors=eigenvectors[:, 0:n_clusters], coarse_graining_matrix=coarse_graining_matrix, | |
| n_clusters=n_clusters) | |
| return eigenvectors[:, 0:n_clusters].dot(coarse_graining_matrix), eigenvectors, stationary_distribution, eigenvalues[order][ | |
| 0:n_clusters] | |
| if __name__ == "__main__": | |
| import msmtools | |
| def same_cols(a, b, **kwargs) -> bool: | |
| found = set() | |
| for i, col_a in enumerate(a.T): | |
| for col_b in b.T: | |
| if np.allclose(col_a, col_b, **kwargs): | |
| found.add(i) | |
| return found == {i for i in range(a.shape[1])} | |
| def proportional_columns(a, b, **kwargs) -> bool: | |
| result = True | |
| for col_a, col_b in zip(a.T, b.T): | |
| non_zero = col_b != 0 | |
| factor = col_a[non_zero][0] / col_b[non_zero][0] | |
| result &= np.allclose(col_a, factor * col_b, **kwargs) | |
| return result | |
| C = np.random.rand(40, 40) | |
| C = C + C.T | |
| T = C / C.sum(axis=1)[:, np.newaxis] | |
| for n_clusters_ in [2, 3, 4, 5, ]: | |
| memberships, eigenvectors_, pi, eigenvalues_ = pcca_plus_for_transition_matrix(transition_matrix=T, n_clusters=n_clusters_) | |
| eps = 1E-7 | |
| assert np.all(memberships <= 1 + eps) | |
| assert np.all(memberships > - eps) | |
| assert np.allclose(memberships.sum(axis=1), 1.0) | |
| A = _pcca_plus_matrix_inner_simplex(eigenvectors_[:, 0:n_clusters_]) | |
| A2 = _pcca_plus_matrix(eigenvectors_, A, n_clusters_) | |
| A_feasible = _feasible_coarse_graining_matrix(partial_coarse_graining_matrix=A[1:, 1:], | |
| eigenvectors=eigenvectors_[:, 0:n_clusters_]) | |
| assert np.allclose(A_feasible.sum(axis=1), np.eye(1, A_feasible.shape[0], 0)) | |
| assert np.all(eigenvectors_[:, 0:n_clusters_].dot(A_feasible) >= -eps) | |
| # check coarse-graining properties of the cluster memberships | |
| T_cg = np.linalg.inv(np.dot(memberships.T * pi[np.newaxis, :], memberships)).dot( | |
| memberships.T.dot(T * pi[:, np.newaxis]).dot(memberships)) | |
| assert np.allclose(T_cg.sum(axis=1), np.ones(n_clusters_)) # check normalization | |
| eigenvalues_cg, eigenvectors_cg = np.linalg.eig(T_cg) | |
| order_ = np.argsort(np.abs(eigenvalues_cg))[::-1] | |
| eigenvalues_cg = eigenvalues_cg[order_] | |
| eigenvectors_cg = eigenvectors_cg[:, order_] | |
| assert np.allclose(eigenvalues_cg[0:n_clusters_], eigenvalues_) # check eigenvalues | |
| # check stationary distribution | |
| eigenvalues_cg_left, eigenvectors_cg_left = np.linalg.eig(T_cg.T) | |
| stationary_distribution_cg = eigenvectors_cg_left[:, np.argmax(eigenvalues_cg_left)] | |
| stationary_distribution_cg = stationary_distribution_cg / np.sum(stationary_distribution_cg) | |
| assert np.allclose(stationary_distribution_cg, memberships.T.dot(pi)) | |
| # check eigenvectors | |
| assert proportional_columns(eigenvectors_cg, np.linalg.inv(A2)) # equation at the bottom of page 162 in Röblitz & Weber | |
| # comparison to msmtools | |
| chi_, Aref = msmtools.analysis.dense.pcca._pcca_connected_isa(eigenvectors_, n_clusters_) | |
| assert same_cols(A, Aref) | |
| assert same_cols(eigenvectors_[:, 0:n_clusters_].dot(A), chi_) | |
| A2ref = msmtools.analysis.dense.pcca._opt_soft(eigenvectors_, Aref, n_clusters_) | |
| assert same_cols(A2, A2ref) | |
| memberships_ref = msmtools.analysis.pcca_memberships(T, n_clusters_) | |
| assert same_cols(memberships, memberships_ref) | |
| T = np.array([[0.9, 0.1, 0.0, 0.0, 0.0], | |
| [0.1, 0.9, 0.0, 0.0, 0.0], | |
| [0.0, 0.1, 0.8, 0.1, 0.0], | |
| [0.0, 0.0, 0.0, 0.8, 0.2], | |
| [0.0, 0.0, 0.0, 0.2, 0.8]]) | |
| memberships_, *_ = pcca_plus_for_transition_matrix(transition_matrix=T, n_clusters=2) | |
| ref = np.array([[1., 0.], | |
| [1., 0.], | |
| [0.5, 0.5], | |
| [0., 1.], | |
| [0., 1.]]) | |
| np.assert_allclose(memberships_, ref) | |
| memberships_, *_ = pcca_plus_for_transition_matrix(transition_matrix=T, n_clusters=4) | |
| ref = np.array([[1., 0., 0., 0.], | |
| [0., 1., 0., 0.], | |
| [0., 0.5, 0.5, 0.], | |
| [0., 0., 1., 0.], | |
| [0., 0., 0., 1.]]) | |
| np.assert_allclose(memberships_, ref) | |
| print("OK") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment