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 +4 -0
- polyads/binary_search.py +56 -0
- polyads/data.py +54 -0
- polyads/fitter.py +189 -0
- polyads/losses.py +77 -0
- polyads/model.py +217 -0
- polyads/polyad_eval.py +226 -0
- polyads/polyad_generation.py +191 -0
- polyads/polyad_utils.py +27 -0
- polyads/utils.py +25 -0
- polyads/validations.py +73 -0
- polyads-0.0.1.dist-info/METADATA +927 -0
- polyads-0.0.1.dist-info/RECORD +15 -0
- polyads-0.0.1.dist-info/WHEEL +4 -0
- polyads-0.0.1.dist-info/licenses/LICENSE +661 -0
polyads/__init__.py
ADDED
polyads/binary_search.py
ADDED
|
@@ -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
|
+
|