diffsvg 0.1.0__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.
- diffsvg/__init__.py +3 -0
- diffsvg/pl/__init__.py +5 -0
- diffsvg/pl/plot_clusters.py +216 -0
- diffsvg/pl/plot_results.py +484 -0
- diffsvg/pl/plot_stability.py +184 -0
- diffsvg/pp/__init__.py +10 -0
- diffsvg/pp/utils.py +620 -0
- diffsvg/py.typed +0 -0
- diffsvg/tl/__init__.py +24 -0
- diffsvg/tl/_exact.py +332 -0
- diffsvg/tl/clustering.py +154 -0
- diffsvg/tl/model.py +1857 -0
- diffsvg/tl/stability.py +335 -0
- diffsvg-0.1.0.dist-info/METADATA +132 -0
- diffsvg-0.1.0.dist-info/RECORD +17 -0
- diffsvg-0.1.0.dist-info/WHEEL +4 -0
- diffsvg-0.1.0.dist-info/licenses/LICENSE +29 -0
diffsvg/tl/_exact.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Exact posterior of the Gaussian GAM, for all genes at once.
|
|
2
|
+
|
|
3
|
+
The default model is linear-Gaussian: ``y ~ N(X theta, sigma^2 / w)`` with independent normal priors on
|
|
4
|
+
every coefficient in ``theta``. Given ``sigma`` the posterior of ``theta`` is Gaussian (conjugacy), and
|
|
5
|
+
``sigma`` is a single number, so it is integrated out numerically on a per-gene grid of ``log sigma``.
|
|
6
|
+
The result is the same posterior NUTS samples from, without Monte Carlo error.
|
|
7
|
+
|
|
8
|
+
All genes share the design ``X`` (and the row weights), so ``X'WX`` and its eigendecomposition are
|
|
9
|
+
computed once; per gene only ``X'Wy`` and ``y'Wy`` (and, for weighted contiguous bins, the within-bin sum
|
|
10
|
+
of squares) are needed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from scipy import stats
|
|
17
|
+
from scipy.special import logsumexp
|
|
18
|
+
|
|
19
|
+
#: ``log p(sigma)`` for the noise priors `_PRIOR_DISTRIBUTIONS` offers, keyed by distribution name.
|
|
20
|
+
_SIGMA_LOGPDF = {
|
|
21
|
+
"halfnormal": lambda s, scale: stats.halfnorm.logpdf(s, scale=scale),
|
|
22
|
+
"halfcauchy": lambda s, beta: stats.halfcauchy.logpdf(s, scale=beta),
|
|
23
|
+
"exponential": lambda s, lam: stats.expon.logpdf(s, scale=1.0 / lam),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def supported(priors, likelihood, has_samples):
|
|
28
|
+
"""Whether the exact posterior applies.
|
|
29
|
+
|
|
30
|
+
It needs a Gaussian likelihood and normal coefficient priors (with slides, any one-dimensional positive
|
|
31
|
+
prior on the two slide scales).
|
|
32
|
+
"""
|
|
33
|
+
coefficient_priors = ("intercept", "beta", "beta_1", "categorical_beta")
|
|
34
|
+
scales = ("sigma", "sample_level_sd", "sample_curve_sd") if has_samples else ("sigma",)
|
|
35
|
+
return (
|
|
36
|
+
likelihood == "normal"
|
|
37
|
+
and all(priors[name][0] == "normal" for name in coefficient_priors)
|
|
38
|
+
and all(priors[name][0] in _SIGMA_LOGPDF for name in scales)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ExactPosterior:
|
|
44
|
+
"""Posterior of ``theta`` for every gene, ``sigma`` integrated out.
|
|
45
|
+
|
|
46
|
+
``mean`` (G, p) and ``cov`` (G, p, p) are the exact posterior moments; ``sigma2_mean`` is E[sigma^2];
|
|
47
|
+
``edge_mass`` is the largest quadrature weight at either end of the gene's ``log sigma`` grid (near 0
|
|
48
|
+
means the grid covers the posterior). The remaining fields let `draws` sample the exact posterior.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
mean: np.ndarray
|
|
52
|
+
cov: np.ndarray
|
|
53
|
+
sigma2_mean: np.ndarray
|
|
54
|
+
edge_mass: np.ndarray
|
|
55
|
+
log_sigma_grid: np.ndarray
|
|
56
|
+
grid_weights: np.ndarray
|
|
57
|
+
eigvals: np.ndarray
|
|
58
|
+
transform: np.ndarray # theta = prior_mean + transform @ u, u in the whitened eigenbasis
|
|
59
|
+
projected: np.ndarray # (p, G): the whitened, rotated X'W(y - X prior_mean)
|
|
60
|
+
prior_mean: np.ndarray
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sufficient_statistics(X, Y, weights=None):
|
|
64
|
+
"""``X'WX`` (p, p), ``X'WY`` (p, G) and ``diag(Y'WY)`` (G,). `Y` is (n, G), dense or scipy.sparse."""
|
|
65
|
+
Xw = X if weights is None else X * weights[:, None]
|
|
66
|
+
XtWX = X.T @ Xw
|
|
67
|
+
XtWY = np.asarray((Y.T @ Xw).T)
|
|
68
|
+
if hasattr(Y, "multiply"):
|
|
69
|
+
squared = Y.multiply(Y)
|
|
70
|
+
yWy = np.asarray(squared.sum(axis=0) if weights is None else squared.T @ weights).ravel()
|
|
71
|
+
else:
|
|
72
|
+
yWy = (Y**2).sum(axis=0) if weights is None else weights @ (Y**2)
|
|
73
|
+
return XtWX, XtWY, np.asarray(yWy, dtype=float)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def posterior(
|
|
77
|
+
XtWX,
|
|
78
|
+
XtWY,
|
|
79
|
+
yWy,
|
|
80
|
+
n_rows,
|
|
81
|
+
prior_mean,
|
|
82
|
+
prior_sd,
|
|
83
|
+
sigma_prior,
|
|
84
|
+
extra_df=None,
|
|
85
|
+
extra_ss=None,
|
|
86
|
+
n_grid=81,
|
|
87
|
+
width=10.0,
|
|
88
|
+
chunk=1024,
|
|
89
|
+
):
|
|
90
|
+
"""Exact posterior moments for every gene (columns of `XtWY`).
|
|
91
|
+
|
|
92
|
+
`sigma_prior` is a ``(distribution, *params)`` tuple. `extra_df`/`extra_ss` add the within-bin term of
|
|
93
|
+
weighted contiguous bins, ``-extra_df * log(sigma) - extra_ss / (2 sigma^2)``, to the likelihood.
|
|
94
|
+
"""
|
|
95
|
+
m0 = np.asarray(prior_mean, dtype=float)
|
|
96
|
+
s0 = np.asarray(prior_sd, dtype=float)
|
|
97
|
+
G = XtWY.shape[1]
|
|
98
|
+
logpdf = _SIGMA_LOGPDF[sigma_prior[0]]
|
|
99
|
+
sigma_params = sigma_prior[1:]
|
|
100
|
+
|
|
101
|
+
# Shift to a zero prior mean and whiten the prior: theta = m0 + S0 u, u ~ N(0, I).
|
|
102
|
+
r = XtWY - (XtWX @ m0)[:, None]
|
|
103
|
+
yy = yWy - 2.0 * (m0 @ XtWY) + m0 @ XtWX @ m0
|
|
104
|
+
d, U = np.linalg.eigh(s0[:, None] * XtWX * s0[None, :])
|
|
105
|
+
d = np.clip(d, 0.0, None)
|
|
106
|
+
c = U.T @ (s0[:, None] * r) # (p, G)
|
|
107
|
+
df = np.zeros(G) if extra_df is None else np.broadcast_to(np.asarray(extra_df, dtype=float), (G,))
|
|
108
|
+
ss = np.zeros(G) if extra_ss is None else np.asarray(extra_ss, dtype=float)
|
|
109
|
+
n_tot = n_rows + df
|
|
110
|
+
|
|
111
|
+
def log_post(log_s, idx):
|
|
112
|
+
"""Unnormalised log p(log sigma | y) on a (g, S) grid for the genes `idx`."""
|
|
113
|
+
s2 = np.exp(2.0 * log_s)
|
|
114
|
+
cg2 = (c[:, idx] ** 2)[:, :, None]
|
|
115
|
+
quad = (cg2 / (s2[None] * (d[:, None, None] + s2[None]))).sum(axis=0)
|
|
116
|
+
logdet = np.log1p(d[:, None, None] / s2[None]).sum(axis=0)
|
|
117
|
+
loglik = -n_tot[idx, None] * log_s - 0.5 * logdet - 0.5 * (yy[idx, None] + ss[idx, None]) / s2 + 0.5 * quad
|
|
118
|
+
return loglik + logpdf(np.exp(log_s), *sigma_params) + log_s # + log-Jacobian of sigma -> log sigma
|
|
119
|
+
|
|
120
|
+
grid = np.empty((G, n_grid))
|
|
121
|
+
weights = np.empty((G, n_grid))
|
|
122
|
+
t = np.linspace(-1.0, 1.0, n_grid)
|
|
123
|
+
coarse = np.linspace(np.log(1e-6), np.log(1e3), 800)
|
|
124
|
+
for start in range(0, G, chunk):
|
|
125
|
+
idx = np.arange(start, min(start + chunk, G))
|
|
126
|
+
lp = log_post(np.tile(coarse, (len(idx), 1)), idx)
|
|
127
|
+
centre = coarse[lp.argmax(axis=1)]
|
|
128
|
+
half = width / np.sqrt(2.0 * np.maximum(n_tot[idx], 2.0)) # sd(log sigma) ~ 1 / sqrt(2 n)
|
|
129
|
+
for _ in range(4): # recentre on the posterior mean; the coarse step can exceed its sd
|
|
130
|
+
g = centre[:, None] + half[:, None] * t[None]
|
|
131
|
+
lp = log_post(g, idx)
|
|
132
|
+
w = np.exp(lp - logsumexp(lp, axis=1, keepdims=True)) # uniform grid: normalised quadrature weights
|
|
133
|
+
centre = (w * g).sum(axis=1)
|
|
134
|
+
grid[idx], weights[idx] = g, w
|
|
135
|
+
|
|
136
|
+
s2 = np.exp(2.0 * grid) # (G, S)
|
|
137
|
+
shrink = 1.0 / (d[None, :, None] + s2[:, None, :]) # (G, p, S)
|
|
138
|
+
u_mean_s = c.T[:, :, None] * shrink # E[u | sigma] in the eigenbasis
|
|
139
|
+
u_mean = (u_mean_s * weights[:, None]).sum(axis=-1)
|
|
140
|
+
var_diag = (s2[:, None, :] * shrink * weights[:, None]).sum(axis=-1) # E[Var(u | sigma)], diagonal
|
|
141
|
+
dev = u_mean_s - u_mean[:, :, None]
|
|
142
|
+
cov_u = np.einsum("gis,gjs,gs->gij", dev, dev, weights) # Var(E[u | sigma])
|
|
143
|
+
cov_u[:, np.arange(len(d)), np.arange(len(d))] += var_diag
|
|
144
|
+
T = s0[:, None] * U
|
|
145
|
+
return ExactPosterior(
|
|
146
|
+
mean=m0 + u_mean @ T.T,
|
|
147
|
+
cov=T @ cov_u @ T.T,
|
|
148
|
+
sigma2_mean=(s2 * weights).sum(axis=1),
|
|
149
|
+
edge_mass=np.maximum(weights[:, 0], weights[:, -1]),
|
|
150
|
+
log_sigma_grid=grid,
|
|
151
|
+
grid_weights=weights,
|
|
152
|
+
eigvals=d,
|
|
153
|
+
transform=T,
|
|
154
|
+
projected=c,
|
|
155
|
+
prior_mean=m0,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def draws(post, gene, n_draws, rng):
|
|
160
|
+
"""``n_draws`` samples of ``theta`` from gene index `gene`'s exact posterior, (n_draws, p)."""
|
|
161
|
+
s_idx = rng.choice(post.grid_weights.shape[1], size=n_draws, p=post.grid_weights[gene])
|
|
162
|
+
s2 = np.exp(2.0 * post.log_sigma_grid[gene, s_idx])[:, None] # (n, 1)
|
|
163
|
+
d = post.eigvals[None, :]
|
|
164
|
+
u = post.projected[:, gene][None, :] / (d + s2) + np.sqrt(s2 / (d + s2)) * rng.standard_normal(
|
|
165
|
+
(n_draws, d.shape[1])
|
|
166
|
+
)
|
|
167
|
+
return post.prior_mean[None, :] + u @ post.transform.T
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ----------------------------------------------------------------------------------------------------------
|
|
171
|
+
# Slide effects: random level and random curve per slide, scales `level_sd`, `curve_sd` with their own priors.
|
|
172
|
+
# Given (sigma, level_sd, curve_sd) the model is still linear-Gaussian in (fixed coefficients, slide effects).
|
|
173
|
+
# The two scales are integrated on a shared log grid (one eigendecomposition per grid point, shared by all
|
|
174
|
+
# genes); sigma per gene and grid point by Gauss-Hermite quadrature around its conditional mode.
|
|
175
|
+
# ----------------------------------------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def slide_design(X_fixed, B, sample_index, n_samples):
|
|
179
|
+
"""Columns for the slide effects: one-hot slide levels, then per slide its copy of the spline basis."""
|
|
180
|
+
n, K = B.shape
|
|
181
|
+
onehot = np.zeros((n, n_samples))
|
|
182
|
+
onehot[np.arange(n), sample_index] = 1.0
|
|
183
|
+
curves = (onehot[:, :, None] * B[:, None, :]).reshape(n, n_samples * K)
|
|
184
|
+
return np.hstack([X_fixed, onehot, curves])
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _log_sigma_mode(c, d, yy, n_tot, logpdf, params, start, steps=30):
|
|
188
|
+
"""Mode and curvature of log p(log sigma | y, scales) for every gene (columns of `c`), by damped Newton."""
|
|
189
|
+
|
|
190
|
+
def f(t):
|
|
191
|
+
s2 = np.exp(2.0 * t)[None, :]
|
|
192
|
+
val = (
|
|
193
|
+
-n_tot * t
|
|
194
|
+
- 0.5 * np.log1p(d[:, None] / s2).sum(axis=0)
|
|
195
|
+
- 0.5 * yy / s2[0]
|
|
196
|
+
+ 0.5 * (c**2 / (s2 * (d[:, None] + s2))).sum(axis=0)
|
|
197
|
+
)
|
|
198
|
+
return val + logpdf(np.exp(t), *params) + t
|
|
199
|
+
|
|
200
|
+
t = start.copy()
|
|
201
|
+
h = 1e-4
|
|
202
|
+
for _ in range(steps):
|
|
203
|
+
f0, fp, fm = f(t), f(t + h), f(t - h)
|
|
204
|
+
g1 = (fp - fm) / (2 * h)
|
|
205
|
+
g2 = np.minimum((fp - 2 * f0 + fm) / h**2, -1e-8)
|
|
206
|
+
step = np.clip(-g1 / g2, -0.5, 0.5)
|
|
207
|
+
t = t + step
|
|
208
|
+
if np.abs(step).max() < 1e-7:
|
|
209
|
+
break
|
|
210
|
+
f0, fp, fm = f(t), f(t + h), f(t - h)
|
|
211
|
+
curvature = np.minimum((fp - 2 * f0 + fm) / h**2, -1e-8)
|
|
212
|
+
return t, curvature, f
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def slide_posterior(
|
|
216
|
+
X,
|
|
217
|
+
Y,
|
|
218
|
+
weights,
|
|
219
|
+
n_fixed,
|
|
220
|
+
n_samples,
|
|
221
|
+
n_bsplines,
|
|
222
|
+
prior_mean,
|
|
223
|
+
prior_sd,
|
|
224
|
+
sigma_prior,
|
|
225
|
+
level_prior,
|
|
226
|
+
curve_prior,
|
|
227
|
+
extra_df=None,
|
|
228
|
+
extra_ss=None,
|
|
229
|
+
n_draws=1000,
|
|
230
|
+
seeds=None,
|
|
231
|
+
n_hyper=72,
|
|
232
|
+
hyper_range=(1e-5, 5.0),
|
|
233
|
+
n_nodes=9,
|
|
234
|
+
):
|
|
235
|
+
"""Exact posterior of the fixed coefficients with slide effects integrated out, for every gene.
|
|
236
|
+
|
|
237
|
+
`X` is `slide_design`'s output, `prior_mean`/`prior_sd` cover its first `n_fixed` columns. Returns a dict
|
|
238
|
+
with ``mean`` (G, n_fixed), ``cov`` (G, n_fixed, n_fixed), ``draws`` (G, n_draws, n_fixed),
|
|
239
|
+
``sigma2_mean``, ``level_sd_mean``, ``curve_sd_mean`` and ``edge_mass`` (largest posterior mass on the
|
|
240
|
+
boundary of the scale grid).
|
|
241
|
+
"""
|
|
242
|
+
XtWX, XtWY, yWy = sufficient_statistics(X, Y, weights)
|
|
243
|
+
p = X.shape[1]
|
|
244
|
+
G = XtWY.shape[1]
|
|
245
|
+
m = np.zeros(p)
|
|
246
|
+
m[:n_fixed] = prior_mean
|
|
247
|
+
r = XtWY - (XtWX @ m)[:, None]
|
|
248
|
+
yy0 = yWy - 2.0 * (m @ XtWY) + m @ XtWX @ m
|
|
249
|
+
df = np.zeros(G) if extra_df is None else np.broadcast_to(np.asarray(extra_df, dtype=float), (G,))
|
|
250
|
+
ss = np.zeros(G) if extra_ss is None else np.asarray(extra_ss, dtype=float)
|
|
251
|
+
yy = yy0 + ss
|
|
252
|
+
n_tot = X.shape[0] + df
|
|
253
|
+
sig_logpdf, sig_params = _SIGMA_LOGPDF[sigma_prior[0]], sigma_prior[1:]
|
|
254
|
+
lev_logpdf, lev_params = _SIGMA_LOGPDF[level_prior[0]], level_prior[1:]
|
|
255
|
+
cur_logpdf, cur_params = _SIGMA_LOGPDF[curve_prior[0]], curve_prior[1:]
|
|
256
|
+
|
|
257
|
+
log_grid = np.linspace(np.log(hyper_range[0]), np.log(hyper_range[1]), n_hyper)
|
|
258
|
+
hyper = [(a, b) for a in log_grid for b in log_grid]
|
|
259
|
+
nodes, node_w = np.polynomial.hermite_e.hermegauss(n_nodes) # for \int e^{-x^2/2} g(x) dx
|
|
260
|
+
|
|
261
|
+
# Pass 1: per scale pair, eigendecomposition and per gene the log-weights of the sigma nodes.
|
|
262
|
+
eig = []
|
|
263
|
+
log_w = np.empty((G, len(hyper), n_nodes))
|
|
264
|
+
t_nodes = np.empty((G, len(hyper), n_nodes))
|
|
265
|
+
start = 0.5 * np.log(np.maximum(yy / np.maximum(n_tot, 1.0), 1e-12))
|
|
266
|
+
for h, (la, lb) in enumerate(hyper):
|
|
267
|
+
s_full = np.concatenate([prior_sd, np.full(n_samples, np.exp(la)), np.full(n_samples * n_bsplines, np.exp(lb))])
|
|
268
|
+
d, U = np.linalg.eigh(s_full[:, None] * XtWX * s_full[None, :])
|
|
269
|
+
d = np.clip(d, 0.0, None)
|
|
270
|
+
c = U.T @ (s_full[:, None] * r)
|
|
271
|
+
eig.append((d, U, s_full))
|
|
272
|
+
mode, curv, f = _log_sigma_mode(c, d, yy, n_tot, sig_logpdf, sig_params, start)
|
|
273
|
+
sd = 1.0 / np.sqrt(-curv)
|
|
274
|
+
t = mode[:, None] + sd[:, None] * nodes[None, :]
|
|
275
|
+
vals = np.stack([f(t[:, k]) for k in range(n_nodes)], axis=1)
|
|
276
|
+
log_w[:, h] = (
|
|
277
|
+
vals + np.log(node_w)[None, :] + 0.5 * nodes[None, :] ** 2 + np.log(sd)[:, None]
|
|
278
|
+
+ lev_logpdf(np.exp(la), *lev_params) + la + cur_logpdf(np.exp(lb), *cur_params) + lb
|
|
279
|
+
) # fmt: skip
|
|
280
|
+
t_nodes[:, h] = t
|
|
281
|
+
log_w -= logsumexp(log_w.reshape(G, -1), axis=1)[:, None, None]
|
|
282
|
+
w = np.exp(log_w)
|
|
283
|
+
|
|
284
|
+
# Pass 2: mixture moments of the fixed block.
|
|
285
|
+
M1 = np.zeros((G, n_fixed))
|
|
286
|
+
M2 = np.zeros((G, n_fixed, n_fixed))
|
|
287
|
+
s2_mean = np.zeros(G)
|
|
288
|
+
for h, (d, U, s_full) in enumerate(eig):
|
|
289
|
+
Tf = (s_full[:, None] * U)[:n_fixed] # (n_fixed, p)
|
|
290
|
+
c = U.T @ (s_full[:, None] * r) # (p, G)
|
|
291
|
+
s2 = np.exp(2.0 * t_nodes[:, h]) # (G, n_nodes)
|
|
292
|
+
wk = w[:, h] # (G, n_nodes)
|
|
293
|
+
shrink = 1.0 / (d[None, None, :] + s2[:, :, None]) # (G, n_nodes, p)
|
|
294
|
+
u_mean = c.T[:, None, :] * shrink
|
|
295
|
+
mean = m[:n_fixed] + u_mean @ Tf.T # (G, n_nodes, n_fixed)
|
|
296
|
+
M1 += (wk[:, :, None] * mean).sum(axis=1)
|
|
297
|
+
M2 += np.einsum("gk,gki,gkj->gij", wk, mean, mean)
|
|
298
|
+
var_w = (wk[:, :, None] * s2[:, :, None] * shrink).sum(axis=1) # (G, p): sum_k w Var(u | .)
|
|
299
|
+
M2 += (Tf[None] * var_w[:, None, :]) @ Tf.T
|
|
300
|
+
s2_mean += (wk * s2).sum(axis=1)
|
|
301
|
+
cov = M2 - M1[:, :, None] * M1[:, None, :]
|
|
302
|
+
|
|
303
|
+
# Draws of the fixed block from the mixture.
|
|
304
|
+
flat = w.reshape(G, -1)
|
|
305
|
+
out_draws = np.empty((G, n_draws, n_fixed))
|
|
306
|
+
seeds = seeds if seeds is not None else np.random.SeedSequence(0).spawn(G)
|
|
307
|
+
for g in range(G):
|
|
308
|
+
rng = np.random.default_rng(seeds[g])
|
|
309
|
+
idx = rng.choice(flat.shape[1], size=n_draws, p=flat[g] / flat[g].sum())
|
|
310
|
+
hs, ks = np.divmod(idx, n_nodes)
|
|
311
|
+
for h in np.unique(hs):
|
|
312
|
+
sel = np.flatnonzero(hs == h)
|
|
313
|
+
d, U, s_full = eig[h]
|
|
314
|
+
Tf = (s_full[:, None] * U)[:n_fixed]
|
|
315
|
+
cg = U.T @ (s_full * r[:, g])
|
|
316
|
+
s2 = np.exp(2.0 * t_nodes[g, h, ks[sel]])[:, None]
|
|
317
|
+
u = cg[None, :] / (d[None, :] + s2) + np.sqrt(s2 / (d[None, :] + s2)) * rng.standard_normal((len(sel), p))
|
|
318
|
+
out_draws[g, sel] = m[:n_fixed] + u @ Tf.T
|
|
319
|
+
|
|
320
|
+
a_idx = np.array([np.exp(a) for a, _ in hyper])
|
|
321
|
+
b_idx = np.array([np.exp(b) for _, b in hyper])
|
|
322
|
+
wh = w.sum(axis=2) # (G, H)
|
|
323
|
+
on_edge = np.array([(a in (log_grid[0], log_grid[-1])) or (b in (log_grid[0], log_grid[-1])) for a, b in hyper])
|
|
324
|
+
return {
|
|
325
|
+
"mean": M1,
|
|
326
|
+
"cov": cov,
|
|
327
|
+
"draws": out_draws,
|
|
328
|
+
"sigma2_mean": s2_mean,
|
|
329
|
+
"level_sd_mean": wh @ a_idx,
|
|
330
|
+
"curve_sd_mean": wh @ b_idx,
|
|
331
|
+
"edge_mass": wh[:, on_edge].sum(axis=1),
|
|
332
|
+
}
|
diffsvg/tl/clustering.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""K-means clustering of the fitted curves, on their spline coefficients.
|
|
2
|
+
|
|
3
|
+
The features come from the tables `diffsvg.tl.fit_model` and
|
|
4
|
+
`diffsvg.tl.fit_profiles` write; the result is plotted by
|
|
5
|
+
`diffsvg.pl.plot_cluster_curves`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from sklearn.cluster import KMeans
|
|
11
|
+
from sklearn.metrics import silhouette_score
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _coefficient_table(adata, by, coefs_key, profiles_key):
|
|
15
|
+
"""The genes x coefficients table behind `by`, with every gene of `adata.var_names` (NaN where not fitted)."""
|
|
16
|
+
|
|
17
|
+
def table(key, fit):
|
|
18
|
+
if key not in adata.varm:
|
|
19
|
+
raise KeyError(f"adata.varm[{key!r}] not found - run `diffsvg.tl.{fit}` first, with `inplace=True`.")
|
|
20
|
+
return adata.varm[key]
|
|
21
|
+
|
|
22
|
+
if by == "average":
|
|
23
|
+
return table(f"{coefs_key}_beta", "fit_model") + table(f"{coefs_key}_beta_1", "fit_model") / 2.0
|
|
24
|
+
if by == "interaction":
|
|
25
|
+
return table(f"{coefs_key}_beta_1", "fit_model")
|
|
26
|
+
if by == "profile":
|
|
27
|
+
return table(f"{profiles_key}_beta", "fit_profiles")
|
|
28
|
+
raise ValueError(f"Unknown by {by!r}. Available: ['average', 'interaction', 'profile']")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _features(table, scale):
|
|
32
|
+
"""Coefficient rows as k-means features: mean coefficient removed, optionally scaled to unit length.
|
|
33
|
+
|
|
34
|
+
The columns of the (centred, intercept-including) B-spline basis sum to 0,
|
|
35
|
+
so adding a constant to a gene's coefficients does not change its curve:
|
|
36
|
+
that direction holds only the prior and the sampling noise, not data.
|
|
37
|
+
"""
|
|
38
|
+
values = table.to_numpy(dtype=float)
|
|
39
|
+
values = values - values.mean(axis=1, keepdims=True)
|
|
40
|
+
if scale:
|
|
41
|
+
norms = np.linalg.norm(values, axis=1, keepdims=True)
|
|
42
|
+
values = values / np.where(norms > 0, norms, 1.0)
|
|
43
|
+
return values
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cluster_curves(
|
|
47
|
+
adata,
|
|
48
|
+
by="average",
|
|
49
|
+
n_clusters=5,
|
|
50
|
+
genes=None,
|
|
51
|
+
scale=False,
|
|
52
|
+
coefs_key="dsvg_coefs",
|
|
53
|
+
profiles_key="dsvg_profiles",
|
|
54
|
+
key_added=None,
|
|
55
|
+
random_state=0,
|
|
56
|
+
n_init=100,
|
|
57
|
+
inplace=True,
|
|
58
|
+
):
|
|
59
|
+
"""Group genes by the shape of their fitted curves, with k-means on the spline coefficients.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
adata
|
|
64
|
+
Annotated data matrix that `diffsvg.tl.fit_model` (or
|
|
65
|
+
`diffsvg.tl.fit_profiles`, for ``by="profile"``) wrote into.
|
|
66
|
+
by
|
|
67
|
+
Which curves to compare:
|
|
68
|
+
|
|
69
|
+
- ``"average"``: ``beta + beta_1 / 2``, the combined curve: the mean
|
|
70
|
+
of the two condition curves, weighting the conditions equally. Not
|
|
71
|
+
`beta` alone, which with the 0/1 condition coding is the curve of
|
|
72
|
+
condition 0.
|
|
73
|
+
- ``"interaction"``: the interaction term `beta_1`, i.e. the shape of
|
|
74
|
+
the difference curve ``f1 - f0``. Not the level difference, which
|
|
75
|
+
is a separate coefficient (``categorical_beta``).
|
|
76
|
+
- ``"profile"``: `beta` of `diffsvg.tl.fit_profiles`, the one curve
|
|
77
|
+
of a fit that ignores the condition. Without slide effects.
|
|
78
|
+
n_clusters
|
|
79
|
+
Number of clusters (`k`).
|
|
80
|
+
genes
|
|
81
|
+
Genes to cluster, by default every fitted one.
|
|
82
|
+
scale
|
|
83
|
+
Scale each gene's coefficients to unit length, so that genes group by
|
|
84
|
+
shape alone, however strong the trend. By default (`False`) the
|
|
85
|
+
amplitude counts, so genes without a trend share one flat cluster.
|
|
86
|
+
Scaling blows the noise of such genes up to the size of real trends,
|
|
87
|
+
so combine it with `genes`, e.g. the genes with a clear trend.
|
|
88
|
+
coefs_key, profiles_key
|
|
89
|
+
Prefixes passed to `diffsvg.tl.fit_model` / `diffsvg.tl.fit_profiles`.
|
|
90
|
+
key_added
|
|
91
|
+
Where to write the result. Defaults to ``f"dsvg_cluster_{by}"``.
|
|
92
|
+
random_state, n_init
|
|
93
|
+
Passed to `sklearn.cluster.KMeans`. Many restarts cost next to nothing
|
|
94
|
+
with one row per gene, and they make the result depend far less on the
|
|
95
|
+
seed: on an 88-gene panel 10 gave up to 25 different partitions across
|
|
96
|
+
30 seeds, 100 mostly one.
|
|
97
|
+
inplace
|
|
98
|
+
If `True` (default), write ``adata.var[key_added]`` (categorical,
|
|
99
|
+
`NaN` for genes not clustered) and ``adata.uns[key_added]`` (`by`,
|
|
100
|
+
`scale`, `n_clusters`, `silhouette`, `inertia`, `centers`) and return
|
|
101
|
+
`None`. Otherwise return the labels as a `pandas.Series`.
|
|
102
|
+
|
|
103
|
+
Returns
|
|
104
|
+
-------
|
|
105
|
+
None or pandas.Series
|
|
106
|
+
Labels are ``"0"``, ``"1"``, ..., the largest cluster first.
|
|
107
|
+
|
|
108
|
+
Examples
|
|
109
|
+
--------
|
|
110
|
+
>>> dvg.tl.cluster_curves(adata, by="average", n_clusters=6)
|
|
111
|
+
>>> dvg.pl.plot_cluster_curves(adata, "dsvg_cluster_average")
|
|
112
|
+
"""
|
|
113
|
+
table = _coefficient_table(adata, by, coefs_key, profiles_key)
|
|
114
|
+
if genes is not None:
|
|
115
|
+
genes = list(genes)
|
|
116
|
+
missing = [g for g in genes if g not in table.index]
|
|
117
|
+
if missing:
|
|
118
|
+
raise KeyError(f"genes not found in adata.var_names: {missing}")
|
|
119
|
+
table = table.loc[genes]
|
|
120
|
+
table = table.dropna(how="any")
|
|
121
|
+
if len(table) < n_clusters:
|
|
122
|
+
raise ValueError(f"Cannot form {n_clusters} clusters from {len(table)} fitted genes.")
|
|
123
|
+
|
|
124
|
+
features = _features(table, scale)
|
|
125
|
+
kmeans = KMeans(n_clusters=n_clusters, n_init=n_init, random_state=random_state).fit(features)
|
|
126
|
+
|
|
127
|
+
# Largest cluster first; labels do not depend on KMeans's arbitrary numbering.
|
|
128
|
+
sizes = np.bincount(kmeans.labels_, minlength=n_clusters)
|
|
129
|
+
order = np.argsort(-sizes, kind="stable")
|
|
130
|
+
new_label = np.empty(n_clusters, dtype=int)
|
|
131
|
+
new_label[order] = np.arange(n_clusters)
|
|
132
|
+
categories = [str(c) for c in range(n_clusters)]
|
|
133
|
+
labels = pd.Series(
|
|
134
|
+
pd.Categorical([str(c) for c in new_label[kmeans.labels_]], categories=categories),
|
|
135
|
+
index=table.index,
|
|
136
|
+
name=key_added or f"dsvg_cluster_{by}",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if not inplace:
|
|
140
|
+
return labels
|
|
141
|
+
|
|
142
|
+
# A single label, or one gene per cluster, has no silhouette.
|
|
143
|
+
has_silhouette = 1 < labels.nunique() < len(labels)
|
|
144
|
+
key = labels.name
|
|
145
|
+
adata.var[key] = labels.reindex(adata.var_names)
|
|
146
|
+
adata.uns[key] = {
|
|
147
|
+
"by": by,
|
|
148
|
+
"scale": bool(scale),
|
|
149
|
+
"n_clusters": int(n_clusters),
|
|
150
|
+
"silhouette": float(silhouette_score(features, kmeans.labels_)) if has_silhouette else float("nan"),
|
|
151
|
+
"inertia": float(kmeans.inertia_),
|
|
152
|
+
"centers": kmeans.cluster_centers_[order],
|
|
153
|
+
}
|
|
154
|
+
return None
|