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
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""Plots for the output of `diffsvg.tl.fit_model`: the statistics table and the per-condition curves."""
|
|
2
|
+
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
# ============================================================
|
|
8
|
+
# Label placement helpers (used by plot_volcano)
|
|
9
|
+
# ============================================================
|
|
10
|
+
# A dependency-free stand-in for `adjustText`: try a ring of offsets around each point and take the first
|
|
11
|
+
# label box that overlaps nothing placed so far, else the least-overlapping one. Best-effort.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_renderer(fig):
|
|
15
|
+
try:
|
|
16
|
+
fig.canvas.draw()
|
|
17
|
+
return fig.canvas.get_renderer()
|
|
18
|
+
except Exception: # noqa: BLE001 - collision avoidance is a nice-to-have, never worth crashing the plot
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _bbox_tuple(bbox):
|
|
23
|
+
return (bbox.x0, bbox.y0, bbox.x1, bbox.y1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _boxes_overlap(a, b, pad=2.0):
|
|
27
|
+
ax0, ay0, ax1, ay1 = a
|
|
28
|
+
bx0, by0, bx1, by1 = b
|
|
29
|
+
return not (ax1 + pad < bx0 or bx1 + pad < ax0 or ay1 + pad < by0 or by1 + pad < ay0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _dot_boxes(ax, xs, ys, marker_size, pad_px=2.0):
|
|
33
|
+
"""Small display-coordinate boxes around each `(x, y)` scatter point, for label-collision checks."""
|
|
34
|
+
if len(xs) == 0:
|
|
35
|
+
return []
|
|
36
|
+
pts = ax.transData.transform(np.column_stack([xs, ys]))
|
|
37
|
+
r = np.sqrt(marker_size / np.pi) * ax.get_figure().dpi / 72.0 + pad_px
|
|
38
|
+
return [(px - r, py - r, px + r, py + r) for px, py in pts]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _place_label(
|
|
42
|
+
ax,
|
|
43
|
+
renderer,
|
|
44
|
+
x,
|
|
45
|
+
y,
|
|
46
|
+
text,
|
|
47
|
+
occupied,
|
|
48
|
+
fontsize=7,
|
|
49
|
+
color="black",
|
|
50
|
+
fontweight="normal",
|
|
51
|
+
preferred_offset=None,
|
|
52
|
+
base_radius=10.0,
|
|
53
|
+
n_angles=10,
|
|
54
|
+
n_rings=4,
|
|
55
|
+
arrow=False,
|
|
56
|
+
bbox_kwargs=None,
|
|
57
|
+
zorder=12,
|
|
58
|
+
):
|
|
59
|
+
"""Place `text` near data point `(x, y)`, avoiding the display-coordinate boxes in `occupied`.
|
|
60
|
+
|
|
61
|
+
Tries `preferred_offset` (data units) first, then rings of pixel offsets; takes the first label box
|
|
62
|
+
that overlaps nothing in `occupied`, else the least-overlapping one, and appends it to `occupied`.
|
|
63
|
+
Without a `renderer` the label goes at `preferred_offset` (or the point) unchecked.
|
|
64
|
+
Returns the `matplotlib.text.Annotation`.
|
|
65
|
+
"""
|
|
66
|
+
point_disp = ax.transData.transform((x, y))
|
|
67
|
+
style = {
|
|
68
|
+
"textcoords": "data",
|
|
69
|
+
"fontsize": fontsize,
|
|
70
|
+
"color": color,
|
|
71
|
+
"fontweight": fontweight,
|
|
72
|
+
"ha": "center",
|
|
73
|
+
"va": "center",
|
|
74
|
+
"zorder": zorder,
|
|
75
|
+
"bbox": bbox_kwargs,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
def _measure(dx, dy):
|
|
79
|
+
# Without the arrow: its window extent reaches back to the point and would count as occupied.
|
|
80
|
+
cand = ax.transData.inverted().transform((point_disp[0] + dx, point_disp[1] + dy))
|
|
81
|
+
return cand, ax.annotate(text, xy=(x, y), xytext=cand, **style)
|
|
82
|
+
|
|
83
|
+
def _finalize(cand, ann):
|
|
84
|
+
if not arrow:
|
|
85
|
+
return ann
|
|
86
|
+
ann.remove()
|
|
87
|
+
arrowprops = {"arrowstyle": "->", "lw": 1.0, "color": color}
|
|
88
|
+
return ax.annotate(text, xy=(x, y), xytext=cand, arrowprops=arrowprops, **style)
|
|
89
|
+
|
|
90
|
+
if renderer is None:
|
|
91
|
+
dx, dy = (
|
|
92
|
+
(0.0, 0.0)
|
|
93
|
+
if preferred_offset is None
|
|
94
|
+
else tuple(ax.transData.transform((x + preferred_offset[0], y + preferred_offset[1])) - point_disp)
|
|
95
|
+
)
|
|
96
|
+
cand, ann = _measure(dx, dy)
|
|
97
|
+
return _finalize(cand, ann)
|
|
98
|
+
|
|
99
|
+
candidates = []
|
|
100
|
+
if preferred_offset is not None:
|
|
101
|
+
pdx, pdy = preferred_offset
|
|
102
|
+
candidates.append(tuple(ax.transData.transform((x + pdx, y + pdy)) - point_disp))
|
|
103
|
+
for ring in range(n_rings):
|
|
104
|
+
radius = base_radius * (1 + ring)
|
|
105
|
+
for i in range(n_angles):
|
|
106
|
+
angle = 2 * np.pi * i / n_angles + ring * 0.35
|
|
107
|
+
candidates.append((np.cos(angle) * radius, np.sin(angle) * radius))
|
|
108
|
+
|
|
109
|
+
best, best_score, best_box, best_cand = None, None, None, None
|
|
110
|
+
for dx, dy in candidates:
|
|
111
|
+
cand, ann = _measure(dx, dy)
|
|
112
|
+
box = _bbox_tuple(ann.get_window_extent(renderer=renderer))
|
|
113
|
+
score = sum(1 for other in occupied if _boxes_overlap(box, other))
|
|
114
|
+
if score == 0:
|
|
115
|
+
if best is not None:
|
|
116
|
+
best.remove()
|
|
117
|
+
occupied.append(box)
|
|
118
|
+
return _finalize(cand, ann)
|
|
119
|
+
# On ties prefer the later candidate: labels sharing a preferred spot would otherwise stack.
|
|
120
|
+
if best_score is None or score <= best_score:
|
|
121
|
+
if best is not None:
|
|
122
|
+
best.remove()
|
|
123
|
+
best, best_score, best_box, best_cand = ann, score, box, cand
|
|
124
|
+
else:
|
|
125
|
+
ann.remove()
|
|
126
|
+
|
|
127
|
+
occupied.append(best_box)
|
|
128
|
+
return _finalize(best_cand, best)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_parallel_table(stats_df):
|
|
132
|
+
"""Whether `stats_df` comes from ``fit_model(..., test="parallel")`` rather than the Wald test."""
|
|
133
|
+
return "parallel_pval" in stats_df.columns
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_AXIS_LABELS = {
|
|
137
|
+
# `integral` is the area under f1 - f0 (condition 1 minus condition 0).
|
|
138
|
+
"integral": r"$\Delta_{f_1 - f_0}$",
|
|
139
|
+
"slope_diff": r"$\Delta$ slope ($f_1 - f_0$)",
|
|
140
|
+
"level_diff": r"$\Delta$ level ($f_1 - f_0$)",
|
|
141
|
+
"shape_diff": "shape difference",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def plot_volcano(
|
|
146
|
+
stats_df,
|
|
147
|
+
x=None,
|
|
148
|
+
pvalue=None,
|
|
149
|
+
pvalue_adj=None,
|
|
150
|
+
alpha=0.05,
|
|
151
|
+
show_dots=True,
|
|
152
|
+
n_label=20,
|
|
153
|
+
highlight_genes=None,
|
|
154
|
+
highlight_offset=(0.025, 1.25),
|
|
155
|
+
ax=None,
|
|
156
|
+
figsize=(4, 6),
|
|
157
|
+
save_path=None,
|
|
158
|
+
):
|
|
159
|
+
"""Volcano plot of per-gene curve difference vs. significance.
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
stats_df
|
|
164
|
+
A statistics table indexed by gene, e.g. ``adata.varm["dsvg_stats"]``.
|
|
165
|
+
x
|
|
166
|
+
Column used for the x-axis. Defaults to ``"integral"`` (area between
|
|
167
|
+
the two condition curves) for the Wald test and ``"slope_diff"``
|
|
168
|
+
(linear trend of ``f1 - f0`` over the anchor range) for
|
|
169
|
+
``test="parallel"``; ``"integral"`` there shows level against shape.
|
|
170
|
+
pvalue
|
|
171
|
+
Column used for the y-axis, plotted as ``-log10(pvalue)``. Every
|
|
172
|
+
point above the ``-log10(alpha)`` reference line is colored by the
|
|
173
|
+
sign of `x` (blue = positive, red = negative); points at or below it
|
|
174
|
+
are black. Defaults to ``"wald_pval"`` for the Wald test and
|
|
175
|
+
``"parallel_pval"`` for ``test="parallel"``. p-values of exactly 0
|
|
176
|
+
(underflow) are drawn at the smallest non-zero one; genes with a
|
|
177
|
+
missing p-value or `x` (e.g. not fitted) are left out.
|
|
178
|
+
pvalue_adj
|
|
179
|
+
Column used to rank genes for labeling (the `n_label` genes with the
|
|
180
|
+
smallest `pvalue_adj` among those above the line get a text label).
|
|
181
|
+
Defaults to the adjusted version of the default `pvalue`.
|
|
182
|
+
alpha
|
|
183
|
+
Significance threshold on `pvalue`, drawn as a horizontal reference
|
|
184
|
+
line at ``-log10(alpha)`` and used for the color cutoff described
|
|
185
|
+
under `pvalue`.
|
|
186
|
+
show_dots
|
|
187
|
+
If `True` (default), every gene is drawn as a colored dot. If
|
|
188
|
+
`False`, every gene's name is drawn instead, in the same color, in
|
|
189
|
+
place of a dot - there is no separate background scatter in that
|
|
190
|
+
mode. Best suited to modest gene panels (a few hundred genes);
|
|
191
|
+
subset `stats_df` first for larger ones.
|
|
192
|
+
n_label
|
|
193
|
+
Label at most this many of the genes above the line (the ones with
|
|
194
|
+
the smallest `pvalue_adj`) with their gene name. Use `None` to label
|
|
195
|
+
every gene above the line, or `0` to label none. Labels use the
|
|
196
|
+
same collision-avoidance as `highlight_genes` (see below).
|
|
197
|
+
highlight_genes
|
|
198
|
+
A gene name, or list of gene names, to draw with a larger red point
|
|
199
|
+
and a bold, boxed, arrow-annotated label on top of the rest. Raises
|
|
200
|
+
`KeyError` if any given gene is not in `stats_df.index`.
|
|
201
|
+
highlight_offset
|
|
202
|
+
``(dx, dy)`` offset (in data units) tried first for each
|
|
203
|
+
`highlight_genes` label; if that spot is already occupied (by a
|
|
204
|
+
dot, another gene's name, or another label), a nearby spot is used
|
|
205
|
+
instead - so with several highlighted genes this is a preference,
|
|
206
|
+
not a guarantee.
|
|
207
|
+
ax
|
|
208
|
+
Existing axes to draw into; a new figure is created if omitted.
|
|
209
|
+
figsize
|
|
210
|
+
Passed to `plt.subplots` when `ax` is omitted.
|
|
211
|
+
save_path
|
|
212
|
+
If given, save the figure here (``dpi=300, bbox_inches="tight"``).
|
|
213
|
+
|
|
214
|
+
Returns
|
|
215
|
+
-------
|
|
216
|
+
matplotlib.axes.Axes
|
|
217
|
+
"""
|
|
218
|
+
if _is_parallel_table(stats_df):
|
|
219
|
+
defaults = ("slope_diff", "parallel_pval", "parallel_pval_adj")
|
|
220
|
+
elif "slope_pval" in stats_df.columns:
|
|
221
|
+
defaults = ("slope_diff", "slope_pval", "slope_pval_adj")
|
|
222
|
+
elif "level_pval" in stats_df.columns:
|
|
223
|
+
defaults = ("level_diff", "level_pval", "level_pval_adj")
|
|
224
|
+
else:
|
|
225
|
+
defaults = ("integral", "wald_pval", "wald_pval_adj")
|
|
226
|
+
x, pvalue, pvalue_adj = (
|
|
227
|
+
given if given is not None else default
|
|
228
|
+
for given, default in zip((x, pvalue, pvalue_adj), defaults, strict=True)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if ax is None:
|
|
232
|
+
_, ax = plt.subplots(figsize=figsize)
|
|
233
|
+
fig = ax.get_figure()
|
|
234
|
+
|
|
235
|
+
if highlight_genes is None:
|
|
236
|
+
highlight_genes = []
|
|
237
|
+
elif isinstance(highlight_genes, str):
|
|
238
|
+
highlight_genes = [highlight_genes]
|
|
239
|
+
else:
|
|
240
|
+
highlight_genes = list(highlight_genes)
|
|
241
|
+
missing = [g for g in highlight_genes if g not in stats_df.index]
|
|
242
|
+
if missing:
|
|
243
|
+
raise KeyError(f"highlight_genes not found in stats_df.index: {missing}")
|
|
244
|
+
|
|
245
|
+
# Missing p-values (genes that were not fitted) would otherwise be drawn as the most significant.
|
|
246
|
+
stats_df = stats_df[
|
|
247
|
+
np.isfinite(stats_df[pvalue].to_numpy(dtype=float)) & np.isfinite(stats_df[x].to_numpy(dtype=float))
|
|
248
|
+
]
|
|
249
|
+
missing = [g for g in highlight_genes if g not in stats_df.index]
|
|
250
|
+
if missing:
|
|
251
|
+
raise KeyError(f"highlight_genes have no p-value or `x`: {missing}")
|
|
252
|
+
x_vals = stats_df[x]
|
|
253
|
+
pvals = stats_df[pvalue].to_numpy(dtype=float)
|
|
254
|
+
positive = pvals[pvals > 0]
|
|
255
|
+
floor = positive.min() if positive.size else np.finfo(float).tiny
|
|
256
|
+
minus_log10_p = pd.Series(-np.log10(np.where(pvals > 0, pvals, floor)), index=stats_df.index)
|
|
257
|
+
threshold = -np.log10(alpha)
|
|
258
|
+
sig = minus_log10_p > threshold
|
|
259
|
+
pos_sign = x_vals > 0
|
|
260
|
+
neg_sign = x_vals < 0
|
|
261
|
+
|
|
262
|
+
ax.axvline(0, color="black", linestyle="--", lw=1)
|
|
263
|
+
ax.axhline(threshold, color="gray", linestyle="--", lw=1)
|
|
264
|
+
|
|
265
|
+
renderer = _get_renderer(fig) if (n_label != 0 or highlight_genes or not show_dots) else None
|
|
266
|
+
occupied = []
|
|
267
|
+
|
|
268
|
+
if show_dots:
|
|
269
|
+
ax.scatter(x_vals[~sig], minus_log10_p[~sig], color="black", s=20, alpha=0.7, linewidth=0)
|
|
270
|
+
ax.scatter(x_vals[sig & pos_sign], minus_log10_p[sig & pos_sign], color="blue", s=20, alpha=0.7, linewidth=0)
|
|
271
|
+
ax.scatter(x_vals[sig & neg_sign], minus_log10_p[sig & neg_sign], color="red", s=20, alpha=0.7, linewidth=0)
|
|
272
|
+
if renderer is not None:
|
|
273
|
+
occupied.extend(_dot_boxes(ax, x_vals.to_numpy(), minus_log10_p.to_numpy(), marker_size=20))
|
|
274
|
+
else:
|
|
275
|
+
for gene in stats_df.index:
|
|
276
|
+
color = "black" if not sig[gene] else ("blue" if pos_sign[gene] else "red")
|
|
277
|
+
txt = ax.text(
|
|
278
|
+
x_vals[gene], minus_log10_p[gene], gene, fontsize=6, color=color, alpha=0.85, ha="center", va="center"
|
|
279
|
+
)
|
|
280
|
+
if renderer is not None:
|
|
281
|
+
occupied.append(_bbox_tuple(txt.get_window_extent(renderer=renderer)))
|
|
282
|
+
|
|
283
|
+
label_genes = stats_df.index[sig]
|
|
284
|
+
if n_label is not None and len(label_genes) > n_label:
|
|
285
|
+
label_genes = stats_df.loc[label_genes, pvalue_adj].sort_values().index[:n_label]
|
|
286
|
+
|
|
287
|
+
for gene in label_genes:
|
|
288
|
+
if gene in highlight_genes:
|
|
289
|
+
continue # gets the larger highlight treatment below instead
|
|
290
|
+
_place_label(ax, renderer, x_vals[gene], minus_log10_p[gene], gene, occupied, fontsize=7, base_radius=8.0)
|
|
291
|
+
|
|
292
|
+
for gene in highlight_genes:
|
|
293
|
+
hx, hy = x_vals[gene], minus_log10_p[gene]
|
|
294
|
+
ax.scatter(hx, hy, s=80, color="red", edgecolor="black", linewidth=1.5, zorder=10)
|
|
295
|
+
_place_label(
|
|
296
|
+
ax,
|
|
297
|
+
renderer,
|
|
298
|
+
hx,
|
|
299
|
+
hy,
|
|
300
|
+
gene,
|
|
301
|
+
occupied,
|
|
302
|
+
fontsize=13,
|
|
303
|
+
fontweight="bold",
|
|
304
|
+
preferred_offset=highlight_offset,
|
|
305
|
+
base_radius=45.0,
|
|
306
|
+
n_rings=6,
|
|
307
|
+
arrow=True,
|
|
308
|
+
bbox_kwargs={"boxstyle": "round,pad=0.3", "fc": "white", "ec": "black", "alpha": 0.9},
|
|
309
|
+
zorder=13,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
ax.spines["top"].set_visible(False)
|
|
313
|
+
ax.spines["right"].set_visible(False)
|
|
314
|
+
ax.set_xlabel(_AXIS_LABELS.get(x, x), fontsize=12)
|
|
315
|
+
ax.set_ylabel(r"$-\log_{10}(p)$", fontsize=12)
|
|
316
|
+
ax.tick_params(labelsize=10)
|
|
317
|
+
|
|
318
|
+
fig.tight_layout()
|
|
319
|
+
if save_path is not None:
|
|
320
|
+
fig.savefig(save_path, dpi=300, bbox_inches="tight")
|
|
321
|
+
|
|
322
|
+
return ax
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _panel_grid(n_panels, ncols, figsize):
|
|
326
|
+
"""A figure with `n_panels` axes in rows of `ncols`, each panel `figsize` inches; returns ``(fig, axes)``, flat."""
|
|
327
|
+
ncols = max(1, min(ncols, n_panels))
|
|
328
|
+
nrows = (n_panels + ncols - 1) // ncols
|
|
329
|
+
width, height = figsize
|
|
330
|
+
fig, axes = plt.subplots(nrows, ncols, figsize=(ncols * width, nrows * height), squeeze=False)
|
|
331
|
+
axes = axes.ravel()
|
|
332
|
+
for spare in axes[n_panels:]:
|
|
333
|
+
spare.set_visible(False)
|
|
334
|
+
return fig, axes[:n_panels]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _draw_prediction_curve(ax, adata, gene, curves_key, condition_labels, colors):
|
|
338
|
+
"""One gene's two condition curves (mean + credible band) on `ax`."""
|
|
339
|
+
for cat in (0, 1):
|
|
340
|
+
curves = adata.varm[f"{curves_key}_cond{cat}_mean"]
|
|
341
|
+
lower = adata.varm[f"{curves_key}_cond{cat}_ci_low"].loc[gene]
|
|
342
|
+
upper = adata.varm[f"{curves_key}_cond{cat}_ci_high"].loc[gene]
|
|
343
|
+
# Taken from the columns, so the bin grid is read off the table itself.
|
|
344
|
+
x = curves.columns.to_numpy(dtype=float)
|
|
345
|
+
ax.fill_between(x, lower.to_numpy(dtype=float), upper.to_numpy(dtype=float), color=colors[cat], alpha=0.2, lw=0)
|
|
346
|
+
ax.plot(x, curves.loc[gene].to_numpy(dtype=float), color=colors[cat], label=condition_labels[str(cat)])
|
|
347
|
+
ax.spines["top"].set_visible(False)
|
|
348
|
+
ax.spines["right"].set_visible(False)
|
|
349
|
+
ax.set_xlabel("Normalized distance")
|
|
350
|
+
ax.set_ylabel("smoothed expression")
|
|
351
|
+
ax.set_title(gene)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def plot_prediction_curve(
|
|
355
|
+
adata,
|
|
356
|
+
gene,
|
|
357
|
+
curves_key="dsvg_curves",
|
|
358
|
+
condition_labels=None,
|
|
359
|
+
colors=None,
|
|
360
|
+
ncols=4,
|
|
361
|
+
figsize=(4, 3.2),
|
|
362
|
+
ax=None,
|
|
363
|
+
dpi=300,
|
|
364
|
+
save_path=None,
|
|
365
|
+
):
|
|
366
|
+
"""Plot the predicted expression curve (posterior mean +/- credible band) per gene, for both conditions.
|
|
367
|
+
|
|
368
|
+
Reads the six ``adata.varm[f"{curves_key}_cond{0,1}_{mean,ci_low,ci_high}"]``
|
|
369
|
+
tables written by `diffsvg.tl.fit_model`. Several genes are drawn as a
|
|
370
|
+
grid of panels, one gene each, each with its own axis ranges because genes
|
|
371
|
+
differ in scale.
|
|
372
|
+
|
|
373
|
+
Parameters
|
|
374
|
+
----------
|
|
375
|
+
adata
|
|
376
|
+
Annotated data matrix that `diffsvg.tl.fit_model` wrote into.
|
|
377
|
+
gene
|
|
378
|
+
A gene name, or a list of them (all must be in ``adata.var_names``).
|
|
379
|
+
curves_key
|
|
380
|
+
Prefix used by `diffsvg.tl.fit_model` for the per-condition curve
|
|
381
|
+
`adata.varm` entries.
|
|
382
|
+
condition_labels
|
|
383
|
+
``{"0": label, "1": label}`` for the legend. Defaults to
|
|
384
|
+
``adata.uns[f"{curves_key}_condition_categories"]`` if present,
|
|
385
|
+
otherwise generic ``"condition 0"``/``"condition 1"`` labels.
|
|
386
|
+
colors
|
|
387
|
+
``{0: color, 1: color}`` for the two curves.
|
|
388
|
+
ncols
|
|
389
|
+
Panels per row when several genes are given. Fewer genes than this
|
|
390
|
+
give a single, narrower row.
|
|
391
|
+
figsize
|
|
392
|
+
``(width, height)`` of a *single* panel in inches.
|
|
393
|
+
ax
|
|
394
|
+
Existing axes to draw into, for a single gene; a new figure is created
|
|
395
|
+
if omitted.
|
|
396
|
+
dpi
|
|
397
|
+
Resolution used when saving to `save_path`.
|
|
398
|
+
save_path
|
|
399
|
+
If given, save the figure here (``bbox_inches="tight"``).
|
|
400
|
+
|
|
401
|
+
Returns
|
|
402
|
+
-------
|
|
403
|
+
A `matplotlib.axes.Axes` when one gene is given, otherwise an array of
|
|
404
|
+
them, one per gene in the order passed.
|
|
405
|
+
|
|
406
|
+
Examples
|
|
407
|
+
--------
|
|
408
|
+
>>> dvg.pl.plot_prediction_curve(adata, gene=["PAX7", "IRX3"])
|
|
409
|
+
"""
|
|
410
|
+
genes = [gene] if isinstance(gene, str) else list(gene)
|
|
411
|
+
if not genes:
|
|
412
|
+
raise ValueError("`gene` is empty - pass at least one gene name.")
|
|
413
|
+
missing = [g for g in genes if g not in adata.var_names]
|
|
414
|
+
if missing:
|
|
415
|
+
raise KeyError(f"genes not found in adata.var_names: {missing}")
|
|
416
|
+
if ax is not None and len(genes) > 1:
|
|
417
|
+
raise ValueError("`ax` holds a single panel - leave it out to plot several genes.")
|
|
418
|
+
|
|
419
|
+
if condition_labels is None:
|
|
420
|
+
condition_labels = adata.uns.get(f"{curves_key}_condition_categories", {"0": "condition 0", "1": "condition 1"})
|
|
421
|
+
if colors is None:
|
|
422
|
+
colors = {0: "steelblue", 1: "darkorange"}
|
|
423
|
+
|
|
424
|
+
fig, axes = (ax.get_figure(), np.array([ax])) if ax is not None else _panel_grid(len(genes), ncols, figsize)
|
|
425
|
+
for panel, name in zip(axes, genes, strict=True):
|
|
426
|
+
_draw_prediction_curve(panel, adata, name, curves_key, condition_labels, colors)
|
|
427
|
+
# One shared legend: repeating it in every panel is just noise.
|
|
428
|
+
axes[0].legend(frameon=False, fontsize=9)
|
|
429
|
+
if ax is None:
|
|
430
|
+
fig.tight_layout()
|
|
431
|
+
|
|
432
|
+
if save_path is not None:
|
|
433
|
+
fig.savefig(save_path, dpi=dpi, bbox_inches="tight")
|
|
434
|
+
return axes[0] if len(genes) == 1 else axes
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def plot_pvalue_histogram(
|
|
438
|
+
stats_df,
|
|
439
|
+
pvalue=None,
|
|
440
|
+
bins=20,
|
|
441
|
+
ax=None,
|
|
442
|
+
figsize=(4, 4),
|
|
443
|
+
save_path=None,
|
|
444
|
+
):
|
|
445
|
+
"""QC histogram of a per-gene p-value column; should look roughly uniform under the null.
|
|
446
|
+
|
|
447
|
+
Parameters
|
|
448
|
+
----------
|
|
449
|
+
stats_df
|
|
450
|
+
A statistics table indexed by gene, e.g. ``adata.varm["dsvg_stats"]``.
|
|
451
|
+
pvalue
|
|
452
|
+
Column to histogram. Defaults to ``"wald_pval"`` for the Wald
|
|
453
|
+
test and ``"parallel_pval"`` for ``test="parallel"``.
|
|
454
|
+
bins
|
|
455
|
+
Passed to `matplotlib.axes.Axes.hist`.
|
|
456
|
+
ax
|
|
457
|
+
Existing axes to draw into; a new figure is created if omitted.
|
|
458
|
+
figsize
|
|
459
|
+
Passed to `plt.subplots` when `ax` is omitted.
|
|
460
|
+
save_path
|
|
461
|
+
If given, save the figure here (``dpi=300, bbox_inches="tight"``).
|
|
462
|
+
|
|
463
|
+
Returns
|
|
464
|
+
-------
|
|
465
|
+
matplotlib.axes.Axes
|
|
466
|
+
"""
|
|
467
|
+
if pvalue is None:
|
|
468
|
+
pvalue = next(c for c in ("parallel_pval", "slope_pval", "level_pval", "wald_pval") if c in stats_df.columns)
|
|
469
|
+
if ax is None:
|
|
470
|
+
_, ax = plt.subplots(figsize=figsize)
|
|
471
|
+
|
|
472
|
+
ax.hist(stats_df[pvalue].dropna(), bins=bins, edgecolor="black", linewidth=1.0)
|
|
473
|
+
|
|
474
|
+
ax.spines["top"].set_visible(False)
|
|
475
|
+
ax.spines["right"].set_visible(False)
|
|
476
|
+
ax.set_xlabel(pvalue, fontsize=12)
|
|
477
|
+
ax.set_ylabel("count", fontsize=12)
|
|
478
|
+
|
|
479
|
+
fig = ax.get_figure()
|
|
480
|
+
fig.tight_layout()
|
|
481
|
+
if save_path is not None:
|
|
482
|
+
fig.savefig(save_path, dpi=300, bbox_inches="tight")
|
|
483
|
+
|
|
484
|
+
return ax
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Plots for the output of `diffsvg.tl.check_stability`."""
|
|
2
|
+
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
import numpy as np
|
|
5
|
+
from scipy.cluster.hierarchy import dendrogram, linkage
|
|
6
|
+
from scipy.spatial.distance import squareform
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _clustered_order(similarity):
|
|
10
|
+
"""Order runs by hierarchical clustering on ``1 - similarity``, returning ``(order, linkage)``.
|
|
11
|
+
|
|
12
|
+
The linkage is what the dendrogram is drawn from; clustering the
|
|
13
|
+
configurations is the point of the plot, since it shows which parameter
|
|
14
|
+
changes land in the same place and which one sits on its own branch.
|
|
15
|
+
"""
|
|
16
|
+
distance = 1.0 - similarity.to_numpy()
|
|
17
|
+
distance = (distance + distance.T) / 2.0 # squareform insists on exact symmetry
|
|
18
|
+
np.fill_diagonal(distance, 0.0)
|
|
19
|
+
if distance.shape[0] < 3:
|
|
20
|
+
return list(range(distance.shape[0])), None
|
|
21
|
+
link = linkage(squareform(distance, checks=False), method="average")
|
|
22
|
+
return dendrogram(link, no_plot=True)["leaves"], link
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _heatmap(result, matrix, label, floor, ax, cmap, annotate, title):
|
|
26
|
+
order, link = _clustered_order(matrix)
|
|
27
|
+
ordered = matrix.iloc[order, order]
|
|
28
|
+
values = ordered.to_numpy()
|
|
29
|
+
n = len(ordered)
|
|
30
|
+
|
|
31
|
+
fig = ax.get_figure()
|
|
32
|
+
tree_ax, colorbar_ax = None, None
|
|
33
|
+
if ax.get_subplotspec() is not None:
|
|
34
|
+
# Give the dendrogram and the colorbar their own cells rather than
|
|
35
|
+
# letting the colorbar steal width from the heatmap: the dendrogram
|
|
36
|
+
# only lines up with the columns if both share one grid column.
|
|
37
|
+
rows = [1, 4] if link is not None else [0.001, 4]
|
|
38
|
+
grid = ax.get_subplotspec().subgridspec(
|
|
39
|
+
2, 2, height_ratios=rows, width_ratios=[20, 1], hspace=0.04, wspace=0.04
|
|
40
|
+
)
|
|
41
|
+
ax.remove()
|
|
42
|
+
if link is not None:
|
|
43
|
+
tree_ax = fig.add_subplot(grid[0, 0])
|
|
44
|
+
ax = fig.add_subplot(grid[1, 0])
|
|
45
|
+
colorbar_ax = fig.add_subplot(grid[1, 1])
|
|
46
|
+
|
|
47
|
+
if tree_ax is not None:
|
|
48
|
+
dendrogram(link, ax=tree_ax, color_threshold=0, above_threshold_color="black", no_labels=True)
|
|
49
|
+
# scipy places leaf i at 10*i + 5; pinning the limits to (0, 10n)
|
|
50
|
+
# makes that the same fractional position as column i under imshow.
|
|
51
|
+
tree_ax.set_xlim(0, 10 * n)
|
|
52
|
+
tree_ax.set_axis_off()
|
|
53
|
+
|
|
54
|
+
# Scaled to the data rather than a fixed 0-1: agreement is usually
|
|
55
|
+
# bunched near the top, and a fixed floor would flatten the differences
|
|
56
|
+
# that matter. Negative correlations stay visible.
|
|
57
|
+
image = ax.imshow(values, cmap=cmap, vmin=float(values.min()), vmax=1.0, aspect="auto")
|
|
58
|
+
ax.set_xticks(range(n), ordered.columns, rotation=90, fontsize=7)
|
|
59
|
+
ax.set_yticks(range(n), ordered.index, fontsize=7)
|
|
60
|
+
|
|
61
|
+
if annotate:
|
|
62
|
+
midpoint = (float(values.min()) + 1.0) / 2
|
|
63
|
+
for i in range(n):
|
|
64
|
+
for j in range(n):
|
|
65
|
+
ax.text(
|
|
66
|
+
j,
|
|
67
|
+
i,
|
|
68
|
+
f"{values[i, j]:.2f}",
|
|
69
|
+
ha="center",
|
|
70
|
+
va="center",
|
|
71
|
+
fontsize=6,
|
|
72
|
+
color="white" if values[i, j] < midpoint else "black",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
colorbar = fig.colorbar(image, cax=colorbar_ax, ax=None if colorbar_ax else ax)
|
|
76
|
+
colorbar.set_label(label, fontsize=9)
|
|
77
|
+
if np.isfinite(floor) and float(values.min()) <= floor <= 1.0:
|
|
78
|
+
colorbar.ax.axhline(floor, color="red", lw=1.5)
|
|
79
|
+
|
|
80
|
+
# On the dendrogram axes when there is one, so it clears the tree.
|
|
81
|
+
(tree_ax or ax).set_title(title, fontsize=11)
|
|
82
|
+
return ax
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _sensitivity(result, metric, ax, color):
|
|
86
|
+
data = result.sensitivity
|
|
87
|
+
if data.empty:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"`sensitivity` is empty - it needs configurations that differ from the baseline in exactly "
|
|
90
|
+
"one parameter, which requires at least one grid axis with more than one value."
|
|
91
|
+
)
|
|
92
|
+
labels = [f"{row.parameter}={row.value}" for row in data.itertuples()]
|
|
93
|
+
positions = np.arange(len(data))
|
|
94
|
+
|
|
95
|
+
ax.barh(positions, data[metric].to_numpy(), color=color, alpha=0.85)
|
|
96
|
+
ax.set_yticks(positions, labels, fontsize=8)
|
|
97
|
+
ax.invert_yaxis()
|
|
98
|
+
ax.set_xlim(0, 1)
|
|
99
|
+
ax.set_xlabel(f"{metric} vs. baseline", fontsize=10)
|
|
100
|
+
|
|
101
|
+
floor = result.noise_floor.get(metric, float("nan"))
|
|
102
|
+
if np.isfinite(floor):
|
|
103
|
+
ax.axvline(floor, color="red", linestyle="--", lw=1.5, label="noise floor")
|
|
104
|
+
ax.legend(frameon=False, fontsize=8, loc="lower right")
|
|
105
|
+
ax.spines["top"].set_visible(False)
|
|
106
|
+
ax.spines["right"].set_visible(False)
|
|
107
|
+
return ax
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def plot_stability(
|
|
111
|
+
result,
|
|
112
|
+
kind="heatmap",
|
|
113
|
+
metric="spearman",
|
|
114
|
+
ax=None,
|
|
115
|
+
figsize=None,
|
|
116
|
+
cmap="viridis",
|
|
117
|
+
annotate=True,
|
|
118
|
+
save_path=None,
|
|
119
|
+
):
|
|
120
|
+
"""Visualize a `diffsvg.tl.check_stability` result.
|
|
121
|
+
|
|
122
|
+
Parameters
|
|
123
|
+
----------
|
|
124
|
+
result
|
|
125
|
+
A `diffsvg.tl.StabilityResult`.
|
|
126
|
+
kind
|
|
127
|
+
``"heatmap"`` (default) draws the run-by-run agreement matrix with a
|
|
128
|
+
dendrogram above it, so configurations that rank genes the same way
|
|
129
|
+
cluster together. ``"sensitivity"`` draws the one-factor-at-a-time
|
|
130
|
+
view instead: how far each single parameter change moves the ranking
|
|
131
|
+
away from the baseline.
|
|
132
|
+
metric
|
|
133
|
+
``"spearman"`` (rank correlation over all genes) or ``"top_overlap"``
|
|
134
|
+
(fraction of the top `result.top_k` genes shared). The first is the
|
|
135
|
+
global view, the second is the decision-relevant one.
|
|
136
|
+
ax
|
|
137
|
+
Existing axes to draw into; a new figure is created if omitted. For
|
|
138
|
+
``kind="heatmap"`` the axes is split to make room for the
|
|
139
|
+
dendrogram, so pass one that can be subdivided (a normal subplot).
|
|
140
|
+
figsize
|
|
141
|
+
Defaults to something that scales with the number of runs.
|
|
142
|
+
cmap, annotate
|
|
143
|
+
Heatmap colormap, and whether to write each cell's value into it
|
|
144
|
+
(readable up to roughly a dozen runs).
|
|
145
|
+
save_path
|
|
146
|
+
If given, save the figure here (``dpi=300, bbox_inches="tight"``).
|
|
147
|
+
|
|
148
|
+
Returns
|
|
149
|
+
-------
|
|
150
|
+
matplotlib.axes.Axes
|
|
151
|
+
The axes the data was drawn into. Note that for ``kind="heatmap"``
|
|
152
|
+
this is *not* the `ax` that was passed in, since that one is split.
|
|
153
|
+
"""
|
|
154
|
+
if metric not in ("spearman", "top_overlap"):
|
|
155
|
+
raise ValueError(f"Unknown metric {metric!r}; expected 'spearman' or 'top_overlap'.")
|
|
156
|
+
|
|
157
|
+
if kind == "heatmap":
|
|
158
|
+
matrix = result.similarity if metric == "spearman" else result.top_overlap
|
|
159
|
+
label = "Spearman rank correlation" if metric == "spearman" else f"Top-{result.top_k} overlap"
|
|
160
|
+
floor = result.noise_floor.get(metric, float("nan"))
|
|
161
|
+
title = f"Ranking agreement across runs (by {result.rank_by})"
|
|
162
|
+
if np.isfinite(floor):
|
|
163
|
+
title += f"\nnoise floor {floor:.3f} (red line on the colorbar)"
|
|
164
|
+
if figsize is None:
|
|
165
|
+
side = max(5.0, 0.55 * len(matrix) + 3.0)
|
|
166
|
+
figsize = (side, side)
|
|
167
|
+
if ax is None:
|
|
168
|
+
_, ax = plt.subplots(figsize=figsize)
|
|
169
|
+
drawn = _heatmap(result, matrix, label, floor, ax, cmap, annotate, title)
|
|
170
|
+
elif kind == "sensitivity":
|
|
171
|
+
if figsize is None:
|
|
172
|
+
figsize = (6.0, max(2.5, 0.4 * len(result.sensitivity) + 1.5))
|
|
173
|
+
if ax is None:
|
|
174
|
+
_, ax = plt.subplots(figsize=figsize)
|
|
175
|
+
drawn = _sensitivity(result, metric, ax, "steelblue")
|
|
176
|
+
drawn.set_title(f"Effect of one parameter change (baseline: {result.baseline})", fontsize=10)
|
|
177
|
+
else:
|
|
178
|
+
raise ValueError(f"Unknown kind {kind!r}; expected 'heatmap' or 'sensitivity'.")
|
|
179
|
+
|
|
180
|
+
fig = drawn.get_figure()
|
|
181
|
+
fig.tight_layout()
|
|
182
|
+
if save_path is not None:
|
|
183
|
+
fig.savefig(save_path, dpi=300, bbox_inches="tight")
|
|
184
|
+
return drawn
|
diffsvg/pp/__init__.py
ADDED