polyads 0.0.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.
polyads/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .model import PolyadEstimator
2
+ from .data import generate_data
3
+
4
+ __all__ = ["PolyadEstimator", "generate_data"]
@@ -0,0 +1,56 @@
1
+ import numpy as np
2
+ from numba import jit
3
+
4
+ @jit(nopython=True)
5
+ def _binary_search_edge_value(keys, values, query):
6
+ """
7
+ Generalized binary search for edge value in a sorted list of edge keys.
8
+
9
+ Parameters
10
+ ----------
11
+ keys : np.ndarray
12
+ Array of edge keys (shape: [n_edges, D]).
13
+ values : np.ndarray
14
+ Array of edge values (shape: [n_edges]).
15
+ query : np.ndarray
16
+ Query key (shape: [D]).
17
+
18
+ Returns
19
+ -------
20
+ int
21
+ Value associated with query key, or 0 if not found.
22
+ """
23
+ lo = 0
24
+ hi, D = keys.shape
25
+ hi -= 1
26
+
27
+ while lo <= hi:
28
+ mid = (lo + hi) // 2
29
+ kmid = keys[mid]
30
+
31
+ # --- lex_less(kmid, query) ---
32
+ less = False
33
+ for i in range(D):
34
+ if kmid[i] < query[i]:
35
+ less = True
36
+ break
37
+ elif kmid[i] > query[i]:
38
+ less = False
39
+ break
40
+
41
+ if less:
42
+ lo = mid + 1
43
+ continue
44
+
45
+ # --- lex_equal(kmid, query) ---
46
+ equal = True
47
+ for i in range(D):
48
+ if kmid[i] != query[i]:
49
+ equal = False
50
+ break
51
+ if equal:
52
+ return values[mid]
53
+
54
+ hi = mid - 1
55
+
56
+ return 0
polyads/data.py ADDED
@@ -0,0 +1,54 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ def generate_data(seed, n_ds, c, shape, beta, groups=None, return_full = False):
5
+ """
6
+ Generate synthetic data for polyad model.
7
+ Allows for p-dimensional features (last axis of X is p).
8
+ beta should be a vector of length p or a scalar.
9
+ """
10
+ rng = np.random.default_rng(seed)
11
+ D = len(n_ds)
12
+
13
+ # beta: shape (p,) or scalar
14
+ beta = np.asarray(beta)
15
+ if beta.ndim == 0:
16
+ beta = beta[None]
17
+ p = beta.size
18
+
19
+ X = rng.normal(size=(*n_ds, p))
20
+ # for i in range(1, n_ds[-1]):
21
+ # X[:,:,i,-1] = X[:,:,0,-1] # to force a singular Hessian
22
+
23
+ # Compute linear predictor
24
+ linpred = c + np.tensordot(X, beta, axes=([-1],[0]))
25
+
26
+ # Fill the null groups
27
+ if groups is None:
28
+ groups = []
29
+ for d in range(D):
30
+ groups.append( [d_p for d_p in range(D) if d_p != d] )
31
+
32
+ # Add the fixed effects
33
+ thetas = []
34
+ for g in groups:
35
+ theta_g = rng.normal(scale = .25, size = tuple( n_ds[d] for d in g ))
36
+ thetas.append(theta_g)
37
+ linpred += theta_g[tuple(slice(None) if d in g else np.newaxis for d in range(D))]
38
+
39
+ if shape == np.inf:
40
+ lam = np.exp(linpred)
41
+ else:
42
+ lam = rng.gamma(shape, np.exp(linpred)/shape)
43
+ Y = rng.poisson(lam)
44
+
45
+ keys = np.where(Y > 0)
46
+ values = Y[Y>0]
47
+
48
+ df = pd.DataFrame({f"i_{i}": keys[i] for i in range(len(n_ds))})
49
+ df["Y"] = values
50
+
51
+ if return_full:
52
+ return Y, X, linpred, thetas
53
+ else:
54
+ return df, X
polyads/fitter.py ADDED
@@ -0,0 +1,189 @@
1
+
2
+ import warnings
3
+ try:
4
+ from numba.core.errors import NumbaPendingDeprecationWarning
5
+ warnings.filterwarnings("ignore", category=NumbaPendingDeprecationWarning)
6
+ except ImportError:
7
+ pass
8
+ warnings.filterwarnings("ignore", category=UserWarning)
9
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
10
+ warnings.filterwarnings("ignore", category=FutureWarning)
11
+ import numpy as np
12
+ import time
13
+ from .polyad_eval import (
14
+ _compute_polyad_features, _evaluate_polyad_losses,
15
+ _evaluate_loss, _compute_polyad_pairwise_covariance
16
+ )
17
+ from .polyad_generation import _find_active_polyads, _compute_polyads_permutations
18
+ from .utils import get_bar_description, _get_keys_values
19
+
20
+
21
+ # Main fitting routine for PolyadEstimator
22
+ def _fit_polyad_estimator(
23
+ beta: np.ndarray,
24
+ df: np.ndarray,
25
+ eval_X: np.ndarray,
26
+ eval_kwargs: dict = None,
27
+ max_iter: int = 100,
28
+ tol: float = 1e-4,
29
+ max_step: float = 1.0,
30
+ use_tqdm: bool = False,
31
+ loss: str = "poisson_multiclass",
32
+ max_n_polyads: int = 1e7,
33
+ variance_threshold: float = 1.0,
34
+ ) -> dict:
35
+ """
36
+ Fit the polyad model using iterative optimization.
37
+
38
+ Parameters
39
+ ----------
40
+ beta : np.ndarray
41
+ Initial parameter vector for the model.
42
+ df : np.ndarray
43
+ Data array containing the observed data (primary and edge indices/values).
44
+ eval_X : np.ndarray
45
+ Feature matrix for evaluation.
46
+ eval_kwargs : dict, optional
47
+ Additional keyword arguments for feature evaluation (default: None).
48
+ max_iter : int, optional
49
+ Maximum number of optimization iterations (default: 100).
50
+ tol : float, optional
51
+ Tolerance for convergence (default: 1e-4).
52
+ max_step : float, optional
53
+ Maximum allowed step size for parameter updates (default: 0.5).
54
+ use_tqdm : bool, optional
55
+ Whether to display a progress bar using tqdm (default: False).
56
+ loss : str, optional
57
+ Loss function name. Supported: 'poisson_binary', 'poisson_multiclass', 'poisson_binary_multiclass'.
58
+ (default: 'poisson_multiclass')
59
+ max_n_polyads : int, optional
60
+ Maximum number of polyads to consider (default: 1e7).
61
+ variance_threshold : float, optional
62
+ Threshold for variance approximation (default: 1.0). Ranges from 0 (never) to 1 (always).
63
+
64
+ Returns
65
+ -------
66
+ dict
67
+ Dictionary containing the results of the optimization, including:
68
+ - 'beta': Final parameter vector
69
+ - 'converged': Whether convergence was achieved
70
+ - 'loss': Final loss value
71
+ - 'score': Gradient at solution
72
+ - 'hessian': Hessian matrix at solution
73
+ - 'det': Determinant of Hessian
74
+ - 'iterations': Number of iterations performed
75
+ - 'var': Estimated parameter covariance matrix
76
+ - 'n_polyads': Number of polyads used
77
+ - 'time': Total runtime in seconds
78
+ """
79
+
80
+ num_pos_edges = len(df)
81
+ if use_tqdm:
82
+ from tqdm import tqdm
83
+ progress_bar = tqdm(total=max_iter, desc=f"Gathering polyads over {num_pos_edges}² pairs of edges")
84
+
85
+ ts = time.time()
86
+ D: int = len(df.columns) - 1 # Polyad dimension
87
+ p: int = beta.size
88
+
89
+ primary_indices, edge_indices, edge_values = _get_keys_values(df.values)
90
+ # Find all active polyads in the data
91
+ xis, Y_xis, m_xis, M_xis = _find_active_polyads(
92
+ D, primary_indices, edge_indices, edge_values, int(max_n_polyads))
93
+ # Compute feature matrix for all polyads
94
+ X_xis = _compute_polyad_features(
95
+ D, p, xis, eval_X, kwargs=eval_kwargs)
96
+
97
+ num_polyads: int = m_xis.size
98
+
99
+ if num_polyads == 0:
100
+ if use_tqdm:
101
+ progress_bar.close()
102
+ return {
103
+ "beta": beta,
104
+ "converged": False,
105
+ "loss": np.inf,
106
+ "score": np.full(p, np.inf),
107
+ "hessian": np.full((p, p), np.inf),
108
+ "det": np.inf,
109
+ "iterations": 0,
110
+ "var": np.full((p, p), np.inf),
111
+ "n_edges": num_pos_edges,
112
+ "n_polyads": 0,
113
+ "n_pairs": 0,
114
+ "time": time.time() - ts
115
+ }
116
+
117
+ if use_tqdm:
118
+ progress_bar.set_description(get_bar_description(beta, num_polyads))
119
+
120
+ iteration: int = 0
121
+ while iteration < max_iter:
122
+ iteration += 1
123
+
124
+ # Evaluate loss, gradient, and Hessian for current parameters
125
+ log_likelihoods, expectations, variances = _evaluate_polyad_losses(
126
+ D, Y_xis, X_xis, m_xis, M_xis, beta, loss)
127
+ loss_val, gradient, hessian = _evaluate_loss(p, X_xis, log_likelihoods, expectations, variances)
128
+ hessian_det = np.linalg.det(hessian)
129
+ gradient_norm = np.linalg.norm(gradient)
130
+
131
+ # Compute update direction (Newton or gradient step)
132
+ if hessian_det > 1e-8:
133
+ update_direction = np.linalg.solve(hessian, gradient)
134
+ else:
135
+ update_direction = gradient
136
+ update_norm = np.linalg.norm(update_direction)
137
+
138
+ # Limit step size if necessary
139
+ update_direction = update_direction if update_norm <= max_step else update_direction/update_norm * max_step
140
+ beta -= update_direction
141
+ if use_tqdm:
142
+ progress_bar.update(1)
143
+ progress_bar.set_description(get_bar_description(beta, num_polyads, grad_norm=gradient_norm, det=hessian_det))
144
+
145
+ # Check for convergence or stopping criteria
146
+ if update_norm < tol or iteration == max_iter or hessian_det <= 1e-8:
147
+ var = np.inf * np.ones((p, p))
148
+ n_pairs = 0
149
+
150
+ if hessian_det > 1e-8:
151
+ if use_tqdm:
152
+ progress_bar.set_description(f"Finding all permutations of the {num_polyads} active polyads")
153
+
154
+ # Compute covariance matrix for parameter estimates
155
+ xi_permutations = _compute_polyads_permutations(D, xis)
156
+ xi_permutations = xi_permutations[np.lexsort([xi_permutations[:,i] for i in range(D)])]
157
+ permutation_group_ends = np.append(
158
+ 1 + np.where(np.sum(np.abs(xi_permutations[1:,:D] - xi_permutations[:-1,:D]), axis=1) > 0)[0],
159
+ xi_permutations.shape[0])
160
+ n_pairs = permutation_group_ends[1:] - permutation_group_ends[:-1]
161
+ n_pairs = (n_pairs * (n_pairs + 1) // 2).sum()
162
+ n_pairs += permutation_group_ends[0] * (permutation_group_ends[0] + 1) // 2
163
+
164
+ if use_tqdm:
165
+ progress_bar.set_description(f"Looping over {num_polyads} polyads to evaluate the variance")
166
+
167
+ covariance_matrix = _compute_polyad_pairwise_covariance(
168
+ D, p, num_polyads,
169
+ xi_permutations, permutation_group_ends,
170
+ expectations, X_xis,
171
+ variance_threshold = variance_threshold
172
+ )
173
+ inv_hessian = np.linalg.inv(hessian)
174
+ var = inv_hessian @ covariance_matrix @ inv_hessian.T
175
+
176
+ return {
177
+ "beta": beta,
178
+ "converged": bool(update_norm < tol),
179
+ "loss": loss_val,
180
+ "score": gradient,
181
+ "hessian": hessian,
182
+ "det": hessian_det,
183
+ "iterations": iteration,
184
+ "var": var,
185
+ "n_edges": num_pos_edges,
186
+ "n_polyads": num_polyads,
187
+ "n_pairs": n_pairs,
188
+ "time": time.time() - ts
189
+ }
polyads/losses.py ADDED
@@ -0,0 +1,77 @@
1
+ import numpy as np
2
+ from numba import jit
3
+
4
+ @jit(nopython=True)
5
+ def _compute_polyad_loss(
6
+ Y_xis: np.ndarray,
7
+ m_xis: int,
8
+ M_xis: int,
9
+ signs: np.ndarray,
10
+ c: float,
11
+ loss: str
12
+ ) -> tuple:
13
+ """
14
+ Evaluate the distribution for a given polyad.
15
+
16
+ Parameters
17
+ ----------
18
+ Y_xi : np.ndarray
19
+ Array of observed counts for each configuration of the polyad.
20
+ min_positive_count : int
21
+ Minimum count among positive-sign configurations.
22
+ min_negative_count : int
23
+ Minimum count among negative-sign configurations.
24
+ signs : np.ndarray
25
+ Array of +1/-1 signs for each configuration.
26
+ c : float
27
+ Linear predictor (dot product of features and parameters).
28
+ loss : str
29
+ Loss function name. Supported: 'poisson_binary', 'poisson_multiclass', 'poisson_binary_multiclass'.
30
+
31
+ Returns
32
+ -------
33
+ tuple
34
+ (loss, expectation, variance) for the polyad.
35
+ """
36
+ Y_0 = Y_xis - signs * m_xis
37
+ nl = np.arange(m_xis + M_xis + 1)
38
+ W = np.empty(m_xis + M_xis + 1)
39
+ W[0] = 0
40
+ for i in range(m_xis + M_xis):
41
+ W[i+1] = W[i] - np.sum( signs * np.log(Y_0 + i * signs + (signs+1)/2) ) + c
42
+ W -= np.max(W)
43
+ ps = np.exp(W)
44
+
45
+ if loss == "poisson_multiclass":
46
+ err = np.log(ps.sum()) - W[m_xis]
47
+ ps /= ps.sum()
48
+ exp = (nl * ps).sum()
49
+ var = ((nl**2) * ps).sum() - exp**2
50
+ return err, exp - m_xis, var
51
+
52
+ else:
53
+ err_t = np.log(ps.sum())
54
+ ps_t = ps/ps.sum()
55
+ exp_t = (nl * ps_t).sum()
56
+ var_t = ((nl**2) * ps_t).sum() - exp_t**2
57
+
58
+ if loss == "poisson_binary_balanced":
59
+ cut_m = 1
60
+ ps_under_cut = ps_t[0]
61
+ while ps_under_cut < .5 and cut_m < m_xis + M_xis:
62
+ ps_under_cut += ps_t[cut_m]
63
+ cut_m += 1
64
+ else:
65
+ cut_m = (m_xis + M_xis + 1)//2
66
+
67
+ if m_xis >= cut_m:
68
+ err_s = np.log(ps[cut_m:].sum())
69
+ ps_s = ps[cut_m:]/ps[cut_m:].sum()
70
+ exp_s = (nl[cut_m:] * ps_s).sum()
71
+ var_s = ((nl[cut_m:]**2) * ps_s).sum() - exp_s**2
72
+ else:
73
+ err_s = np.log(ps[:cut_m].sum())
74
+ ps_s = ps[:cut_m]/ps[:cut_m].sum()
75
+ exp_s = (nl[:cut_m] * ps_s).sum()
76
+ var_s = ((nl[:cut_m]**2) * ps_s).sum() - exp_s**2
77
+ return err_t - err_s, exp_t - exp_s, var_t - var_s
polyads/model.py ADDED
@@ -0,0 +1,217 @@
1
+ import numpy as np
2
+ from scipy.stats import norm
3
+ import pandas as pd
4
+ from .validations import _validate_fit_inputs
5
+ from .fitter import _fit_polyad_estimator
6
+
7
+ class PolyadEstimator:
8
+ """
9
+ Scikit-learn-like estimator for polyad-based statistical modeling.
10
+ """
11
+ def __init__(
12
+ self,
13
+ max_iter: int = 100,
14
+ tol: float = 1e-4,
15
+ max_step: float = 1.0,
16
+ loss: str = "poisson_multiclass",
17
+ max_n_polyads: int = int(1e8),
18
+ variance_threshold: float = 0.0,
19
+ use_tqdm: bool = False
20
+ ) -> None:
21
+ """
22
+ Initialize a PolyadEstimator instance.
23
+
24
+ Parameters
25
+ ----------
26
+ max_iter : int, optional
27
+ Maximum number of optimization iterations (default: 100).
28
+ tol : float, optional
29
+ Convergence tolerance for the optimization (default: 1e-4).
30
+ max_step : float, optional
31
+ Maximum step size for parameter updates (default: 0.5).
32
+ loss : str, optional
33
+ Loss function name. Supported: 'poisson_binary', 'poisson_multiclass'.
34
+ (default: 'poisson_multiclass')
35
+ max_n_polyads : int, optional
36
+ Maximum number of polyads to generate/use (default: 1e7).
37
+ variance_threshold : float, optional
38
+ Threshold for variance approximation (default: 0.0). Ranges from 0 (never) to 1 (always).
39
+ use_tqdm : bool, optional
40
+ Whether to display a progress bar during fitting (default: False).
41
+
42
+ Returns
43
+ -------
44
+ None
45
+ """
46
+ self.max_iter = max_iter
47
+ self.tol = tol
48
+ self.max_step = max_step
49
+ self.loss = loss
50
+ self.max_n_polyads = int(max_n_polyads)
51
+ self.variance_threshold = variance_threshold
52
+ self.use_tqdm = use_tqdm
53
+ self.beta_ = None
54
+ self.n_edges_ = None
55
+ self.n_polyads_ = None
56
+ self.n_pairs_ = None
57
+ self.loss_ = None
58
+ self.score_ = None
59
+ self.hessian_ = None
60
+ self.det_ = None
61
+ self.var_ = None
62
+ self.converged_ = None
63
+ self.iterations_ = None
64
+ self.time_ = None
65
+
66
+ def _validate_input(self, df, beta_init, eval_X, X, loss, indices, values):
67
+ supported_losses = ["poisson_binary", "poisson_multiclass", "poisson_binary_balanced"]
68
+ return _validate_fit_inputs(df, beta_init, eval_X, X, loss, supported_losses, indices, values)
69
+
70
+ @staticmethod
71
+ def default_eval_X(matrix: np.ndarray) -> 'callable':
72
+ """
73
+ Returns an eval_X function for a given 3D matrix.
74
+ """
75
+ def eval_X(key):
76
+ return matrix[tuple(key)]
77
+ return eval_X
78
+
79
+ def fit(
80
+ self,
81
+ df: pd.DataFrame,
82
+ indices: list,
83
+ values: str,
84
+ beta_init: np.ndarray,
85
+ eval_X: 'callable | None' = None,
86
+ eval_kwargs: 'dict | None' = None,
87
+ X: 'np.ndarray | None' = None,
88
+ loss: str = None,
89
+ ) -> 'PolyadEstimator':
90
+ """
91
+ Fit the polyad model to data.
92
+
93
+ Parameters
94
+ ----------
95
+ df : pandas.DataFrame
96
+ DataFrame with columns representing the observed data (typically columns: t, i, j, v).
97
+ beta_init : np.ndarray or list or float
98
+ Initial guess for the parameter vector (shape: (n_features,) or scalar).
99
+ eval_X : callable or None, optional
100
+ Feature extraction function. If None and X is provided, uses default_eval_X(X).
101
+ Should accept a key (tuple or array) and return a feature vector.
102
+ eval_kwargs : dict or None, optional
103
+ Additional keyword arguments to pass to eval_X.
104
+ X : np.ndarray or None, optional
105
+ 3D or 4D numpy array of features. If provided and eval_X is None, uses default_eval_X(X).
106
+
107
+ Returns
108
+ -------
109
+ self : PolyadEstimator
110
+ The fitted estimator instance (self).
111
+ """
112
+
113
+ if eval_X is None:
114
+ if X is not None:
115
+ eval_X = self.default_eval_X(X)
116
+ if eval_kwargs is None:
117
+ eval_kwargs = {}
118
+ else:
119
+ raise ValueError("You must provide either eval_X or X (matrix)!")
120
+
121
+ # Allow override of loss for this fit, else use self.loss
122
+ loss_name = loss if loss is not None else self.loss
123
+
124
+ # Validate all inputs before fitting
125
+ df, beta_init = self._validate_input(df, beta_init, eval_X, X, loss_name, indices, values)
126
+
127
+ result = _fit_polyad_estimator(
128
+ beta_init,
129
+ df,
130
+ eval_X,
131
+ eval_kwargs=eval_kwargs,
132
+ max_iter=self.max_iter,
133
+ tol=self.tol,
134
+ max_step=self.max_step,
135
+ use_tqdm=self.use_tqdm,
136
+ loss=loss_name,
137
+ max_n_polyads=self.max_n_polyads,
138
+ variance_threshold=self.variance_threshold
139
+ )
140
+ self.beta_ = result['beta']
141
+ self.n_edges_ = result['n_edges']
142
+ self.n_polyads_ = result['n_polyads']
143
+ self.n_pairs_ = result['n_pairs']
144
+ self.loss_ = result['loss']
145
+ self.score_ = result['score']
146
+ self.hessian_ = result['hessian']
147
+ self.det_ = result['det']
148
+ self.var_ = result['var']
149
+ self.converged_ = result['converged']
150
+ self.iterations_ = result['iterations']
151
+ self.time_ = result['time']
152
+ return self
153
+
154
+ def summary(self, alpha: float = 0.05) -> None:
155
+ """
156
+ Display a regression summary for a fitted PolyadEstimator, similar to statsmodels/pystats.
157
+ Shows coefficient, std err, z, p-value, and confidence intervals.
158
+ """
159
+ if self.beta_ is None or self.var_ is None:
160
+ print("Model is not fitted or variance is not available.")
161
+ return
162
+ beta = np.asarray(self.beta_)
163
+ var = np.asarray(self.var_)
164
+ se = np.sqrt(np.diag(var))
165
+ z = beta / se
166
+ p = 2 * (1 - norm.cdf(np.abs(z)))
167
+ ci_low = beta + norm.ppf(alpha/2) * se
168
+ ci_upp = beta + norm.ppf(1 - alpha/2) * se
169
+ df = pd.DataFrame({
170
+ 'coef': beta,
171
+ 'std err': se,
172
+ 'z': z,
173
+ 'P>|z|': p,
174
+ f'[{100*alpha/2:.1f}%': ci_low,
175
+ f'{100*(1-alpha/2):.1f}%]': ci_upp
176
+ })
177
+ print("="*65)
178
+ print("\t"*3 + "Polyad Fit Results")
179
+ print("="*65)
180
+
181
+ if self.n_polyads_ == 0:
182
+ print(f"No polyads found. No optimization step was taken.")
183
+ else:
184
+ print(f"Converged: {self.converged_}")
185
+ print(f"Iterations: {self.iterations_}")
186
+ print(f"Time: {self.time_:.2f} seconds")
187
+ print(f"Number of Positive Edges: {self.n_edges_}")
188
+ print(f"Number of Active Polyads: {self.n_polyads_}")
189
+ if self.n_polyads_ == self.max_n_polyads:
190
+ print("(Maximum number of polyads reached. Results may be unreliable.)")
191
+
192
+ # Provide statistics when the model reaches a singular Hessian
193
+ if self.det_ <= 1e-8 and self.n_polyads_ > 0:
194
+ print("="*65)
195
+ print(df)
196
+ print("="*65)
197
+ print("The optimization procedure encountered a singular Hessian,")
198
+ print("its eigenvalues and eigenvectors are:")
199
+ eigvals, eigvecs = np.linalg.eigh(self.hessian_)
200
+ eigvals *= (np.abs(eigvals) > 1e-8)
201
+ eigvecs *= (np.abs(eigvecs) > 1e-8)
202
+ df = pd.DataFrame({
203
+ "eigenvalue": eigvals,
204
+ "eigenvector": [eigvecs[i] for i in range(eigvecs.shape[0])],
205
+ })
206
+ df.sort_values(by="eigenvalue", inplace=True)
207
+ print(df)
208
+ print("A singular Hessian may be associated with collinear variables.")
209
+ print("It can also be associated with ill-defined models, e.g.,")
210
+ print("one feature do not vary and is absorbed by the fixed effects.")
211
+ print("The eigenvectors of zero may explain both cases.")
212
+ else:
213
+ print(f"Number of Pairs of Active Polyads Sharing Edges: {self.n_pairs_}")
214
+ print("="*65)
215
+ print(df)
216
+
217
+