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.
@@ -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
@@ -0,0 +1,10 @@
1
+ from .utils import BinnedData, bin_counts, bin_obs, build_spline_basis, compute_interior_knots, spline_basis_at
2
+
3
+ __all__ = [
4
+ "BinnedData",
5
+ "bin_counts",
6
+ "bin_obs",
7
+ "build_spline_basis",
8
+ "compute_interior_knots",
9
+ "spline_basis_at",
10
+ ]