maxent_graph 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ """
2
+ Contains ABC for Maximum Entropy graph null model.
3
+ """
4
+
5
+ import warnings
6
+ import time
7
+ from abc import abstractmethod, ABC
8
+ from collections import namedtuple
9
+
10
+ import numpy as np
11
+ import scipy.optimize
12
+ from jax import jit, jacfwd, jacrev, grad
13
+
14
+
15
+ from .util import wrap_with_array, print_percentiles, jax_class_jit, hvp
16
+
17
+ Solution = namedtuple(
18
+ "Solution", ["x", "nll", "residual_error_norm", "relative_error", "total_time"]
19
+ )
20
+
21
+
22
+ class MaxentGraph(ABC):
23
+ """
24
+ ABC for Maximum Entropy graph null model.
25
+ """
26
+
27
+ @abstractmethod
28
+ def bounds(self):
29
+ """
30
+ Returns the bounds on the parameters vector.
31
+ """
32
+
33
+ def clip(self, v):
34
+ """
35
+ Clips the parameters vector according to bounds.
36
+ """
37
+ (lower, upper), _bounds_object = self.bounds()
38
+ return np.clip(v, lower, upper)
39
+
40
+ @abstractmethod
41
+ def transform_parameters(self, v):
42
+ """
43
+ Transforms parameters to bounded form.
44
+ """
45
+
46
+ @abstractmethod
47
+ def transform_parameters_inv(self, v):
48
+ """
49
+ Transforms parameters to all real numbers for optimization convenience.
50
+ """
51
+
52
+ @abstractmethod
53
+ def order_node_sequence(self):
54
+ """
55
+ Concatenates node constraint sequence in a canonical order.
56
+ """
57
+
58
+ @abstractmethod
59
+ def get_initial_guess(self, option):
60
+ """
61
+ Gets initial guess.
62
+ """
63
+
64
+ @abstractmethod
65
+ def expected_node_sequence(self, v):
66
+ """
67
+ Computes the expected node constraint using matrices.
68
+ """
69
+
70
+ @abstractmethod
71
+ def expected_node_sequence_loops(self, v):
72
+ """
73
+ Computes the expected node constraint using loops.
74
+ """
75
+
76
+ @jax_class_jit
77
+ def node_sequence_residuals(self, v):
78
+ """
79
+ Computes the residuals of the expected node constraint sequence minus the actual sequence.
80
+ """
81
+ return self.expected_node_sequence(v) - self.order_node_sequence()
82
+
83
+ @abstractmethod
84
+ def neg_log_likelihood_loops(self, v):
85
+ """
86
+ Computes the negative log-likelihood using loops.
87
+ """
88
+
89
+ @abstractmethod
90
+ def neg_log_likelihood(self, v):
91
+ """
92
+ Computes the negative log-likelihood using matrix operations.
93
+ """
94
+
95
+ def compute_relative_error(self, expected):
96
+ """
97
+ Computes relative error for solution for every element of the sequence.
98
+ """
99
+ actual = self.order_node_sequence()
100
+
101
+ # okay not actually relative error but close enough
102
+ return np.abs(expected - actual) / (1 + np.abs(actual))
103
+
104
+ def solve(self, x0, method="trust-krylov", verbose=False):
105
+ """
106
+ Solves for the parameters of the null model using either bounded minimization of the
107
+ negative log-likelihood or bounded least-squares minimization of the equation residuals.
108
+ """
109
+
110
+ args = {}
111
+
112
+ # for some reason scipy prefers hess over hessp if the former is passed
113
+ # but since the latter is more efficient, only pass hess when necessary
114
+ if method in ["trust-exact", "dogleg"]:
115
+ hess = jit(jacfwd(jacrev(self.neg_log_likelihood)))
116
+ args["hess"] = hess
117
+ elif method in ["Newton-CG", "trust-ncg", "trust-krylov", "trust-constr"]:
118
+ hessp = jit(hvp(self.neg_log_likelihood))
119
+ args["hessp"] = hessp
120
+
121
+ if method in ["trf", "dogbox", "lm"]:
122
+ f = self.node_sequence_residuals
123
+ jac = jit(jacrev(self.expected_node_sequence))
124
+ args["jac"] = jac
125
+ solver = scipy.optimize.least_squares
126
+ elif method in [
127
+ "Nelder-Mead",
128
+ "Powell",
129
+ "CG",
130
+ "BFGS",
131
+ "Newton-CG",
132
+ "L-BFGS-B",
133
+ "TNC",
134
+ "COBYLA",
135
+ "SLSQP",
136
+ "trust-constr",
137
+ "dogleg",
138
+ "trust-ncg",
139
+ "trust-exact",
140
+ "trust-krylov",
141
+ ]:
142
+ f = self.neg_log_likelihood
143
+ jac = jit(grad(self.neg_log_likelihood))
144
+
145
+ # lbfgsb is fussy. wont accept jax's devicearray
146
+ # there may be others, though
147
+ if method in ["L-BFGS-B"]:
148
+ jac = wrap_with_array(jac)
149
+
150
+ if method in [
151
+ "CG",
152
+ "BFGS",
153
+ "Newton-CG",
154
+ "L-BFGS-B",
155
+ "TNC",
156
+ "SLSQP",
157
+ "dogleg",
158
+ "trust-ncg",
159
+ "trust-krylov",
160
+ "trust-exact",
161
+ "trust-constr",
162
+ ]:
163
+ args["jac"] = jac
164
+
165
+ solver = scipy.optimize.minimize
166
+ else:
167
+ raise ValueError("Invalid optimization method")
168
+
169
+ start = time.time()
170
+ sol = solver(f, x0=x0, method=method, **args)
171
+ end = time.time()
172
+
173
+ total_time = end - start
174
+
175
+ eq_r = self.node_sequence_residuals(sol.x)
176
+ expected = self.expected_node_sequence(sol.x)
177
+ residual_error_norm = np.linalg.norm(eq_r, ord=2)
178
+ relative_error = self.compute_relative_error(expected)
179
+ nll = self.neg_log_likelihood(sol.x)
180
+
181
+ if not sol.success:
182
+ if np.max(relative_error) < 0.5:
183
+ warnings.warn(
184
+ "Didn't succeed according to algorithm, but max relative error is low.",
185
+ RuntimeWarning,
186
+ )
187
+ else:
188
+ raise RuntimeError(
189
+ f"Didn't succeed in minimization. Message: {sol.message}"
190
+ )
191
+
192
+ if verbose:
193
+ print(f"Took {total_time} seconds")
194
+ print("Relative error for expected degree/strength sequence: ")
195
+ print()
196
+ print_percentiles(relative_error)
197
+
198
+ print(f"\nResidual error: {residual_error_norm}")
199
+
200
+ return Solution(
201
+ x=sol.x,
202
+ nll=float(nll),
203
+ residual_error_norm=residual_error_norm,
204
+ relative_error=relative_error,
205
+ total_time=total_time,
206
+ )
@@ -0,0 +1,11 @@
1
+ from .bicm import BICM as BICM
2
+ from .biecm import BIECM as BIECM
3
+ from .bwcm import BWCM as BWCM
4
+ from .decm import DECM as DECM
5
+ from .ecm import ECM as ECM
6
+ from .rcm import RCM as RCM
7
+
8
+ import jax
9
+
10
+ # ensure jax is using doubles. important.
11
+ jax.config.update("jax_enable_x64", True)
maxent_graph/bicm.py ADDED
@@ -0,0 +1,301 @@
1
+ import numpy as np
2
+ import scipy.optimize
3
+ import scipy.special
4
+ import pandas as pd
5
+ import itertools
6
+ import numba
7
+ import time
8
+
9
+ from tqdm import tqdm
10
+
11
+ import jax.numpy as jnp
12
+
13
+ from .MaxentGraph import MaxentGraph
14
+ from .util import EPS, flatten, jax_class_jit, R_to_zero_to_inf
15
+ from . import poibin
16
+
17
+
18
+ class BICM(MaxentGraph):
19
+ def __init__(self, B, transform=0):
20
+ self.B = B
21
+ num_rows, num_cols = B.shape
22
+
23
+ self.num_edges = B.count_nonzero()
24
+
25
+ row_sums = flatten(B.sum(axis=1)).astype(np.float64)
26
+ col_sums = flatten(B.sum(axis=0)).astype(np.float64)
27
+
28
+ assert len(row_sums) == num_rows
29
+ assert len(col_sums) == num_cols
30
+
31
+ # since in empirical networks there will be many nodes with the same degree
32
+ # we can count them and use that information to speed up solving the equations.
33
+ # the bicm doesn't distinguish between nodes with the same degree.
34
+ # we also want to keep track of which nodes have which degree (for later). for that we just use pd's groupby
35
+ row_degrees, row_inverse, row_multiplicity = np.unique(
36
+ row_sums, return_index=False, return_inverse=True, return_counts=True
37
+ )
38
+ row_df = pd.DataFrame(row_sums)
39
+ self.row_groups = row_df.groupby(by=0).groups
40
+ self.row_degrees = row_degrees
41
+ self.row_inverse = row_inverse
42
+ self.row_multiplicity = row_multiplicity
43
+
44
+ col_degrees, col_inverse, col_multiplicity = np.unique(
45
+ col_sums, return_index=False, return_inverse=True, return_counts=True
46
+ )
47
+ col_df = pd.DataFrame(col_sums)
48
+ self.col_groups = col_df.groupby(by=0).groups
49
+ self.col_degrees = col_degrees
50
+ self.col_inverse = col_inverse
51
+ self.col_multiplicity = col_multiplicity
52
+
53
+ self.n_row_degrees = len(self.row_degrees)
54
+ self.n_col_degrees = len(self.col_degrees)
55
+ self.total_unique = self.n_row_degrees + self.n_col_degrees
56
+
57
+ self.transform, self.inv_transform = R_to_zero_to_inf[transform]
58
+
59
+ def bounds(self):
60
+ lower_bounds = np.array([EPS] * self.total_unique)
61
+ upper_bounds = np.array([np.inf] * self.total_unique)
62
+
63
+ return (
64
+ (lower_bounds, upper_bounds),
65
+ scipy.optimize.Bounds(lower_bounds, upper_bounds),
66
+ )
67
+
68
+ def order_node_sequence(self):
69
+ return np.concatenate([self.row_degrees, self.col_degrees])
70
+
71
+ @jax_class_jit
72
+ def transform_parameters(self, v):
73
+ return self.transform(v)
74
+
75
+ @jax_class_jit
76
+ def transform_parameters_inv(self, v):
77
+ return self.inv_transform(v)
78
+
79
+ def get_initial_guess(self, option=1):
80
+ if option == 1:
81
+ x0_rows = self.row_degrees / np.max(self.row_degrees)
82
+ x0_cols = self.col_degrees / np.max(self.col_degrees)
83
+ elif option == 2:
84
+ x0_rows = self.row_degrees / np.sqrt(np.sum(self.row_degrees) + 1)
85
+ x0_cols = self.col_degrees / np.sqrt(np.sum(self.col_degrees) + 1)
86
+ elif option == 3:
87
+ denom = np.sqrt(np.sum(self.row_degrees) * np.sum(self.col_degrees))
88
+
89
+ x0_rows = self.row_degrees / denom
90
+ x0_cols = self.col_degrees / denom
91
+ else:
92
+ raise ValueError("Invalid option value. Choose from 1-3.")
93
+
94
+ initial_guess = self.clip(np.concatenate([x0_rows, x0_cols]))
95
+
96
+ return self.transform_parameters_inv(initial_guess)
97
+
98
+ @jax_class_jit
99
+ def expected_node_sequence(self, v):
100
+ z = self.transform_parameters(v)
101
+
102
+ x = z[: self.n_row_degrees]
103
+ y = z[self.n_row_degrees :]
104
+
105
+ xy = jnp.outer(x, y)
106
+ p = xy / (1 + xy)
107
+
108
+ # row expected
109
+ # multiply every row by col_multiplicity then sum across columns
110
+ row_expected = (p * self.col_multiplicity).sum(axis=1)
111
+
112
+ # multiply every column by row_multiplicity then sum across rows
113
+ col_expected = (p.T * self.row_multiplicity).sum(axis=1)
114
+
115
+ return jnp.concatenate((row_expected, col_expected))
116
+
117
+ def expected_node_sequence_loops(self, v):
118
+ z = self.transform_parameters(v)
119
+
120
+ x = z[: self.n_row_degrees]
121
+ y = z[self.n_row_degrees :]
122
+
123
+ row_expected = np.zeros(self.n_row_degrees)
124
+ col_expected = np.zeros(self.n_col_degrees)
125
+
126
+ for i in range(self.n_row_degrees):
127
+ for j in range(self.n_col_degrees):
128
+ x_ij = x[i] * y[j]
129
+ v = x_ij / (1.0 + x_ij)
130
+ row_expected[i] += self.col_multiplicity[j] * v
131
+ col_expected[j] += self.row_multiplicity[i] * v
132
+
133
+ return np.concatenate((row_expected, col_expected))
134
+
135
+ def neg_log_likelihood_loops(self, v):
136
+ z = self.transform_parameters(v)
137
+
138
+ x = z[: self.n_row_degrees]
139
+ y = z[self.n_row_degrees :]
140
+ llhood = 0
141
+
142
+ for i in range(self.n_row_degrees):
143
+ llhood += self.row_degrees[i] * self.row_multiplicity[i] * np.log(x[i])
144
+
145
+ for i in range(self.n_col_degrees):
146
+ llhood += self.col_degrees[i] * self.col_multiplicity[i] * np.log(y[i])
147
+
148
+ for i in range(self.n_row_degrees):
149
+ for j in range(self.n_col_degrees):
150
+ llhood -= (
151
+ self.row_multiplicity[i]
152
+ * self.col_multiplicity[j]
153
+ * np.log(1 + x[i] * y[j])
154
+ )
155
+
156
+ return -llhood
157
+
158
+ @jax_class_jit
159
+ def neg_log_likelihood(self, v):
160
+ z = self.transform_parameters(v)
161
+
162
+ x = z[: self.n_row_degrees]
163
+ y = z[self.n_row_degrees :]
164
+
165
+ llhood = jnp.sum(self.row_degrees * self.row_multiplicity * jnp.log(x))
166
+ llhood += jnp.sum(self.col_degrees * self.col_multiplicity * jnp.log(y))
167
+
168
+ Q = jnp.log(1 + jnp.outer(x, y))
169
+ Q = Q * self.col_multiplicity
170
+ Q = Q.T * self.row_multiplicity
171
+ # don't need to transpose back because we're summing anyways
172
+ llhood -= jnp.sum(Q)
173
+
174
+ return -llhood
175
+
176
+ def get_fitness_model_solution(self):
177
+ """
178
+ Just a good initial guess based on the 'fitness' assumption
179
+ """
180
+ start = time.time()
181
+ solution = scipy.optimize.root_scalar(
182
+ self.fitness_zero,
183
+ args=(
184
+ self.num_edges,
185
+ self.row_degrees,
186
+ self.row_multiplicity,
187
+ self.col_degrees,
188
+ self.col_multiplicity,
189
+ ),
190
+ method=None,
191
+ x0=1e-10,
192
+ x1=0.01,
193
+ fprime=self.fitness_zero_prime,
194
+ fprime2=self.fitness_zero_prime_prime,
195
+ )
196
+ print(f"Fitness model solution took {time.time() - start}")
197
+
198
+ z = solution.root
199
+
200
+ return self.inv_transform(np.sqrt(z) * self.order_node_sequence())
201
+
202
+ @staticmethod
203
+ @numba.jit(nopython=True)
204
+ def fitness_zero(x, E, row_degrees, row_mult, col_degrees, col_mult):
205
+ s = -E
206
+ for i, _ in enumerate(row_degrees):
207
+ for j, _ in enumerate(col_degrees):
208
+ kk = row_degrees[i] * col_degrees[j]
209
+ s += row_mult[i] * col_mult[j] * x * kk / (1 + x * kk)
210
+ return s
211
+
212
+ @staticmethod
213
+ @numba.jit(nopython=True)
214
+ def fitness_zero_prime(x, E, row_degrees, row_mult, col_degrees, col_mult):
215
+ s = 0
216
+ for i, _ in enumerate(row_degrees):
217
+ for j, _ in enumerate(col_degrees):
218
+ kk = row_degrees[i] * col_degrees[j]
219
+ s += row_mult[i] * col_mult[j] * kk / (1 + x * kk) ** 2
220
+ return s
221
+
222
+ @staticmethod
223
+ @numba.jit(nopython=True)
224
+ def fitness_zero_prime_prime(x, E, row_degrees, row_mult, col_degrees, col_mult):
225
+ s = 0
226
+ for i, _ in enumerate(row_degrees):
227
+ for j, _ in enumerate(col_degrees):
228
+ kk = row_degrees[i] * col_degrees[j]
229
+ s += row_mult[i] * col_mult[j] * -2 * kk**2 / (1 + x * kk) ** 3
230
+ return s
231
+
232
+ @staticmethod
233
+ @numba.jit(nopython=True, parallel=True)
234
+ def get_probs_with_multiplicity(i, j, x, y):
235
+ v_i = x[i] * y
236
+ v_j = x[j] * y
237
+
238
+ ps_i = v_i / (1 + v_i)
239
+ ps_j = v_j / (1 + v_j)
240
+
241
+ expected_lambda_motif_probs = ps_i * ps_j
242
+
243
+ return expected_lambda_motif_probs
244
+
245
+ def get_projection(self, solution, p_val=0.05):
246
+ B = self.B
247
+ # faster indexing when dense. but, more memory. in most cases it won't be sparse so this is fine.
248
+ # this will be symmetric
249
+ observed_lambda_motif_counts = (B @ B.T).todense()
250
+ nonzero_set = set(zip(*observed_lambda_motif_counts.nonzero()))
251
+
252
+ print(f"Nonzero lambda-motif counts to check pval of {len(nonzero_set)}")
253
+ print(f"Total possible pairs: {scipy.special.comb(B.shape[0], 2)}")
254
+
255
+ z = self.transform(solution)
256
+
257
+ # numba doesn't like xlaarrays
258
+ x = np.array(z[: self.n_row_degrees])
259
+ y = np.array(z[self.n_row_degrees :])
260
+
261
+ print(f"Unique degrees {(self.n_row_degrees, self.n_col_degrees)}")
262
+
263
+ edgelist = []
264
+ print(
265
+ f"Total unique row degree pairs to check {scipy.special.comb(self.n_row_degrees, 2)}"
266
+ )
267
+ for i, j in tqdm(itertools.combinations(range(self.n_row_degrees), 2)):
268
+ degree_i = self.row_degrees[i]
269
+ degree_j = self.row_degrees[j]
270
+
271
+ expected_lambda_motif_probs_with_mult = self.get_probs_with_multiplicity(
272
+ i, j, x, y
273
+ )
274
+
275
+ mean = poibin.mean_with_multiplicity(
276
+ expected_lambda_motif_probs_with_mult, self.col_multiplicity
277
+ )
278
+
279
+ # filter zeros to speed up loop
280
+ # this may slow it down if the number of nonzeros is very dense, but only slightly
281
+ # if it's sparse then this can substantially speed up
282
+ to_check = nonzero_set & set(
283
+ itertools.product(self.row_groups[degree_i], self.row_groups[degree_j])
284
+ )
285
+
286
+ for orig_i, orig_j in to_check:
287
+ observed = observed_lambda_motif_counts[orig_i, orig_j]
288
+ assert observed != 0
289
+ q = poibin.poisson_wh_cdf(observed, mean)
290
+ p = 1 - q
291
+ if p < p_val:
292
+ # the WH approx to the poisson isn't numerically stable this low, nor is the dc_fft
293
+ # breaks down in the 1-e14/1e-15 range
294
+ if p < 1e-13:
295
+ # technically an upper bound on poisson approx, but it keeps close enough for these very small values to get order of magnitude
296
+ # and is numerically stable/fast. seems to be stable at least until 1e-300
297
+ # certainly NOT low relative error this small, just order of magnitude is right
298
+ p = poibin.poisson_upper(observed, mean)
299
+ edgelist.append((orig_i, orig_j, -np.log(p)))
300
+
301
+ return edgelist