plot-io 0.12.0__py3-none-win_arm64.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.
- plot_io/__init__.py +73 -0
- plot_io/_methods.py +1691 -0
- plot_io/_native/__init__.py +199 -0
- plot_io/_native/__main__.py +197 -0
- plot_io/_native/_api.py +457 -0
- plot_io/_native/bin/windows-arm64.dll +0 -0
- plot_io/_native/kernels.py +335 -0
- plot_io/_native/manifest.json +49 -0
- plot_io/axis.py +1347 -0
- plot_io/boxes.py +1156 -0
- plot_io/canvas.py +389 -0
- plot_io/color/__init__.py +3 -0
- plot_io/color/data.py +1646 -0
- plot_io/color/maps.py +922 -0
- plot_io/color/values.py +295 -0
- plot_io/color/variants.py +1246 -0
- plot_io/colorbar.py +1426 -0
- plot_io/columns.py +614 -0
- plot_io/decorations.py +920 -0
- plot_io/defaults.py +809 -0
- plot_io/density.py +113 -0
- plot_io/ext.py +68 -0
- plot_io/facets.py +240 -0
- plot_io/figure.py +788 -0
- plot_io/formatters.py +97 -0
- plot_io/genome/__init__.py +17 -0
- plot_io/genome/adapters.py +356 -0
- plot_io/genome/combine.py +123 -0
- plot_io/genome/contacts.py +508 -0
- plot_io/genome/intervals.py +750 -0
- plot_io/genome/lanes.py +191 -0
- plot_io/genome/layout.py +184 -0
- plot_io/genome/links.py +364 -0
- plot_io/genome/pack.py +27 -0
- plot_io/genome/readers.py +145 -0
- plot_io/genome/regions.py +228 -0
- plot_io/genome/scale.py +349 -0
- plot_io/genome/series.py +344 -0
- plot_io/genome/signal.py +621 -0
- plot_io/genome/sites.py +782 -0
- plot_io/genome/sources.py +459 -0
- plot_io/genome/testing.py +161 -0
- plot_io/geometry.py +289 -0
- plot_io/groups.py +308 -0
- plot_io/guides.py +503 -0
- plot_io/helpers/__init__.py +17 -0
- plot_io/helpers/clustermap.py +202 -0
- plot_io/helpers/dendrogram.py +172 -0
- plot_io/helpers/grids.py +304 -0
- plot_io/helpers/qq.py +334 -0
- plot_io/helpers/raincloud.py +249 -0
- plot_io/helpers/tree.py +214 -0
- plot_io/helpers/volcano.py +505 -0
- plot_io/hints.py +125 -0
- plot_io/kernels.py +740 -0
- plot_io/labels.py +566 -0
- plot_io/layers/__init__.py +100 -0
- plot_io/layers/base.py +630 -0
- plot_io/layers/context.py +87 -0
- plot_io/layers/core.py +446 -0
- plot_io/layers/geoms.py +1362 -0
- plot_io/layers/grammar.py +586 -0
- plot_io/layers/paint.py +614 -0
- plot_io/layers/pipeline.py +170 -0
- plot_io/layers/positions.py +431 -0
- plot_io/layers/stats.py +1088 -0
- plot_io/layout.py +1381 -0
- plot_io/legend.py +605 -0
- plot_io/levels.py +509 -0
- plot_io/limits.py +124 -0
- plot_io/lines.py +42 -0
- plot_io/mapped.py +522 -0
- plot_io/markers.py +110 -0
- plot_io/markup.py +128 -0
- plot_io/numbers.py +354 -0
- plot_io/occupancy.py +295 -0
- plot_io/plot.py +597 -0
- plot_io/presets/__init__.py +51 -0
- plot_io/presets/_util.py +153 -0
- plot_io/presets/area.py +145 -0
- plot_io/presets/bar.py +261 -0
- plot_io/presets/box.py +400 -0
- plot_io/presets/catpoints.py +177 -0
- plot_io/presets/contour.py +353 -0
- plot_io/presets/curves.py +30 -0
- plot_io/presets/dots.py +355 -0
- plot_io/presets/ecdf.py +97 -0
- plot_io/presets/errorbar.py +106 -0
- plot_io/presets/errors.py +296 -0
- plot_io/presets/fill.py +74 -0
- plot_io/presets/heatmap.py +322 -0
- plot_io/presets/hexbin.py +198 -0
- plot_io/presets/hist.py +391 -0
- plot_io/presets/hist2d.py +87 -0
- plot_io/presets/kde.py +162 -0
- plot_io/presets/kde2d.py +147 -0
- plot_io/presets/line.py +143 -0
- plot_io/presets/pie.py +502 -0
- plot_io/presets/regression.py +115 -0
- plot_io/presets/scatter.py +194 -0
- plot_io/presets/stem.py +100 -0
- plot_io/presets/violin.py +323 -0
- plot_io/py.typed +0 -0
- plot_io/registry.py +82 -0
- plot_io/render.py +480 -0
- plot_io/rows.py +491 -0
- plot_io/scales.py +103 -0
- plot_io/seams.py +138 -0
- plot_io/settings.py +807 -0
- plot_io/shapes.py +66 -0
- plot_io/sharing.py +232 -0
- plot_io/sizemaps.py +97 -0
- plot_io/spec.py +240 -0
- plot_io/states.py +216 -0
- plot_io/styles/__init__.py +307 -0
- plot_io/styles/library.py +397 -0
- plot_io/tables.py +138 -0
- plot_io/text.py +412 -0
- plot_io/ticks.py +400 -0
- plot_io/times.py +287 -0
- plot_io/titles.py +225 -0
- plot_io/transform.py +114 -0
- plot_io/types.py +13604 -0
- plot_io/units.py +768 -0
- plot_io/validators.py +1029 -0
- plot_io/values.py +190 -0
- plot_io-0.12.0.dist-info/METADATA +215 -0
- plot_io-0.12.0.dist-info/RECORD +130 -0
- plot_io-0.12.0.dist-info/WHEEL +4 -0
- plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""Volcano, MA and Manhattan plots: scatters of differential or association results, with
|
|
2
|
+
classes by thresholds, threshold rules and the top hits labelled by Scatter's point
|
|
3
|
+
label placer."""
|
|
4
|
+
|
|
5
|
+
import array
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .. import DEFAULTS, Scatter, ext, kernels
|
|
12
|
+
from .._methods import MaMixin, ManhattanMixin, VolcanoMixin
|
|
13
|
+
|
|
14
|
+
__all__ = ["Volcano", "Ma", "Manhattan"]
|
|
15
|
+
|
|
16
|
+
#: The classes of a volcano or MA plot, in legend order.
|
|
17
|
+
CLASSES = ("up", "down", "n.s.")
|
|
18
|
+
#: Title of a −log10 p axis.
|
|
19
|
+
LOG_P = "-log<sub>10</sub>(p)"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _floats(v: Any) -> Any:
|
|
23
|
+
if v is None or isinstance(v, str):
|
|
24
|
+
return None
|
|
25
|
+
try:
|
|
26
|
+
return kernels.doubles(v)
|
|
27
|
+
except (TypeError, ValueError):
|
|
28
|
+
return [math.nan if x is None else float(x) for x in
|
|
29
|
+
(v.tolist() if hasattr(v, "tolist") else v)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _setting(cls: Any, kw: dict[str, Any], name: str) -> Any:
|
|
33
|
+
"""A setting read at the call: given, else the class' default."""
|
|
34
|
+
if name in kw:
|
|
35
|
+
return cls.SETTINGS[name].check(kw[name])
|
|
36
|
+
for c in cls.__mro__:
|
|
37
|
+
key = f"{c.__name__}.{name}"
|
|
38
|
+
if key in DEFAULTS:
|
|
39
|
+
return cls.SETTINGS[name].check(DEFAULTS[key])
|
|
40
|
+
raise KeyError(name)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def classify(fc: list[float], p: list[float] | None, fc_threshold: float,
|
|
44
|
+
p_threshold: float) -> list[str | None]:
|
|
45
|
+
"""``"up"``, ``"down"`` or ``"n.s."`` per point (``None``: fold change or p not a
|
|
46
|
+
number): significant when ``p < p_threshold`` (always without ``p``), up or down when
|
|
47
|
+
also ``|fc| ≥ fc_threshold``."""
|
|
48
|
+
out: list[str | None] = []
|
|
49
|
+
for i, f in enumerate(fc):
|
|
50
|
+
q = p[i] if p is not None else 0.0
|
|
51
|
+
if f != f or q != q:
|
|
52
|
+
out.append(None)
|
|
53
|
+
elif q < p_threshold and f >= fc_threshold:
|
|
54
|
+
out.append("up")
|
|
55
|
+
elif q < p_threshold and f <= -fc_threshold:
|
|
56
|
+
out.append("down")
|
|
57
|
+
else:
|
|
58
|
+
out.append("n.s.")
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _levels(cls: Any, kw: dict[str, Any], fc: list[float], p: list[float] | None
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
classes = classify(fc, p, _setting(cls, kw, "fold_change_threshold"),
|
|
65
|
+
_setting(cls, kw, "p_threshold"))
|
|
66
|
+
colors = {**dict(zip(CLASSES, (DEFAULTS["Volcano.colors"][k] for k in CLASSES))),
|
|
67
|
+
**_setting(cls, kw, "colors")}
|
|
68
|
+
counts = _setting(cls, kw, "counts")
|
|
69
|
+
n = Counter(classes)
|
|
70
|
+
names = {k: f"{k} ({n[k]})" if counts else k for k in CLASSES}
|
|
71
|
+
present = [names[k] for k in CLASSES if k in classes]
|
|
72
|
+
return {"color": [names["n.s."] if c is None else names[c] for c in classes],
|
|
73
|
+
"color_map": {names[k]: colors[k] for k in CLASSES},
|
|
74
|
+
"level_order": present or [names["n.s."]]}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _colors(v: Any) -> dict[str, Any]:
|
|
78
|
+
if not isinstance(v, dict):
|
|
79
|
+
raise ValueError(f"expected a dict class → color, got {v!r}")
|
|
80
|
+
bad = sorted(set(v) - set(CLASSES))
|
|
81
|
+
if bad:
|
|
82
|
+
raise ValueError(f"no class {bad[0]!r} (classes: {', '.join(CLASSES)})")
|
|
83
|
+
return {k: ext.color(c) for k, c in v.items()}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _label(v: Any) -> Any:
|
|
87
|
+
if v is None or v is False:
|
|
88
|
+
return None
|
|
89
|
+
if isinstance(v, str):
|
|
90
|
+
return ext.one_of("top")(v)
|
|
91
|
+
return list(v)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _neg_log10(p: Any) -> Any:
|
|
95
|
+
"""−log10 of p-values, 0 at the smallest positive p (NaN, negatives: NaN)."""
|
|
96
|
+
v = kernels.doubles(p)
|
|
97
|
+
if kernels.extent(v)[1] <= 0:
|
|
98
|
+
tiny = min((q for q in v if q > 0), default=1.0)
|
|
99
|
+
v = [tiny if q == 0 else (q if q > 0 else math.nan) for q in v]
|
|
100
|
+
return kernels.scaled(v, -1.0, 0.0, "log10")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_READ_AT_CALL = ("fold_change_threshold", "p_threshold", "colors", "counts")
|
|
104
|
+
|
|
105
|
+
_COMMON = dict(
|
|
106
|
+
fold_change_threshold=ext.Setting(ext.number(0), doc="Smallest |log2 fold change| "
|
|
107
|
+
"of the up and down classes."),
|
|
108
|
+
p_threshold=ext.Setting(ext.number(0, 1), doc="p below which a point is significant."),
|
|
109
|
+
colors=ext.Setting(_colors, doc="Colors of the classes `up`, `down`, `n.s.` (a dict; "
|
|
110
|
+
"classes not given keep theirs)."),
|
|
111
|
+
counts=ext.Setting(ext.boolean, doc="Counts in the legend entries (`up (123)`)."),
|
|
112
|
+
thresholds=ext.Setting(ext.boolean, doc="Dashed rules at the thresholds."),
|
|
113
|
+
threshold_color=ext.Setting(ext.color, doc="Color of the threshold rules."),
|
|
114
|
+
threshold_width=ext.Setting(ext.length(lo=0), doc="Width of the threshold rules."),
|
|
115
|
+
label=ext.Setting(_label, doc="Points labelled with their `labels` text: `top` (the "
|
|
116
|
+
"`label_count` most significant beyond both thresholds), "
|
|
117
|
+
"a list of texts (those points), `None`."),
|
|
118
|
+
label_count=ext.Setting(ext.number(0, integer=True), doc="Points `label=\"top\"` labels."),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _settings(**extra: Any) -> Any:
|
|
123
|
+
return ext.table(
|
|
124
|
+
**{k: v for k, v in Scatter.SETTINGS.items()
|
|
125
|
+
if k not in ("x_error", "y_error", "orientation")},
|
|
126
|
+
**extra)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _rules(s: Any, xs: list[float], ys: list[float], solid: int = -1) -> Any:
|
|
130
|
+
"""A table of dashed rules across the axes: vertical at ``xs``, horizontal at ``ys``
|
|
131
|
+
(the ``solid``-th one solid)."""
|
|
132
|
+
if not s.resolve("thresholds") or not (xs or ys):
|
|
133
|
+
return None
|
|
134
|
+
t = ext.Table()
|
|
135
|
+
t.lazy["x0"] = lambda ctx: [*xs, *(ctx.x.lo for _ in ys)]
|
|
136
|
+
t.lazy["x1"] = lambda ctx: [*xs, *(ctx.x.hi for _ in ys)]
|
|
137
|
+
t.lazy["y0"] = lambda ctx: [*(ctx.y.lo for _ in xs), *ys]
|
|
138
|
+
t.lazy["y1"] = lambda ctx: [*(ctx.y.hi for _ in xs), *ys]
|
|
139
|
+
t["dash"] = [None if k == solid else "dashed" for k in range(len(xs) + len(ys))]
|
|
140
|
+
return t
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _rule_layer() -> Any:
|
|
144
|
+
return ext.Lay("segments", ("stroke", {"color": "threshold_color",
|
|
145
|
+
"width": "threshold_width",
|
|
146
|
+
"fixed": lambda s: {"dash": _dashes(s)}}),
|
|
147
|
+
table="rules", key=False, occupy=False, bounds=False)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _dashes(s: Any) -> Any:
|
|
151
|
+
t = s._rule_table()
|
|
152
|
+
return None if t is None else t["dash"]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _points_layer() -> Any:
|
|
156
|
+
return ext.Lay("points", ("marker", {"order": True, "once": True,
|
|
157
|
+
"n": lambda s: len(s.cols[s.FOLD])}),
|
|
158
|
+
name="points", radius=lambda s: _radius(s.resolve("size")))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _radius(size: Any) -> float:
|
|
162
|
+
return size / 2 if isinstance(size, (int, float)) else 0.0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _finish(s: Any, b: Any, x_title: str, y_title: str) -> None:
|
|
166
|
+
labels = Scatter.SPEC.finish # Scatter's point labels
|
|
167
|
+
if labels is not None:
|
|
168
|
+
labels(s, b)
|
|
169
|
+
b.call.titles.setdefault("x", x_title)
|
|
170
|
+
b.call.titles.setdefault("y", y_title)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class _Thresholds(Scatter):
|
|
174
|
+
"""What Volcano and Ma share: classes read at the call (``color`` becomes the class of
|
|
175
|
+
each point, a level axis coloured by ``colors``, with counts), the labels of the top
|
|
176
|
+
hits."""
|
|
177
|
+
|
|
178
|
+
FOLD = "fold_change"
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def _at_call(cls, data: dict[str, Any], kw: dict[str, Any]) -> dict[str, Any]:
|
|
182
|
+
if "color" in kw or "group" in kw:
|
|
183
|
+
return kw
|
|
184
|
+
fc, p = _floats(data.get(cls.FOLD)), _floats(data.get("p"))
|
|
185
|
+
if fc is None or (p is None and data.get("p") is not None):
|
|
186
|
+
return kw
|
|
187
|
+
return {**kw, **_levels(cls, kw, fc, p)}
|
|
188
|
+
|
|
189
|
+
def set(self, **settings: Any) -> Any:
|
|
190
|
+
if hasattr(self, "cols"):
|
|
191
|
+
late = [k for k in settings if k in _READ_AT_CALL]
|
|
192
|
+
if late and getattr(self, "_classified", False):
|
|
193
|
+
raise ValueError(f"{type(self).__name__}: {late[0]} is read at the call: "
|
|
194
|
+
f"pass it to plt.{type(self).__name__}(…)")
|
|
195
|
+
return super().set(**settings)
|
|
196
|
+
|
|
197
|
+
def _p(self) -> list[float] | None:
|
|
198
|
+
return _floats(self.cols["p"])
|
|
199
|
+
|
|
200
|
+
def _ranked(self, fc: Any, p: list[float] | None) -> Any:
|
|
201
|
+
"""Point indices, most significant first (NaN first: skipped by the caller)."""
|
|
202
|
+
return kernels.argsort(p) if p is not None else _by_fold(fc)
|
|
203
|
+
|
|
204
|
+
def _label_picks(self) -> list[tuple[int, Any]]:
|
|
205
|
+
texts = self.resolve("labels")
|
|
206
|
+
which = self.resolve("label")
|
|
207
|
+
if not texts or which is None:
|
|
208
|
+
return []
|
|
209
|
+
if which != "top":
|
|
210
|
+
want = set(map(str, which))
|
|
211
|
+
return [(i, str(t)) for i, t in enumerate(texts) if t is not None
|
|
212
|
+
and str(t) in want]
|
|
213
|
+
fc = kernels.doubles(self.cols[self.FOLD])
|
|
214
|
+
p = self._p()
|
|
215
|
+
t_fc, t_p = self.resolve("fold_change_threshold"), self.resolve("p_threshold")
|
|
216
|
+
count = self.resolve("label_count")
|
|
217
|
+
out: list[tuple[int, Any]] = []
|
|
218
|
+
for i in self._ranked(fc, p):
|
|
219
|
+
if len(out) >= count:
|
|
220
|
+
break
|
|
221
|
+
if i >= len(texts) or texts[i] is None or texts[i] == "":
|
|
222
|
+
continue
|
|
223
|
+
c = classify([fc[i]], None if p is None else [p[i]], t_fc, t_p)[0]
|
|
224
|
+
if c in ("up", "down"):
|
|
225
|
+
out.append((i, str(texts[i])))
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _by_fold(fc: Any) -> Any:
|
|
230
|
+
return kernels.argsort([-abs(f) for f in fc])
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class Volcano(VolcanoMixin, _Thresholds):
|
|
234
|
+
"""Differential results: log2 ``fold_change`` (x) against −log10 ``p`` (y; p = 0 at
|
|
235
|
+
the smallest positive p, NaN left out). Points are ``up``, ``down`` or ``n.s.``: up
|
|
236
|
+
or down when ``p < p_threshold`` and |fold change| ≥ ``fold_change_threshold``,
|
|
237
|
+
coloured by ``colors`` as a level axis on ``color`` (the legend counts them), dashed
|
|
238
|
+
rules at the thresholds. ``labels`` gives a text per point, ``label`` which are drawn
|
|
239
|
+
(placed as Scatter's). The classification settings are read at the call.
|
|
240
|
+
|
|
241
|
+
:class:`Scatter`'s settings apply; a later ``set`` of the classification settings
|
|
242
|
+
(``fold_change_threshold``, ``p_threshold``, ``colors``, ``counts``) raises.
|
|
243
|
+
|
|
244
|
+
The class colors are ``colors`` (up ``#d6604d``, down ``#4393c3``, n.s. ``#bdbdbd``; the
|
|
245
|
+
plot's colors are not used), with their counts in the legend (``up (123)``; ``counts=False``).
|
|
246
|
+
``thresholds=False`` leaves the rules out. ``label="top"`` (default) labels the
|
|
247
|
+
``label_count`` (10) most significant points beyond both thresholds, a list of texts
|
|
248
|
+
labels those points, ``None`` none. ``color=`` or ``group=`` given replaces the
|
|
249
|
+
classes. Uses::
|
|
250
|
+
|
|
251
|
+
p.volcano("log2FC", "pvalue", data=res, labels="gene") # top 10 hits labelled
|
|
252
|
+
p.volcano(fc, pv, labels=names, label=["TP53", "MYC"]) # these labelled
|
|
253
|
+
|
|
254
|
+
Example::
|
|
255
|
+
|
|
256
|
+
rng = random.Random(1)
|
|
257
|
+
fc = [rng.gauss(0, 1.5) for _ in range(300)]
|
|
258
|
+
p = plt.Plot(width=240, height=160)
|
|
259
|
+
p.volcano(fc, [min(1, rng.random() * 2 ** -abs(v * 4)) for v in fc])
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
SETTINGS = _settings(**_COMMON)
|
|
263
|
+
DATA = {"fold_change": "numbers", "p": "numbers"}
|
|
264
|
+
|
|
265
|
+
def __init__(self, fold_change: Any, p: Any, plot: Any = None,
|
|
266
|
+
**settings: Any) -> None:
|
|
267
|
+
ext.Series.__init__(self, plot=plot, **settings)
|
|
268
|
+
self.__dict__["_classified"] = True
|
|
269
|
+
|
|
270
|
+
def density_points(self) -> tuple[Any, Any]:
|
|
271
|
+
return kernels.doubles(self.cols["fold_change"]), _y(self)
|
|
272
|
+
|
|
273
|
+
def _rule_table(self) -> Any:
|
|
274
|
+
t, p = self.resolve("fold_change_threshold"), self.resolve("p_threshold")
|
|
275
|
+
return _rules(self, [-t, t] if t else [0.0], [-math.log10(p)] if p > 0 else [])
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _volcano_data(s: Any, b: Any) -> Any:
|
|
279
|
+
return ext.Table(x=kernels.doubles(s.cols["fold_change"]), y=_y(s))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _y(s: Any) -> Any:
|
|
283
|
+
"""−log10 p of a series' points (once)."""
|
|
284
|
+
return s.cached("neg_log10_p", lambda: _neg_log10(s.cols["p"]))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
Volcano.SPEC = ext.Spec(
|
|
288
|
+
[_rule_layer(), _points_layer()],
|
|
289
|
+
tables={"data": _volcano_data, "rules": lambda s, b: s._rule_table()},
|
|
290
|
+
key={"fill": "marker", "stroke": "marker", "marker": "@points"},
|
|
291
|
+
titles={"fold_change": "x"}, color_sources=True, size_sources=True,
|
|
292
|
+
finish=lambda s, b: _finish(s, b, "log<sub>2</sub> fold change", LOG_P))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class Ma(MaMixin, _Thresholds):
|
|
296
|
+
"""An MA plot: mean expression ``mean`` (x, on a log10 axis unless the axis has a scale)
|
|
297
|
+
against log2 ``fold_change`` (y), a rule at 0 and dashed ones at ±
|
|
298
|
+
``fold_change_threshold``. Classes, colors and labels as `Volcano`'s (by fold change
|
|
299
|
+
alone without ``p``; ``label="top"``: the largest |fold change| among the
|
|
300
|
+
significant).
|
|
301
|
+
|
|
302
|
+
:class:`Scatter`'s settings apply. The classification settings (``fold_change_threshold``,
|
|
303
|
+
``p_threshold``, ``colors``, ``counts``) are read at the call: set them there (a later
|
|
304
|
+
``set`` of them raises). Use::
|
|
305
|
+
|
|
306
|
+
p.ma("baseMean", "log2FC", "padj", data=res)
|
|
307
|
+
|
|
308
|
+
Example::
|
|
309
|
+
|
|
310
|
+
rng = random.Random(1)
|
|
311
|
+
fc = [rng.gauss(0, 1.5) for _ in range(300)]
|
|
312
|
+
p = plt.Plot(width=240, height=160)
|
|
313
|
+
p.ma([10 ** rng.uniform(0, 4) for _ in fc], fc, [min(1, rng.random() * 2 ** -abs(v * 4)) for v in fc])
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
SETTINGS = _settings(**_COMMON)
|
|
317
|
+
DATA = {"mean": "numbers", "fold_change": "numbers", "p": "raw"}
|
|
318
|
+
|
|
319
|
+
def __init__(self, mean: Any, fold_change: Any, p: Any = None, plot: Any = None,
|
|
320
|
+
**settings: Any) -> None:
|
|
321
|
+
ext.Series.__init__(self, plot=plot, **settings)
|
|
322
|
+
self.__dict__["_classified"] = True
|
|
323
|
+
axis = self.plot.x_axis
|
|
324
|
+
if not axis.is_set("scale"):
|
|
325
|
+
axis.set(scale="log10")
|
|
326
|
+
|
|
327
|
+
def _ranked(self, fc: Any, p: list[float] | None) -> Any:
|
|
328
|
+
return _by_fold(fc)
|
|
329
|
+
|
|
330
|
+
def density_points(self) -> tuple[Any, Any]:
|
|
331
|
+
return kernels.doubles(self.cols["mean"]), kernels.doubles(self.cols["fold_change"])
|
|
332
|
+
|
|
333
|
+
def _rule_table(self) -> Any:
|
|
334
|
+
t = self.resolve("fold_change_threshold")
|
|
335
|
+
return _rules(self, [], [0.0, -t, t] if t else [0.0], solid=0)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _ma_data(s: Any, b: Any) -> Any:
|
|
339
|
+
return ext.Table(x=kernels.doubles(s.cols["mean"]), y=kernels.doubles(s.cols["fold_change"]))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
Ma.SPEC = ext.Spec(
|
|
343
|
+
[_rule_layer(), _points_layer()],
|
|
344
|
+
tables={"data": _ma_data, "rules": lambda s, b: s._rule_table()},
|
|
345
|
+
key={"fill": "marker", "stroke": "marker", "marker": "@points"},
|
|
346
|
+
titles={"mean": "x", "fold_change": "y"}, color_sources=True, size_sources=True,
|
|
347
|
+
finish=lambda s, b: _finish(s, b, "mean", "log<sub>2</sub> fold change"))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# -- Manhattan -----------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
def _chrom_key(name: str) -> tuple[int, int, str]:
|
|
353
|
+
"""Natural order of chromosome names: numbered ones by number, then X, Y, M, others."""
|
|
354
|
+
bare = re.sub(r"^chr", "", name, flags=re.IGNORECASE)
|
|
355
|
+
if bare.isdigit():
|
|
356
|
+
return 0, int(bare), ""
|
|
357
|
+
rank = {"X": 1, "Y": 2, "M": 3, "MT": 3}.get(bare.upper(), 4)
|
|
358
|
+
return 1, rank, bare
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _sizes(v: Any) -> Any:
|
|
362
|
+
if not isinstance(v, dict):
|
|
363
|
+
raise ValueError(f"expected a dict chromosome → size, got {v!r}")
|
|
364
|
+
return {str(k): ext.number(0)(n) for k, n in v.items()}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _pair(v: Any) -> Any:
|
|
368
|
+
if isinstance(v, str) or len(v) != 2:
|
|
369
|
+
raise ValueError(f"expected two colors, got {v!r}")
|
|
370
|
+
return tuple(ext.color(c) for c in v)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _p_line(v: Any) -> Any:
|
|
374
|
+
return None if v is None or v is False else ext.number(0, 1)(v)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class Manhattan(ManhattanMixin, Scatter):
|
|
378
|
+
"""Genome-wide association results: −log10 ``p`` (y) at ``positions`` (bp) of
|
|
379
|
+
``chromosomes`` laid end to end (x) in natural order (chr1 … chr22, X, Y, M), each
|
|
380
|
+
``chr_sizes`` long (unset: to its last position), its points in the next of the two
|
|
381
|
+
``colors``, its name as the tick at its middle (left to right, a name that would
|
|
382
|
+
overlap the last one shown is left out), the x axis from the first chromosome's start
|
|
383
|
+
to the last one's end; a dashed rule at ``threshold`` (genome-wide significance).
|
|
384
|
+
``labels``/``label``/``label_count`` as `Volcano`'s (``top``: the most significant
|
|
385
|
+
beyond ``threshold``).
|
|
386
|
+
|
|
387
|
+
:class:`Scatter`'s settings apply. The classification settings (``fold_change_threshold``,
|
|
388
|
+
``p_threshold``, ``colors``, ``counts``) are read at the call: set them there (a later
|
|
389
|
+
``set`` of them raises). Use::
|
|
390
|
+
|
|
391
|
+
p.manhattan("chr", "pos", "p", data=gwas, labels="snp", label="top")
|
|
392
|
+
|
|
393
|
+
Example::
|
|
394
|
+
|
|
395
|
+
rng = random.Random(1)
|
|
396
|
+
chrs = [str(c) for c in range(1, 7) for _ in range(100)]
|
|
397
|
+
p = plt.Plot(width=300, height=160)
|
|
398
|
+
p.manhattan(chrs, [rng.randrange(50_000_000) for _ in chrs],
|
|
399
|
+
[rng.random() ** 6 for _ in chrs], threshold=1e-5)
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
SETTINGS = _settings(
|
|
403
|
+
chr_sizes=ext.Setting(_sizes, doc="Chromosome sizes (bp), a dict."),
|
|
404
|
+
colors=ext.Setting(_pair, doc="The two colors chromosomes alternate."),
|
|
405
|
+
threshold=ext.Setting(_p_line, doc="p of the dashed rule (`None`: none)."),
|
|
406
|
+
**{k: _COMMON[k] for k in ("threshold_color", "threshold_width", "label",
|
|
407
|
+
"label_count")},
|
|
408
|
+
)
|
|
409
|
+
DATA = {"chromosomes": "raw", "positions": "numbers", "p": "numbers"}
|
|
410
|
+
|
|
411
|
+
def __init__(self, chromosomes: Any, positions: Any, p: Any, plot: Any = None,
|
|
412
|
+
**settings: Any) -> None:
|
|
413
|
+
ext.Series.__init__(self, plot=plot, **settings)
|
|
414
|
+
|
|
415
|
+
def layout(self) -> tuple[list[float], list[tuple[str, float, float]]]:
|
|
416
|
+
"""x of each point and ``(chromosome, start, end)`` of each chromosome on x."""
|
|
417
|
+
def compute() -> Any:
|
|
418
|
+
names = [str(c) for c in self._column("chromosomes")]
|
|
419
|
+
pos = list(kernels.doubles(self.cols["positions"]))
|
|
420
|
+
last: dict[str, float] = {}
|
|
421
|
+
for c, x in zip(names, pos):
|
|
422
|
+
if x == x and x > last.get(c, -math.inf):
|
|
423
|
+
last[c] = x
|
|
424
|
+
sizes = self.resolve("chr_sizes") or {}
|
|
425
|
+
spans, at, start = [], {}, 0.0
|
|
426
|
+
for c in sorted(set(names), key=_chrom_key):
|
|
427
|
+
size = float(sizes.get(c, last.get(c, 0.0)))
|
|
428
|
+
spans.append((c, start, start + size))
|
|
429
|
+
at[c] = start
|
|
430
|
+
start += size
|
|
431
|
+
return [at[c] + x for c, x in zip(names, pos)], spans
|
|
432
|
+
return self.cached("manhattan_layout", compute, ("chr_sizes",))
|
|
433
|
+
|
|
434
|
+
def _column(self, name: str) -> list[Any]:
|
|
435
|
+
v = self.cols[name]
|
|
436
|
+
return list(v.tolist() if hasattr(v, "tolist") else v)
|
|
437
|
+
|
|
438
|
+
def density_points(self) -> tuple[Any, Any]:
|
|
439
|
+
return self.layout()[0], _y(self)
|
|
440
|
+
|
|
441
|
+
def _point_colors(self, ctx: Any) -> Any:
|
|
442
|
+
a, b = self.resolve("colors")
|
|
443
|
+
names = [str(c) for c in self._column("chromosomes")]
|
|
444
|
+
order = {c: k for k, (c, _, _) in enumerate(self.layout()[1])}
|
|
445
|
+
return [a if order[c] % 2 == 0 else b for c in names]
|
|
446
|
+
|
|
447
|
+
def _rule_table(self) -> Any:
|
|
448
|
+
t = self.resolve("threshold")
|
|
449
|
+
if t is None or t <= 0:
|
|
450
|
+
return None
|
|
451
|
+
tab = ext.Table(y0=[-math.log10(t)], y1=[-math.log10(t)])
|
|
452
|
+
tab.lazy["x0"] = lambda ctx: [ctx.x.lo]
|
|
453
|
+
tab.lazy["x1"] = lambda ctx: [ctx.x.hi]
|
|
454
|
+
tab["dash"] = ["dashed"]
|
|
455
|
+
return tab
|
|
456
|
+
|
|
457
|
+
def _label_picks(self) -> list[tuple[int, Any]]:
|
|
458
|
+
texts = self.resolve("labels")
|
|
459
|
+
which = self.resolve("label")
|
|
460
|
+
if not texts or which is None:
|
|
461
|
+
return []
|
|
462
|
+
named = [(i, str(t)) for i, t in enumerate(texts) if t is not None and t != ""]
|
|
463
|
+
if which != "top":
|
|
464
|
+
want = set(map(str, which))
|
|
465
|
+
return [(i, t) for i, t in named if t in want]
|
|
466
|
+
p = list(kernels.doubles(self.cols["p"]))
|
|
467
|
+
t = self.resolve("threshold")
|
|
468
|
+
hits = [(i, s) for i, s in named if i < len(p) and p[i] == p[i]
|
|
469
|
+
and (t is None or p[i] < t)]
|
|
470
|
+
hits.sort(key=lambda it: (p[it[0]], it[0]))
|
|
471
|
+
return hits[:self.resolve("label_count")]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _manhattan_data(s: Any, b: Any) -> Any:
|
|
475
|
+
return ext.Table(x=s.layout()[0], y=_y(s))
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _manhattan_finish(s: Any, b: Any) -> None:
|
|
479
|
+
_finish(s, b, "chromosome", LOG_P)
|
|
480
|
+
spans = s.layout()[1]
|
|
481
|
+
b.call.ticks["x"] = [((a + e) / 2, re.sub(r"^chr", "", c, flags=re.IGNORECASE))
|
|
482
|
+
for c, a, e in spans]
|
|
483
|
+
b.call.thin.add("x")
|
|
484
|
+
if spans: # the axis spans the chromosomes, unpadded
|
|
485
|
+
b.call.extra["x"] = array.array("d", [spans[0][1], spans[-1][2]])
|
|
486
|
+
b.call.exact.add("x")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
Manhattan.SPEC = ext.Spec(
|
|
490
|
+
[ext.Lay("segments", ("stroke", {"color": "threshold_color", "width": "threshold_width",
|
|
491
|
+
"fixed": {"dash": ["dashed"]}}),
|
|
492
|
+
table="rules", key=False, occupy=False, bounds=False),
|
|
493
|
+
ext.Lay("points", ("marker", {"order": True, "once": True,
|
|
494
|
+
"n": lambda s: len(s.cols["positions"]),
|
|
495
|
+
"color": lambda s: "base" if s.is_set("color")
|
|
496
|
+
else "_point_colors()"}),
|
|
497
|
+
name="points", radius=lambda s: _radius(s.resolve("size")))],
|
|
498
|
+
tables={"data": _manhattan_data, "rules": lambda s, b: s._rule_table()},
|
|
499
|
+
key={"fill": "marker", "stroke": "marker", "marker": "@points"},
|
|
500
|
+
color_sources=True, size_sources=True, finish=_manhattan_finish)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
ext.register("volcano", Volcano)
|
|
504
|
+
ext.register("ma", Ma)
|
|
505
|
+
ext.register("manhattan", Manhattan)
|
plot_io/hints.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""matplotlib names and their plot_io forms, read by the error messages and by the
|
|
2
|
+
"coming from matplotlib" page. Hints only: the matplotlib names are not accepted (but
|
|
3
|
+
those that are plot_io's own shorthands, ``settings.ALIASES``)."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Iterable
|
|
7
|
+
|
|
8
|
+
#: matplotlib keyword → the plot_io settings meaning the same, the first one the class has
|
|
9
|
+
#: is suggested ("width and height": both).
|
|
10
|
+
MATPLOTLIB: dict[str, tuple[str, ...]] = {
|
|
11
|
+
"lw": ("line_width", "edge_width", "border_width"),
|
|
12
|
+
"linewidth": ("line_width", "edge_width", "border_width"),
|
|
13
|
+
"linewidths": ("edge_width", "line_width"),
|
|
14
|
+
"ls": ("line_style",),
|
|
15
|
+
"linestyle": ("line_style",),
|
|
16
|
+
"c": ("color",),
|
|
17
|
+
"s": ("size",),
|
|
18
|
+
"ms": ("marker_size", "size"),
|
|
19
|
+
"markersize": ("marker_size", "size"),
|
|
20
|
+
"mec": ("marker_edge_color", "edge_color"),
|
|
21
|
+
"markeredgecolor": ("marker_edge_color", "edge_color"),
|
|
22
|
+
"mew": ("marker_edge_width", "edge_width"),
|
|
23
|
+
"markeredgewidth": ("marker_edge_width", "edge_width"),
|
|
24
|
+
"mfc": ("marker_color", "color"),
|
|
25
|
+
"markerfacecolor": ("marker_color", "color"),
|
|
26
|
+
"ec": ("edge_color", "border_color"),
|
|
27
|
+
"edgecolor": ("edge_color", "border_color"),
|
|
28
|
+
"edgecolors": ("edge_color",),
|
|
29
|
+
"fc": ("color",),
|
|
30
|
+
"facecolor": ("color",),
|
|
31
|
+
"facecolors": ("color",),
|
|
32
|
+
"ecolor": ("error_color", "color"),
|
|
33
|
+
"elinewidth": ("error_width", "line_width"),
|
|
34
|
+
"capsize": ("cap_length",),
|
|
35
|
+
"capstyle": ("cap",),
|
|
36
|
+
"joinstyle": ("join",),
|
|
37
|
+
"cmap": ("color_map",),
|
|
38
|
+
"palette": ("qualitative_map", "color_map"),
|
|
39
|
+
"vmin": ("color_limits",),
|
|
40
|
+
"vmax": ("color_limits",),
|
|
41
|
+
"zorder": ("z",),
|
|
42
|
+
"rasterized": ("rasterize",),
|
|
43
|
+
"fontsize": ("font_size",),
|
|
44
|
+
"fontweight": ("font_weight",),
|
|
45
|
+
"figsize": ("width and height",),
|
|
46
|
+
"dpi": ("default_dpi",),
|
|
47
|
+
"nrows": ("rows",),
|
|
48
|
+
"ncols": ("cols",),
|
|
49
|
+
"sharex": ("share_x",),
|
|
50
|
+
"sharey": ("share_y",),
|
|
51
|
+
"aspect": ("aspect_ratio", "data_ratio"),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#: matplotlib axis keyword suffix → the plot_io axis setting (``xlim`` → ``x_axis``'s
|
|
55
|
+
#: ``limits``).
|
|
56
|
+
AXIS = {"lim": "limits", "label": "label", "scale": "scale", "ticks": "ticks"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def axis_setting(name: str) -> str | None:
|
|
60
|
+
"""The plot_io form of the matplotlib axis keyword ``name`` (``xlim``:
|
|
61
|
+
``x_axis={"limits": …}``), or ``None``."""
|
|
62
|
+
m = re.fullmatch(r"([xy])(lim|label|scale|ticks)", name)
|
|
63
|
+
if m is None:
|
|
64
|
+
return None
|
|
65
|
+
axis, key = m.group(1), AXIS[m.group(2)]
|
|
66
|
+
return f'{axis}_axis={{"{key}": …}} on the plot, or plot.{axis}_axis({key}=…)'
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_TAB = ("blue", "orange", "green", "red", "purple", "brown", "pink", "gray", "olive", "cyan")
|
|
70
|
+
|
|
71
|
+
#: matplotlib color → the plot_io color.
|
|
72
|
+
COLORS: dict[str, str] = {
|
|
73
|
+
"k": "black", "w": "white", "r": "red", "g": "green", "b": "blue", "c": "cyan",
|
|
74
|
+
"m": "magenta", "y": "yellow",
|
|
75
|
+
**{f"tab:{n}": f'C{i} with qualitative_map="tab10"' for i, n in enumerate(_TAB)},
|
|
76
|
+
"tab:grey": 'C7 with qualitative_map="tab10"',
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def setting(name: str, candidates: Iterable[str]) -> str | None:
|
|
81
|
+
"""The plot_io form of the matplotlib keyword ``name`` among ``candidates``
|
|
82
|
+
(``"line_width="``), or ``None``."""
|
|
83
|
+
have = set(candidates)
|
|
84
|
+
for choice in MATPLOTLIB.get(name, ()):
|
|
85
|
+
names = choice.split(" and ")
|
|
86
|
+
if all(n in have for n in names):
|
|
87
|
+
return " and ".join(n + "=" for n in names)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _variant(key: str) -> str:
|
|
92
|
+
"""A hint for a color with a variant word (``"pastel navy"``, ``"red pastel"``)."""
|
|
93
|
+
from .color.variants import VARIANTS
|
|
94
|
+
words = key.split()
|
|
95
|
+
at = [i for i, w in enumerate(words) if w in VARIANTS]
|
|
96
|
+
if not at:
|
|
97
|
+
return ""
|
|
98
|
+
w = words[at[0]]
|
|
99
|
+
rest = " ".join(words[:at[0]] + words[at[0] + 1:])
|
|
100
|
+
if not rest:
|
|
101
|
+
return f" ({w!r} is a color variant: before a scale name, \"{w} red\", or as " \
|
|
102
|
+
f"color_variant=\"{w}\")"
|
|
103
|
+
if at[0] > 0:
|
|
104
|
+
return f" (the variant word comes first: \"{w} {rest}\")"
|
|
105
|
+
return f" (a variant applies to the named scales, \"{w} red\"; {rest!r} is not one)"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def color(v: Any) -> str:
|
|
109
|
+
"""A hint for the invalid color ``v`` (``" (matplotlib's 'k' is \"black\")"``), or ``""``."""
|
|
110
|
+
if isinstance(v, str):
|
|
111
|
+
key = v.strip().lower()
|
|
112
|
+
if key in COLORS:
|
|
113
|
+
want = COLORS[key]
|
|
114
|
+
want = want if " with " in want else f'"{want}"'
|
|
115
|
+
return f" (matplotlib's {v!r} is {want})"
|
|
116
|
+
variant = _variant(key)
|
|
117
|
+
if variant:
|
|
118
|
+
return variant
|
|
119
|
+
if re.fullmatch(r"0?\.\d+|[01](\.0*)?", key):
|
|
120
|
+
pct = f"{float(key) * 100:g}%"
|
|
121
|
+
return f" (matplotlib's {v!r} is a gray: \"rgb({pct} {pct} {pct})\")"
|
|
122
|
+
elif isinstance(v, (tuple, list)) and 3 <= len(v) <= 4 and all(
|
|
123
|
+
isinstance(x, (int, float)) for x in v) and any(x > 1 for x in v):
|
|
124
|
+
return ' (components are 0–1; or "rgb(255 0 0)")'
|
|
125
|
+
return ""
|