├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── distribute_python.sh ├── examples └── basic_usage.py ├── requirements.txt ├── setup.py └── springrank ├── __init__.py └── springrank.py /.gitignore: -------------------------------------------------------------------------------- 1 | env 2 | .eggs 3 | SpringRank.egg-info 4 | *.pyc 5 | dist 6 | **/.DS_Store -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Daniel B Larremore 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SpringRank 2 | 3 | This is a sparse `numpy` and `scipy` implementation of SpringRank. 4 | 5 | **Paper**: Cate De Bacco, Dan Larremore, and Cris Moore. Science Advances. 6 | 7 | **Code**: Dan Larremore, K. Hunter Wapman, Apara Venkateswaran. 8 | 9 | # Installation 10 | 11 | ``` 12 | pip install -r requirements.txt 13 | ``` 14 | 15 | # Examples 16 | 17 | **Get the ranks from a directed adjacency matrix (numpy array)** 18 | ``` 19 | from springrank import SpringRank 20 | A = np.random.binomial(1, 0.3, size=(10, 10)) 21 | # Initialize and fit model 22 | model = SpringRank() 23 | model.fit(A) 24 | # Print the ranks 25 | print(model.ranks) 26 | ``` 27 | 28 | **Compute the inverse temperature parameter (beta) of the ranking and matrix** 29 | ``` 30 | print(model.get_beta()) 31 | ``` 32 | 33 | **Get the scaled ranks so that a one-rank difference means a 75% win rate** 34 | ``` 35 | scaled_ranks = model.get_scaled_ranks(0.75) 36 | ``` 37 | 38 | **Include or change regularization alpha (defaults to alpha=0 unless specified)** 39 | ``` 40 | # Instantiate with regularization 41 | model = SpringRank(alpha=0.1) 42 | model.fit(A) 43 | print(model.ranks) 44 | # Change the regularization of an existing model 45 | model.alpha = 0.2 46 | model.fit(A) 47 | print(model.ranks) 48 | ``` 49 | 50 | **Make predictions about edge directions** 51 | ``` 52 | from springrank import SpringRank 53 | A = np.random.binomial(1, 0.3, size=(10, 10)) 54 | # Initialize and fit model 55 | model = SpringRank() 56 | model.fit(A) 57 | print("The probability that an undirected edge beween 3 and 5 points from 3 to 5 is:\n") 58 | print(model.predict([3,5])) 59 | ``` -------------------------------------------------------------------------------- /distribute_python.sh: -------------------------------------------------------------------------------- 1 | python3 setup.py sdist 2 | python3 -m twine upload --skip-existing dist/* 3 | -------------------------------------------------------------------------------- /examples/basic_usage.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from scipy.sparse import csr_matrix 3 | from springrank import SpringRank 4 | 5 | # Create sample data 6 | np.random.seed(42); N = 10; A = np.random.binomial(1, 0.3, size=(N, N)) 7 | 8 | # Initialize and fit model 9 | model = SpringRank(alpha=0.01) 10 | model.fit(A) 11 | 12 | # Examine ranks 13 | print("ranks:") 14 | print(model.ranks) 15 | 16 | # Predict outcome of a matchup between nodes 1 and 2 17 | print("\nprediction:") 18 | print(model.predict([1,2])) 19 | 20 | # Rescale ranks so that a 1-unit difference means a 75% win rate 21 | print("\nrescaled ranks:") 22 | print(model.get_rescaled_ranks(0.75)) 23 | 24 | # Repeat the process above using a sparse csr matrix 25 | X = csr_matrix(model.A) 26 | model2 = SpringRank(alpha=0.01) 27 | model2.fit(X) 28 | 29 | # Examine ranks 30 | print("sparse ranks:") 31 | print(model2.ranks) 32 | 33 | # Predict outcome of a matchup between nodes 1 and 2 34 | print("\nsparse prediction:") 35 | print(model2.predict([1,2])) 36 | 37 | # Rescale ranks so that a 1-unit difference means a 75% win rate 38 | print("\nsparse rescaled ranks:") 39 | print(model2.get_rescaled_ranks(0.75)) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | scipy 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | from pkgutil import walk_packages 3 | from setuptools import setup 4 | 5 | 6 | def find_packages(path): 7 | # This method returns packages and subpackages as well. 8 | return [name for _, name, is_pkg in walk_packages([path]) if is_pkg] 9 | 10 | 11 | def read_file(filename): 12 | with io.open(filename) as fp: 13 | return fp.read().strip() 14 | 15 | 16 | def read_requirements(filename): 17 | return [line.strip() for line in read_file(filename).splitlines() 18 | if not line.startswith('#')] 19 | 20 | 21 | setup( 22 | name="SpringRank", 23 | packages=list(find_packages('.')), 24 | version="0.0.8", 25 | author="Caterina De Bacco; Daniel Larremore; Cris Moore", 26 | author_email="daniel.larremore@colorado.edu", 27 | description="SpringRank: A physical model for efficient ranking in networks", 28 | long_description="SpringRank: A physical model for efficient ranking in networks", 29 | long_description_content_type="text/markdown", 30 | setup_requires=read_requirements('requirements.txt'), 31 | install_requires=read_requirements('requirements.txt'), 32 | url="https://github.com/LarremoreLab/SpringRank", 33 | include_package_data=True, 34 | keywords='rankings, SpringRank', 35 | classifiers=[ 36 | "Programming Language :: Python :: 3", 37 | 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', 38 | "Operating System :: OS Independent", 39 | 'Natural Language :: English' 40 | ], 41 | python_requires=">=3.6", 42 | ) 43 | -------------------------------------------------------------------------------- /springrank/__init__.py: -------------------------------------------------------------------------------- 1 | from .springrank import SpringRank 2 | 3 | __version__ = '0.0.8' 4 | __authors__ = 'Daniel Larremore' 5 | 6 | 7 | -------------------------------------------------------------------------------- /springrank/springrank.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from scipy.sparse import spdiags, csr_matrix, eye 3 | from scipy.optimize import brentq 4 | import scipy.sparse.linalg 5 | import warnings 6 | from scipy.sparse import SparseEfficiencyWarning 7 | 8 | warnings.simplefilter('ignore', SparseEfficiencyWarning) 9 | 10 | class SpringRank: 11 | """ 12 | A class implementation of the SpringRank algorithm for computing hierarchical rankings 13 | from directed networks. 14 | 15 | Parameters 16 | ---------- 17 | alpha : float, default=0 18 | Regularization parameter. If 0, uses Lagrange multiplier approach. 19 | If > 0, performs L2 regularization. 20 | 21 | Attributes 22 | ---------- 23 | ranks_ : array-like of shape (n_nodes,) 24 | The computed ranks for each node after fitting 25 | is_fitted_ranks_ : bool 26 | Whether the model has been fitted 27 | """ 28 | 29 | def __init__(self, alpha=0): 30 | self.alpha = alpha 31 | self.ranks = None 32 | self.is_fitted_ranks_ = False 33 | self.beta = None 34 | self.is_fitted_beta_ = False 35 | self.A = None 36 | 37 | def fit(self, A): 38 | """ 39 | Compute the SpringRank solution for the input adjacency matrix. 40 | 41 | Parameters 42 | ---------- 43 | A : array-like or sparse matrix of shape (n_nodes, n_nodes) 44 | The adjacency matrix of the directed network 45 | 46 | Returns 47 | ------- 48 | self : object 49 | Returns the instance itself. 50 | """ 51 | # Validation of A 52 | if not (A.shape[0] == A.shape[1]): 53 | raise ValueError("Adjacency matrix must be square") 54 | if scipy.sparse.issparse(A): 55 | neg_entries = A.data < 0 56 | else: 57 | neg_entries = A < 0 58 | if neg_entries.any(): 59 | raise ValueError("Adjacency matrix cannot contain negative entries") 60 | 61 | self.A = A 62 | 63 | if self.alpha == 0: 64 | self.ranks = self._solve_springrank() 65 | else: 66 | self.ranks = self._solve_springrank_regularized() 67 | 68 | self.is_fitted_ranks_ = True 69 | 70 | return self 71 | 72 | def _solve_springrank(self): 73 | """Implementation of non-regularized SpringRank""" 74 | N = self.A.shape[0] 75 | k_in = np.array(self.A.sum(axis=0)) 76 | k_out = np.array(self.A.sum(axis=1).transpose()) 77 | 78 | # form the graph laplacian 79 | operator = csr_matrix( 80 | spdiags(k_out + k_in, 0, N, N) - self.A - self.A.transpose() 81 | ) 82 | 83 | # form the operator A (from Ax=b notation) 84 | # note that this is the operator in the paper, but augmented 85 | # to solve a Lagrange multiplier problem that provides the constraint 86 | operator.resize((N + 1, N + 1)) 87 | operator[N, 0] = 1 88 | operator[0, N] = 1 89 | 90 | # form solution vector b (from Ax=b notation) 91 | solution_vector = np.append((k_out - k_in), np.array([0])).transpose() 92 | 93 | # solve system 94 | ranks = scipy.sparse.linalg.bicgstab( 95 | scipy.sparse.csr_matrix(operator), 96 | solution_vector 97 | )[0] 98 | 99 | mean_centered_ranks = ranks[:-1] - np.mean(ranks[:-1]) 100 | 101 | return mean_centered_ranks 102 | 103 | def _solve_springrank_regularized(self): 104 | """Implementation of regularized SpringRank""" 105 | if isinstance(self.A, np.ndarray): 106 | self.A = csr_matrix(self.A) 107 | 108 | N = self.A.shape[0] 109 | k_in = self.A.sum(axis=0) 110 | k_out = self.A.sum(axis=1).T 111 | 112 | k_in = spdiags(np.array(k_in)[0], 0, N, N, format="csr") 113 | k_out = spdiags(np.array(k_out)[0], 0, N, N, format="csr") 114 | 115 | C = self.A + self.A.T 116 | D1 = k_in + k_out 117 | B = k_out - k_in 118 | B = B @ np.ones([N, 1]) 119 | 120 | operator = self.alpha * eye(N) + D1 - C 121 | ranks = scipy.sparse.linalg.bicgstab(operator, B)[0] 122 | 123 | return ranks 124 | 125 | @staticmethod 126 | def _eqs39(beta, s, A): 127 | """Helper function for inverse temperature calculation 128 | Memory-efficient version of eqs39 that works with sparse matrices. 129 | Instead of converting to dense matrix, we iterate over nonzero elements. 130 | """ 131 | x = 0 132 | rows, cols = A.nonzero() 133 | for idx in range(len(rows)): 134 | i, j = rows[idx], cols[idx] 135 | a_ij = A[i,j] 136 | a_ji = A[j,i] 137 | x += (s[i] - s[j]) * (a_ij - (a_ij + a_ji) / 138 | (1 + np.exp(-2 * beta * (s[i] - s[j])))) 139 | return x 140 | 141 | def _get_inverse_temperature(self): 142 | """Calculate inverse temperature parameter""" 143 | MLE = brentq(self._eqs39, 0.01, 20, args=(self.ranks, self.A)) 144 | return MLE 145 | 146 | def get_beta(self): 147 | if self.is_fitted_beta_ == False: 148 | self.beta = self._get_inverse_temperature() 149 | self.is_fitted_beta_ = True 150 | return self.beta 151 | 152 | def get_rescaled_ranks(self, target_scale): 153 | """Rescale ranks using target scale and inverse temperature""" 154 | if self.is_fitted_beta_ == False: 155 | self.beta = self._get_inverse_temperature() 156 | self.is_fitted_beta_ = True 157 | scaling_factor = 1 / (np.log(target_scale / (1 - target_scale)) / 158 | (2 * self.beta)) 159 | return self.ranks * scaling_factor 160 | 161 | def predict(self, ij_pair): 162 | """Predict probability that i -> j in a pair [i,j]""" 163 | if not self.is_fitted_ranks_: 164 | raise ValueError("Call fit before predicting") 165 | i = ij_pair[0] 166 | j = ij_pair[1] 167 | if not (0 <= i < self.A.shape[0] and 0 <= j < self.A.shape[0]): 168 | raise ValueError(f"Indices {i}, {j} out of bounds for matrix of size {self.A.shape[0]}") 169 | if self.is_fitted_beta_ == False: 170 | self.beta = self._get_inverse_temperature() 171 | self.is_fitted_beta_ = True 172 | diff = self.ranks[i] - self.ranks[j] 173 | return 1 / (1 + np.exp(-2 * self.beta * diff)) --------------------------------------------------------------------------------