causarray 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.
- causarray/DR_estimation.py +269 -0
- causarray/DR_inference.py +201 -0
- causarray/DR_learner.py +296 -0
- causarray/__about__.py +1 -0
- causarray/__init__.py +21 -0
- causarray/gcate.py +237 -0
- causarray/gcate_glm.py +256 -0
- causarray/gcate_likelihood.py +143 -0
- causarray/gcate_opt.py +298 -0
- causarray/utils.py +243 -0
- causarray-0.0.1.dist-info/METADATA +70 -0
- causarray-0.0.1.dist-info/RECORD +14 -0
- causarray-0.0.1.dist-info/WHEEL +4 -0
- causarray-0.0.1.dist-info/licenses/LICENSE +21 -0
causarray/gcate_glm.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
from causarray.utils import *
|
|
2
|
+
from causarray.utils import _filter_params
|
|
3
|
+
import numpy as np
|
|
4
|
+
import statsmodels as stats
|
|
5
|
+
import statsmodels.api as sm
|
|
6
|
+
from sklearn.linear_model import LinearRegression
|
|
7
|
+
from joblib import Parallel, delayed
|
|
8
|
+
from tqdm import tqdm
|
|
9
|
+
|
|
10
|
+
warnings.filterwarnings('ignore')
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def init_inv_link(Y, family, disp):
|
|
14
|
+
if family=='gaussian':
|
|
15
|
+
val = Y/disp
|
|
16
|
+
elif family=='poisson':
|
|
17
|
+
val = np.log1p(Y)
|
|
18
|
+
elif family=='nb':
|
|
19
|
+
val = np.log1p(Y)
|
|
20
|
+
elif family=='binomial':
|
|
21
|
+
eps = (np.mean(Y, axis=0) + np.mean(Y, axis=1)) / 2
|
|
22
|
+
val = np.log((Y + eps)/(disp - Y + eps))
|
|
23
|
+
else:
|
|
24
|
+
raise ValueError('Family not recognized')
|
|
25
|
+
return val
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fit_glm(Y, X, A=None, family='gaussian', disp_family='poisson',
|
|
30
|
+
disp_glm=None, impute=False, offset=None, shrinkage=False,
|
|
31
|
+
alpha=1e-4, maxiter=1000, thres_disp=100., n_jobs=-3, random_state=0, verbose=False, **kwargs):
|
|
32
|
+
'''
|
|
33
|
+
Fit GLM to each column of Y, with covariate X and treatment A.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
Y : array
|
|
38
|
+
n x p matrix of outcomes
|
|
39
|
+
X : array
|
|
40
|
+
n x d matrix of covariates
|
|
41
|
+
A : array
|
|
42
|
+
n x 1 vector of treatments or None
|
|
43
|
+
family : str
|
|
44
|
+
family of GLM to fit, can be one of: 'gaussian', 'poisson', 'nb'
|
|
45
|
+
disp_glm : array or None
|
|
46
|
+
dispersion parameter for negative binomial GLM
|
|
47
|
+
return_df : bool
|
|
48
|
+
whether to return results as DataFrame
|
|
49
|
+
impute : bool
|
|
50
|
+
whether to impute potential outcomes and get predicted values
|
|
51
|
+
offset : bool
|
|
52
|
+
whether to use log of sum of Y as offset
|
|
53
|
+
'''
|
|
54
|
+
np.random.seed(random_state)
|
|
55
|
+
|
|
56
|
+
if family not in ['gaussian', 'poisson', 'nb']:
|
|
57
|
+
raise ValueError('Family not recognized')
|
|
58
|
+
|
|
59
|
+
d = X.shape[1]
|
|
60
|
+
|
|
61
|
+
if A is None:
|
|
62
|
+
a = 1 # dummy treatment
|
|
63
|
+
assert impute is False
|
|
64
|
+
else:
|
|
65
|
+
if A.ndim==1:
|
|
66
|
+
A = A[:,None]
|
|
67
|
+
if impute is not False and isinstance(impute, np.ndarray):
|
|
68
|
+
X_test = impute
|
|
69
|
+
else:
|
|
70
|
+
X_test = X
|
|
71
|
+
X_test = np.c_[X,np.zeros_like(A)]
|
|
72
|
+
X = np.c_[X,A]
|
|
73
|
+
# X_test = X.copy()
|
|
74
|
+
|
|
75
|
+
a = A.shape[1]
|
|
76
|
+
|
|
77
|
+
if offset is not None and offset is not False:
|
|
78
|
+
if type(offset)==bool and offset is True:
|
|
79
|
+
offsets = np.log(comp_size_factor(Y, **_filter_params(comp_size_factor, kwargs)))
|
|
80
|
+
else:
|
|
81
|
+
offsets = offset
|
|
82
|
+
else:
|
|
83
|
+
offsets = None
|
|
84
|
+
|
|
85
|
+
# estimate dispersion parameter for negative binomial GLM if not provided
|
|
86
|
+
if family=='nb' and disp_glm is None:
|
|
87
|
+
disp_glm = estimate_disp(Y, X, offset=offsets, disp_family=disp_family, maxiter=1000, verbose=verbose, **kwargs)
|
|
88
|
+
|
|
89
|
+
alpha = np.full(X.shape[1], alpha)
|
|
90
|
+
pprint.pprint('Fitting {} GLM{}...'.format(family, '' if offsets is None else ' with offset'))
|
|
91
|
+
is_constant = np.all(X == X[0, :], axis=0)
|
|
92
|
+
alpha[is_constant] = 0
|
|
93
|
+
# alpha[:-a] = 0
|
|
94
|
+
|
|
95
|
+
families = {
|
|
96
|
+
'gaussian': lambda disp: sm.families.Gaussian(),
|
|
97
|
+
'poisson': lambda disp: sm.families.Poisson(),
|
|
98
|
+
'nb': lambda disp: sm.families.NegativeBinomial(alpha=1/disp)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def fit_model(j, Y, X, offsets, family, disp, impute, alpha):
|
|
102
|
+
if family=='nb' and disp[j]>thres_disp:
|
|
103
|
+
family = 'poisson'
|
|
104
|
+
try:
|
|
105
|
+
with warnings.catch_warnings():
|
|
106
|
+
warnings.filterwarnings("ignore")
|
|
107
|
+
glm_family = families.get(family, lambda: ValueError('family must be one of: "gaussian", "poisson", "nb"'))(disp_glm[j] if family == 'nb' else None)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
if shrinkage:
|
|
111
|
+
raise ValueError('fit regularized GLM')
|
|
112
|
+
mod = sm.GLM(Y[:,j], X, family=glm_family, offset=offsets).fit(maxiter=maxiter)
|
|
113
|
+
if not np.all(np.isfinite(mod.params)) or np.any(np.abs(mod.params[:d])>50) or np.any(np.abs(mod.params[d:])>10):
|
|
114
|
+
raise ValueError('GLM did not converge')
|
|
115
|
+
resid_deviance = mod.resid_deviance
|
|
116
|
+
except:
|
|
117
|
+
mod = sm.GLM(Y[:,j], X, family=glm_family, offset=offsets).fit_regularized(alpha=alpha, cnvrg_tol=1e-5)
|
|
118
|
+
resid_deviance = np.full(Y.shape[0], 0.)
|
|
119
|
+
|
|
120
|
+
B = mod.params
|
|
121
|
+
|
|
122
|
+
Yhat_0 = np.zeros((Y.shape[0], a))
|
|
123
|
+
Yhat_1 = np.zeros((Y.shape[0], a))
|
|
124
|
+
if impute is not False:
|
|
125
|
+
for j in range(a):
|
|
126
|
+
X_test_copy = X_test.copy()
|
|
127
|
+
Yhat_0[:,j] = mod.predict(X_test_copy, offset=offsets)
|
|
128
|
+
X_test_copy[:, d+j] = 1 # Update the j-th column with all ones
|
|
129
|
+
Yhat_1[:,j] = mod.predict(X_test_copy, offset=offsets)
|
|
130
|
+
else:
|
|
131
|
+
Yhat_0[:,:] = Yhat_1[:,:] = mod.predict(X, offset=offsets).reshape(-1, a)
|
|
132
|
+
|
|
133
|
+
except:
|
|
134
|
+
pprint.pprint('Fitting GLM for column {} does not converge.'.format(j))
|
|
135
|
+
B = np.full(X.shape[1], 0.)
|
|
136
|
+
|
|
137
|
+
Yhat_0 = np.full((Y.shape[0], a), 0)
|
|
138
|
+
Yhat_1 = np.full((Y.shape[0], a), 0)
|
|
139
|
+
if impute is not False:
|
|
140
|
+
for k in range(a):
|
|
141
|
+
Yhat_0[:, k] = np.mean(Y[A[:, k] == 1, j])
|
|
142
|
+
Yhat_1[:, k] = np.mean(Y[A[:, k] == 0, j])
|
|
143
|
+
resid_deviance = np.full(Y.shape[0], 0.)
|
|
144
|
+
return B, Yhat_0, Yhat_1, resid_deviance
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
results = Parallel(n_jobs=n_jobs)(delayed(fit_model)(
|
|
148
|
+
j, Y, X, offsets, family, disp_glm, impute, alpha) for j in tqdm(range(Y.shape[1])))
|
|
149
|
+
|
|
150
|
+
B, Yhat_0, Yhat_1, resid_deviance = zip(*results)
|
|
151
|
+
B = np.array(B)
|
|
152
|
+
Yhat_0 = np.array(Yhat_0).transpose(1, 0, 2)
|
|
153
|
+
Yhat_1 = np.array(Yhat_1).transpose(1, 0, 2)
|
|
154
|
+
resid_deviance = np.array(resid_deviance).T
|
|
155
|
+
|
|
156
|
+
if impute is not False:
|
|
157
|
+
Yhat = (Yhat_0, Yhat_1)
|
|
158
|
+
else:
|
|
159
|
+
Yhat = np.array(Yhat_0)[:,:,0]
|
|
160
|
+
|
|
161
|
+
return B, Yhat, disp_glm, offsets, resid_deviance
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def estimate_disp(Y, X=None, A=None, Y_hat=None, disp_family='gaussian', offset=None, verbose=False, **kwargs):
|
|
165
|
+
if offset is not None:
|
|
166
|
+
if type(offset)==bool and offset is True:
|
|
167
|
+
offsets = np.log(comp_size_factor(Y, **_filter_params(comp_size_factor, kwargs)))
|
|
168
|
+
else:
|
|
169
|
+
offsets = offset
|
|
170
|
+
sf = np.exp(offsets)[:,None]
|
|
171
|
+
else:
|
|
172
|
+
offsets = None
|
|
173
|
+
sf = 1.
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if Y_hat is None:
|
|
177
|
+
if verbose:
|
|
178
|
+
pprint.pprint('Estimating dispersion parameter...')
|
|
179
|
+
|
|
180
|
+
if A is not None:
|
|
181
|
+
X = np.c_[X,A]
|
|
182
|
+
|
|
183
|
+
if disp_family=='gaussian':
|
|
184
|
+
Y_norm = Y/sf
|
|
185
|
+
reg = LinearRegression(fit_intercept=False).fit(X, Y_norm)
|
|
186
|
+
Y_hat = reg.predict(X)
|
|
187
|
+
# Y_hat = np.clip(Y_hat, 0, 1) * sf
|
|
188
|
+
elif disp_family=='poisson':
|
|
189
|
+
Y_hat = fit_glm(Y, X, None, offset=offsets, family='poisson', impute=False, **kwargs)[1]
|
|
190
|
+
Y_hat /= sf
|
|
191
|
+
|
|
192
|
+
# Clip Y_hat based on the range of Y per column
|
|
193
|
+
Y_hat = np.clip(Y_hat, 0., np.max(Y/sf, axis=0))
|
|
194
|
+
|
|
195
|
+
disp_glm = np.mean((Y/sf - Y_hat)**2 - Y_hat, axis=0) / np.mean(Y_hat**2, axis=0)
|
|
196
|
+
disp_glm = 1./np.clip(disp_glm, 0.01, 100.)
|
|
197
|
+
disp_glm[np.isnan(disp_glm)] = 1.
|
|
198
|
+
|
|
199
|
+
return disp_glm
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def loess_fit(Y, X, n_jobs=-3, **kwargs):
|
|
205
|
+
|
|
206
|
+
def _loess_fit(y, x, **kwargs):
|
|
207
|
+
try:
|
|
208
|
+
from skmisc.loess import loess
|
|
209
|
+
l = loess(x, y, **kwargs)
|
|
210
|
+
l.fit()
|
|
211
|
+
pred = l.predict(x, stderror=True)
|
|
212
|
+
conf = pred.confidence()
|
|
213
|
+
pred, lower, upper = pred.values, conf.lower, conf.upper
|
|
214
|
+
except:
|
|
215
|
+
pred, lower, upper = np.full(y.shape[0], np.nan), np.full(y.shape[0], np.nan), np.full(y.shape[0], np.nan)
|
|
216
|
+
|
|
217
|
+
return pred, lower, upper
|
|
218
|
+
|
|
219
|
+
results = Parallel(n_jobs=n_jobs)(delayed(_loess_fit)(Y[:,j], X, **kwargs) for j in range(Y.shape[1]))
|
|
220
|
+
|
|
221
|
+
CATE, CATE_lower, CATE_upper = zip(*results)
|
|
222
|
+
CATE = np.array(CATE).T
|
|
223
|
+
CATE_lower = np.array(CATE_lower).T
|
|
224
|
+
CATE_upper = np.array(CATE_upper).T
|
|
225
|
+
return CATE, CATE_lower, CATE_upper
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def ls_fit(Y, X, n_jobs=-3, **kwargs):
|
|
230
|
+
|
|
231
|
+
def _ls_fit(y, x, **kwargs):
|
|
232
|
+
# try:
|
|
233
|
+
model = sm.OLS(y, x)
|
|
234
|
+
result = model.fit(disp=False)
|
|
235
|
+
|
|
236
|
+
# Get the predicted values
|
|
237
|
+
pred = result.predict(x)
|
|
238
|
+
|
|
239
|
+
# Get the confidence intervals
|
|
240
|
+
conf = result.conf_int()
|
|
241
|
+
pred, lower, upper = pred, np.full(y.shape[0], conf[0][0]), np.full(y.shape[0], conf[0][1])
|
|
242
|
+
# except:
|
|
243
|
+
# pred, lower, upper = np.full(y.shape[0], np.nan), np.full(y.shape[0], np.nan), np.full(y.shape[0], np.nan)
|
|
244
|
+
|
|
245
|
+
return pred, lower, upper
|
|
246
|
+
|
|
247
|
+
results = Parallel(n_jobs=n_jobs)(delayed(_ls_fit)(Y[:,j], X, **kwargs) for j in range(Y.shape[1]))
|
|
248
|
+
|
|
249
|
+
CATE, CATE_lower, CATE_upper = zip(*results)
|
|
250
|
+
CATE = np.array(CATE).T
|
|
251
|
+
CATE_lower = np.array(CATE_lower).T
|
|
252
|
+
CATE_upper = np.array(CATE_upper).T
|
|
253
|
+
return CATE, CATE_lower, CATE_upper
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
# from scipy.special import expit, xlogy, xlog1py, logsumexp, factorial
|
|
3
|
+
# from scipy.stats import binom, poisson, norm, nbinom
|
|
4
|
+
|
|
5
|
+
import numba as nb
|
|
6
|
+
from numba import njit, prange
|
|
7
|
+
|
|
8
|
+
type_f = np.float64
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
from scipy.special import xlogy, gammaln
|
|
12
|
+
from numba.extending import get_cython_function_address
|
|
13
|
+
import ctypes
|
|
14
|
+
|
|
15
|
+
_PTR = ctypes.POINTER
|
|
16
|
+
_dble = ctypes.c_double
|
|
17
|
+
_ptr_dble = _PTR(_dble)
|
|
18
|
+
|
|
19
|
+
addr = get_cython_function_address("scipy.special.cython_special", "gammaln")
|
|
20
|
+
functype = ctypes.CFUNCTYPE(_dble, _dble)
|
|
21
|
+
gammaln_float64 = functype(addr)
|
|
22
|
+
|
|
23
|
+
@nb.vectorize
|
|
24
|
+
def gammaln_nb(x):
|
|
25
|
+
return gammaln_float64(x)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def log_h(y, family, nuisance):
|
|
29
|
+
if family=='nb':
|
|
30
|
+
return gammaln(y + nuisance) - gammaln(nuisance) - gammaln(y+1)
|
|
31
|
+
elif family=='poisson':
|
|
32
|
+
return - gammaln(y+1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@nb.vectorize
|
|
36
|
+
def log1mexp(a):
|
|
37
|
+
'''
|
|
38
|
+
A numeral stable function to compute log(1-exp(a)) for a in [-inf,0].
|
|
39
|
+
'''
|
|
40
|
+
if(a >= -np.log(type_f(2.))):
|
|
41
|
+
return np.log(-np.expm1(a))
|
|
42
|
+
else:
|
|
43
|
+
return np.log1p(-np.exp(a))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@njit
|
|
47
|
+
def nll(Y, A, B, family, nuisance=np.ones((1,1)), Tys=np.zeros((1,1)), thres_disp=10.
|
|
48
|
+
# size_factor=np.ones((1,1))
|
|
49
|
+
):
|
|
50
|
+
"""
|
|
51
|
+
Compute the negative log likelihood for generalized linear models with optional nuisance parameters.
|
|
52
|
+
|
|
53
|
+
Parameters:
|
|
54
|
+
Y : array-like of shape (n_samples, n_features)
|
|
55
|
+
The response variable.
|
|
56
|
+
A : array-like of shape (n_samples, n_factors)
|
|
57
|
+
The input data matrix.
|
|
58
|
+
B : array-like of shape (n_features, n_factors)
|
|
59
|
+
The input data matrix.
|
|
60
|
+
family : str, optional (default='gaussian')
|
|
61
|
+
The family of the generalized linear model. Options include 'poisson', and 'nb'.
|
|
62
|
+
nuisance : float or array-like of shape (n_samples,), optional (default=1)
|
|
63
|
+
The nuisance parameter for the family. For the Gaussian family, this is the variance; for the Poisson
|
|
64
|
+
family, this is the scaling factor; and for the negative binomial family, this is the overdispersion
|
|
65
|
+
parameter.
|
|
66
|
+
size_factor : float or array-like of shape (n_samples,), optional (default=1)
|
|
67
|
+
The size factor for the response variable.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
nll : float
|
|
71
|
+
The negative log likelihood.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
Theta = A @ B.T
|
|
75
|
+
Ty = Y.copy()
|
|
76
|
+
n = Y.shape[0]
|
|
77
|
+
|
|
78
|
+
if family == 'poisson':
|
|
79
|
+
Theta = np.clip(Theta, -np.inf, type_f(1e2))
|
|
80
|
+
b = np.exp(Theta)
|
|
81
|
+
elif family == 'nb':
|
|
82
|
+
Xi = np.clip(Theta, -np.inf, type_f(1e2))
|
|
83
|
+
exp_Xi = np.exp(Xi)
|
|
84
|
+
tmp = np.clip(1 / (type_f(1.) + exp_Xi / nuisance), 1e-6, 1-1e-6)
|
|
85
|
+
|
|
86
|
+
Theta = np.where(nuisance > thres_disp, Xi, np.log1p(-tmp))
|
|
87
|
+
b = np.where(nuisance > thres_disp, exp_Xi, - nuisance * np.log(tmp) #+ gammaln_nb(nuisance+Y) - gammaln_nb(nuisance)
|
|
88
|
+
) # ignoring a common factor - gammaln_nb(Y)
|
|
89
|
+
else:
|
|
90
|
+
raise ValueError('Family not recognized')
|
|
91
|
+
|
|
92
|
+
nll = - np.sum(Ty * Theta - b + Tys) / type_f(n) # * size_factor to get back the likelihood
|
|
93
|
+
|
|
94
|
+
return nll
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@njit
|
|
99
|
+
def grad(Y, A, B, family, nuisance=np.ones((1,1)), thres_disp=10.
|
|
100
|
+
#size_factor=np.ones((1,1)),
|
|
101
|
+
):
|
|
102
|
+
"""
|
|
103
|
+
Compute the gradient of log likelihood with respect to B
|
|
104
|
+
for generalized linear models with optional nuisance parameters.
|
|
105
|
+
|
|
106
|
+
The natural parameter of Y is Theta = A @ B^T.
|
|
107
|
+
|
|
108
|
+
Parameters:
|
|
109
|
+
Y : array-like of shape (n_samples, n_features)
|
|
110
|
+
The response variable.
|
|
111
|
+
A : array-like of shape (n_samples, n_factors)
|
|
112
|
+
The input data matrix.
|
|
113
|
+
B : array-like of shape (n_features, n_factors)
|
|
114
|
+
The input data matrix.
|
|
115
|
+
family : str, optional (default='gaussian')
|
|
116
|
+
The family of the generalized linear model. Options include 'poisson', and 'nb'.
|
|
117
|
+
nuisance : float or array-like of shape (n_samples,), optional (default=1)
|
|
118
|
+
The nuisance parameter for the family. For the Gaussian family, this is the variance; for the Poisson
|
|
119
|
+
family, this is the scaling factor; and for the negative binomial family, this is the overdispersion
|
|
120
|
+
parameter.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
grad : array-like of shape (n_features, n_factors)
|
|
124
|
+
The gradient of log likelihood.
|
|
125
|
+
"""
|
|
126
|
+
Theta = A @ B.T
|
|
127
|
+
Ty = Y.copy()
|
|
128
|
+
n = Y.shape[0]
|
|
129
|
+
|
|
130
|
+
if family == 'nb':
|
|
131
|
+
Xi = np.clip(Theta, -np.inf, type_f(1e2))
|
|
132
|
+
b_p = np.exp(Xi)
|
|
133
|
+
tmp = np.clip(1 / (type_f(1.) + b_p / nuisance), 1e-6, 1-1e-6)
|
|
134
|
+
grad = - (Ty - b_p) * np.where(nuisance > thres_disp, 1., tmp) # * size_factor to get back the likelihood
|
|
135
|
+
elif family == 'poisson':
|
|
136
|
+
Theta = np.clip(Theta, -np.inf, type_f(1e2))
|
|
137
|
+
b_p = np.exp(Theta)
|
|
138
|
+
grad = - (Ty - b_p) # * size_factor to get back the likelihood
|
|
139
|
+
else:
|
|
140
|
+
raise ValueError('Family not recognized')
|
|
141
|
+
grad = grad.T @ A / type_f(n)
|
|
142
|
+
return grad
|
|
143
|
+
|
causarray/gcate_opt.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
from causarray.gcate_likelihood import *
|
|
2
|
+
from causarray.utils import *
|
|
3
|
+
from causarray.gcate_glm import *
|
|
4
|
+
import scipy as sp
|
|
5
|
+
from scipy.sparse.linalg import svds
|
|
6
|
+
from joblib import Parallel, delayed
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@njit
|
|
11
|
+
def project_norm_ball(X, radius):
|
|
12
|
+
"""
|
|
13
|
+
Projects a vector X to the norm ball of a given radius.
|
|
14
|
+
|
|
15
|
+
Parameters:
|
|
16
|
+
X (ndarray): The vector to be projected.
|
|
17
|
+
radius (float): The radius of the norm ball.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
ndarray: The projected matrix.
|
|
21
|
+
"""
|
|
22
|
+
norms = np.linalg.norm(X, ord=np.inf)
|
|
23
|
+
if norms > radius:
|
|
24
|
+
X *= radius / norms
|
|
25
|
+
|
|
26
|
+
return X
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@njit
|
|
31
|
+
def line_search(Y, A, x0, g, d, family, nuisance, Ys, thres_disp, start,
|
|
32
|
+
lam, alpha=1., beta=0.5, max_iters=100, tol=1e-3,
|
|
33
|
+
):
|
|
34
|
+
"""
|
|
35
|
+
Performs line search to find the step size that minimizes a given function.
|
|
36
|
+
|
|
37
|
+
Parameters:
|
|
38
|
+
f (callable): A scalar-valued function of a vector argument.
|
|
39
|
+
x0 (ndarray): The starting point for the line search.
|
|
40
|
+
g (ndarray): The search direction vector.
|
|
41
|
+
alpha (float, optional): The initial step size. Default is 10.
|
|
42
|
+
beta (float, optional): The shrinkage factor used to reduce the step size. Default is 0.5.
|
|
43
|
+
max_iters (int, optional): The maximum number of iterations. Default is 100.
|
|
44
|
+
tol (float, optional): The tolerance for the step size. Default is 1e-3.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
float: The step size that minimizes the function along the search direction.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
# Evaluate the function at the starting point.
|
|
51
|
+
f0 = nll(Y, A, x0, family, nuisance, Ys, thres_disp) + np.mean(lam * np.abs(x0[start:d]))
|
|
52
|
+
|
|
53
|
+
# Initialize the step size.
|
|
54
|
+
alpha = type_f(alpha)
|
|
55
|
+
beta = type_f(beta)
|
|
56
|
+
t = alpha
|
|
57
|
+
norm_g = np.linalg.norm(g)
|
|
58
|
+
|
|
59
|
+
# Iterate until the maximum number of iterations is reached or the step size is small enough.
|
|
60
|
+
for i in range(max_iters):
|
|
61
|
+
|
|
62
|
+
# Compute the new point.
|
|
63
|
+
x1 = x0 - t*g
|
|
64
|
+
x1[start:d] = np.sign(x1[start:d]) * np.maximum(np.abs(x1[start:d]) - lam, type_f(0.))
|
|
65
|
+
|
|
66
|
+
# Evaluate the function at the new point.
|
|
67
|
+
f1 = nll(Y, A, x1, family, nuisance, Ys, thres_disp) + np.mean(lam * np.abs(x1[start:d]))
|
|
68
|
+
if i==0:
|
|
69
|
+
f01 = f1
|
|
70
|
+
|
|
71
|
+
# Check if the function has decreased sufficiently.
|
|
72
|
+
if f1 < f0 - tol*t*norm_g:
|
|
73
|
+
return x1
|
|
74
|
+
if t<1e-4:
|
|
75
|
+
break
|
|
76
|
+
t *= beta
|
|
77
|
+
|
|
78
|
+
# Return the maximum step size.
|
|
79
|
+
if f01<1.1*f1:
|
|
80
|
+
x1 = x0 - alpha*g
|
|
81
|
+
x1[start:d] = np.sign(x1[start:d]) * np.maximum(np.abs(x1[start:d]) - lam, type_f(0.))
|
|
82
|
+
return x1
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@njit(parallel=True)
|
|
90
|
+
def update(Y, A, B, d, lam, P1, P2,
|
|
91
|
+
family, nuisance, Ys, thres_disp, a, C,
|
|
92
|
+
alpha, beta, max_iters, tol):
|
|
93
|
+
n, p = Y.shape
|
|
94
|
+
|
|
95
|
+
g = grad(Y.T, B, A, family, nuisance.T, thres_disp)
|
|
96
|
+
g[:, :d] = 0.
|
|
97
|
+
for i in prange(n):
|
|
98
|
+
g[i, d:] = project_norm_ball(g[i, d:], 2*C)
|
|
99
|
+
|
|
100
|
+
A[i, :] = line_search(Y[i, :], B, A[i, :], g[i, :], d,
|
|
101
|
+
family, nuisance[0], Ys[i, :], thres_disp, 0,
|
|
102
|
+
type_f(0.), alpha, beta, max_iters, tol)
|
|
103
|
+
if P1 is not None:
|
|
104
|
+
A[:, d:] = P1 @ A[:, d:]
|
|
105
|
+
|
|
106
|
+
g = grad(Y, A, B, family, nuisance, thres_disp)
|
|
107
|
+
if P1 is not None:
|
|
108
|
+
g[:, :d] = 0.
|
|
109
|
+
elif P2 is not None:
|
|
110
|
+
g[:, d-a:d] = P2 @ g[:, d-a:d]
|
|
111
|
+
g[:, d:] = 0.
|
|
112
|
+
g[:, :d-a] = 0.
|
|
113
|
+
|
|
114
|
+
for j in prange(p):
|
|
115
|
+
if P2 is None:
|
|
116
|
+
g[j, d:] = project_norm_ball(g[j, d:], 2*C)
|
|
117
|
+
else:
|
|
118
|
+
g[j, :d] = project_norm_ball(g[j, :d], 2*C)
|
|
119
|
+
|
|
120
|
+
B[j, :] = line_search(Y[:, j], A, B[j, :], g[j, :], d,
|
|
121
|
+
family, nuisance[:,j], Ys[:, j], thres_disp, d-a,
|
|
122
|
+
lam[j,:], alpha, beta, max_iters, tol
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if P2 is None:
|
|
126
|
+
B[:, d:] = np.clip(B[:, d:], -10., 10.)
|
|
127
|
+
func_val = nll(Y, A, B, family, nuisance, Ys, thres_disp)
|
|
128
|
+
|
|
129
|
+
return func_val, A, B
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def alter_min(
|
|
133
|
+
Y, r, X=None, P1=None, P2=None, A=None, B=None,
|
|
134
|
+
kwargs_glm={}, kwargs_ls={}, kwargs_es={},
|
|
135
|
+
lam=0., a=None, verbose=False, thres_disp=100.):
|
|
136
|
+
'''
|
|
137
|
+
Alternative minimization of latent factorization for generalized linear models.
|
|
138
|
+
|
|
139
|
+
Parameters
|
|
140
|
+
----------
|
|
141
|
+
Y : array-like, shape (n, p)
|
|
142
|
+
Response matrix.
|
|
143
|
+
r : int
|
|
144
|
+
The number of latent factors.
|
|
145
|
+
X : array-like, shape (n, d)
|
|
146
|
+
Observed covariate matrix.
|
|
147
|
+
P1 : array-like, shape (n, n)
|
|
148
|
+
The projection matrix for the rows.
|
|
149
|
+
P2 : array-like, shape (p, p)
|
|
150
|
+
The projection matrix onto the orthogonal column space of Gamma.
|
|
151
|
+
A : array-like, shape (n, d+r)
|
|
152
|
+
The initial matrix for the covariate and latent factors. If None, initialize internally.
|
|
153
|
+
B : array-like, shape (p, d+r)
|
|
154
|
+
The initial matrix for the covariate and latent coefficients. If None, initialize internally.
|
|
155
|
+
kwargs_glm : dict
|
|
156
|
+
Keyword arguments for the generalized linear model.
|
|
157
|
+
kwargs_ls : dict
|
|
158
|
+
Keyword arguments for the line search.
|
|
159
|
+
kwargs_es : dict
|
|
160
|
+
Keyword arguments for the early stopping.
|
|
161
|
+
lam : float
|
|
162
|
+
The regularization parameter for the l1 norm of the coefficients.
|
|
163
|
+
a : int
|
|
164
|
+
The number of columns to be regularized. Assume the last 'a' columns of the covariates are the regularized coefficients. If 'a' is None, it is set to be 'd-offset' by default.
|
|
165
|
+
verbose : bool
|
|
166
|
+
The indicator of whether to print the progress.
|
|
167
|
+
thres_disp : float
|
|
168
|
+
The threshold for the dispersion parameter.
|
|
169
|
+
|
|
170
|
+
Returns
|
|
171
|
+
-------
|
|
172
|
+
res : dict
|
|
173
|
+
A dictionary containing the information of the optimization, including
|
|
174
|
+
'A': the matrix (n, d+r) for the covariate and updated latent factors,
|
|
175
|
+
'B': the matrix (p, d+r) for the updated covariate and latent coefficients,
|
|
176
|
+
'U': the matrix (n, r) for the estimated confouders.
|
|
177
|
+
'''
|
|
178
|
+
|
|
179
|
+
n, p = Y.shape
|
|
180
|
+
d = X.shape[1]
|
|
181
|
+
|
|
182
|
+
assert d>0
|
|
183
|
+
if verbose:
|
|
184
|
+
pprint.pprint({'n':n,'p':p,'d':d,'r':r})
|
|
185
|
+
|
|
186
|
+
kwargs_glm = {**{'family':'poisson', 'disp_glm':np.ones(p), 'size_factor':np.ones(n)
|
|
187
|
+
}, **kwargs_glm}
|
|
188
|
+
kwargs_ls = {**{'alpha':0.1, 'beta':0.5, 'max_iters':20, 'tol':1e-4, 'C':None}, **kwargs_ls}
|
|
189
|
+
kwargs_es = {**{'max_iters':500, 'warmup':0, 'patience':5, 'tolerance':1e-3}, **kwargs_es}
|
|
190
|
+
|
|
191
|
+
if kwargs_es['max_iters'] == 0:
|
|
192
|
+
return A, B, {'n_iter':0, 'func_val':0., 'resid':0., 'hist':[0.], 'kwargs_glm':kwargs_glm, 'kwargs_ls':kwargs_ls, 'kwargs_es':kwargs_es}
|
|
193
|
+
|
|
194
|
+
family, nuisance, size_factor = kwargs_glm['family'], kwargs_glm['disp_glm'].astype(type_f), kwargs_glm['size_factor'].astype(type_f)
|
|
195
|
+
nuisance = nuisance.reshape(1,-1)
|
|
196
|
+
size_factor = size_factor.reshape(-1,1)
|
|
197
|
+
|
|
198
|
+
if kwargs_ls['C'] is None:
|
|
199
|
+
kwargs_ls['C'] = 1e3 if family=='nb' else 1e5
|
|
200
|
+
C = kwargs_ls['C']
|
|
201
|
+
|
|
202
|
+
if P1 is True:
|
|
203
|
+
Q, _ = sp.linalg.qr(X, mode='economic')
|
|
204
|
+
P1 = np.identity(n) - Q @ Q.T
|
|
205
|
+
P1 = P1.astype(type_f)
|
|
206
|
+
|
|
207
|
+
if a is None:
|
|
208
|
+
a = d
|
|
209
|
+
|
|
210
|
+
# initialization for Theta = A @ B^T
|
|
211
|
+
if A is None or B is None:
|
|
212
|
+
if verbose:
|
|
213
|
+
pprint.pprint('Estimating initial latent variables with GLMs...')
|
|
214
|
+
res_glm = fit_glm(Y, X, offset=np.log(size_factor[:,0]), family=family, disp_glm=nuisance[0], maxiter=100)
|
|
215
|
+
u, s, vt = svds(res_glm[-1], k=r)
|
|
216
|
+
|
|
217
|
+
if u.shape[1]<r:
|
|
218
|
+
raise ValueError(f'The number of latent factors is larger than the rank of deviance residuals ({u.shape[1]}). Try to decrease the value of r.')
|
|
219
|
+
|
|
220
|
+
if verbose:
|
|
221
|
+
pprint.pprint('Estimating initial coefficients with GLMs...')
|
|
222
|
+
A = np.c_[X, P1 @ u]
|
|
223
|
+
B = fit_glm(Y, A, offset=np.log(size_factor[:,0]), family=family, disp_glm=nuisance[0], maxiter=100)[0]
|
|
224
|
+
|
|
225
|
+
E = A[:, -r:] @ B[:, -r:].T
|
|
226
|
+
u, s, vh = sp.sparse.linalg.svds(E, k=r)
|
|
227
|
+
A[:, d:] = u * s[None,:]**(1/2)
|
|
228
|
+
B[:, d:] = vh.T * s[None,:]**(1/2)
|
|
229
|
+
del E, u, s, vh
|
|
230
|
+
|
|
231
|
+
# if offset==1:
|
|
232
|
+
# scale = np.sqrt(np.median(np.abs(X[:,0])))
|
|
233
|
+
# B[:, :offset] = scale
|
|
234
|
+
# A[:, :offset] /= scale
|
|
235
|
+
|
|
236
|
+
if P2 is not None:
|
|
237
|
+
P2 = P2.astype(type_f)
|
|
238
|
+
# E = A[:,d-a:] @ B[:,d-a:].T @ (np.identity(p) - P2)
|
|
239
|
+
# u, s, vh = sp.sparse.linalg.svds(E, k=r)
|
|
240
|
+
B[:, d-a:d] = P2 @ B[:, d-a:d]
|
|
241
|
+
# A[:, d:] = u * s[None,:]**(1/2)
|
|
242
|
+
# B[:, d:] = vh.T * s[None,:]**(1/2)
|
|
243
|
+
# del E, u, s, vh
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
Y = Y.astype(type_f)
|
|
247
|
+
Y /= size_factor
|
|
248
|
+
Ys = - gammaln_nb(Y+1)
|
|
249
|
+
if family=='nb':
|
|
250
|
+
Ys += np.where(nuisance > thres_disp, 0., gammaln_nb(nuisance+Y) - gammaln_nb(nuisance))
|
|
251
|
+
|
|
252
|
+
A = A.astype(type_f)
|
|
253
|
+
B = B.astype(type_f)
|
|
254
|
+
|
|
255
|
+
lam = type_f(lam)
|
|
256
|
+
weights = lam / (np.abs(B[:, d-a:d]) + 1e-6)
|
|
257
|
+
|
|
258
|
+
assert ~np.any(np.isnan(A))
|
|
259
|
+
assert ~np.any(np.isnan(B))
|
|
260
|
+
|
|
261
|
+
t = 0
|
|
262
|
+
func_val_pre = (nll(Y, A, B, family, nuisance, Ys, thres_disp) + np.sum(np.abs(B[:,d-a:d]) * weights)) / p
|
|
263
|
+
func_val = func_val_pre
|
|
264
|
+
|
|
265
|
+
kwargs_ls['alpha'] = kwargs_ls['alpha']
|
|
266
|
+
if verbose:
|
|
267
|
+
pprint.pprint({'kwargs_glm':kwargs_glm,'kwargs_ls':kwargs_ls,'kwargs_es':kwargs_es}, compact=True)
|
|
268
|
+
|
|
269
|
+
hist = [func_val_pre]
|
|
270
|
+
es = Early_Stopping(**kwargs_es)
|
|
271
|
+
with tqdm(np.arange(kwargs_es['max_iters'])) as pbar:
|
|
272
|
+
for t in pbar:
|
|
273
|
+
func_val, A, B = update(
|
|
274
|
+
Y, A, B, d, weights, P1, P2,
|
|
275
|
+
family, nuisance, Ys, thres_disp, a, C,
|
|
276
|
+
kwargs_ls['alpha'], kwargs_ls['beta'], kwargs_ls['max_iters'], kwargs_ls['tol'],
|
|
277
|
+
)
|
|
278
|
+
func_val = (func_val + np.sum(np.abs(B[:,d-a:d]) * weights)) / p
|
|
279
|
+
hist.append(func_val)
|
|
280
|
+
if not np.isfinite(func_val) or func_val>np.maximum(1e3*np.abs(func_val_pre),1e3):
|
|
281
|
+
pprint.pprint('Encountered large or infinity values. Try to decrease the value of C for the norm constraints.')
|
|
282
|
+
break
|
|
283
|
+
elif es(func_val):
|
|
284
|
+
pbar.set_postfix_str('Early stopped.' + es.info)
|
|
285
|
+
pbar.close()
|
|
286
|
+
break
|
|
287
|
+
else:
|
|
288
|
+
func_val_pre = func_val
|
|
289
|
+
pbar.set_postfix(nll='{:.02f}'.format(func_val))
|
|
290
|
+
|
|
291
|
+
kwargs_glm['disp_glm'] = nuisance[0]
|
|
292
|
+
res = {'n_iter':t, 'func_val':func_val, 'resid':func_val_pre - func_val,
|
|
293
|
+
'hist':hist, 'kwargs_glm':kwargs_glm, 'kwargs_ls':kwargs_ls, 'kwargs_es':kwargs_es}
|
|
294
|
+
res['X_U'] = A; res['B_Gamma'] = B; res['U'] = A[:,d:]
|
|
295
|
+
return res
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
|