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/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
+ }
@@ -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