scistackplot 0.1.26__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.
- scistackplot/__init__.py +239 -0
- scistackplot/capability.py +656 -0
- scistackplot/codegen.py +886 -0
- scistackplot/groups.py +110 -0
- scistackplot/reduce.py +1475 -0
- scistackplot/render/__init__.py +26 -0
- scistackplot/render/base.py +262 -0
- scistackplot/render/mpl.py +458 -0
- scistackplot/render/plotly_.py +537 -0
- scistackplot/resolved.py +240 -0
- scistackplot/roles.py +413 -0
- scistackplot/shape.py +106 -0
- scistackplot/sources/__init__.py +7 -0
- scistackplot/sources/base.py +256 -0
- scistackplot/sources/csv.py +67 -0
- scistackplot/sources/frame.py +45 -0
- scistackplot/spec.py +698 -0
- scistackplot/table.py +328 -0
- scistackplot/variants.py +743 -0
- scistackplot/xaxis.py +183 -0
- scistackplot/ylimits.py +451 -0
- scistackplot-0.1.26.dist-info/METADATA +212 -0
- scistackplot-0.1.26.dist-info/RECORD +24 -0
- scistackplot-0.1.26.dist-info/WHEEL +4 -0
scistackplot/xaxis.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Composing a nested categorical x axis.
|
|
3
|
+
|
|
4
|
+
"Stim and sham side by side, each split by session" is one axis carrying two
|
|
5
|
+
factors. This module turns the observed combinations of those factors into a
|
|
6
|
+
flat sequence of **leaf positions** plus the **spans** the higher layers cover,
|
|
7
|
+
and nothing else — no frames, no colours, no rendering.
|
|
8
|
+
|
|
9
|
+
That purity is the point, and it is the same bargain
|
|
10
|
+
:func:`~scistackplot.reduce.plan_layout` makes for the facet grid: the two
|
|
11
|
+
renderers draw brackets from the spans rather than each deriving them, ``codegen``
|
|
12
|
+
emits the resolved order rather than replaying the rules, and the whole
|
|
13
|
+
arrangement is testable from label lists.
|
|
14
|
+
|
|
15
|
+
Separation between groups is **spacer categories** — unique labels with no data.
|
|
16
|
+
That is a deliberate choice over numeric offsets: it keeps the axis categorical,
|
|
17
|
+
so box, violin, bar and strip all position themselves exactly as they already do
|
|
18
|
+
in both backends. Numeric positions would give finer control over gap widths at
|
|
19
|
+
the cost of re-implementing every trace type's placement.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, Sequence
|
|
26
|
+
|
|
27
|
+
#: Joins a leaf's layer values into the key the frame is matched on. Chosen to
|
|
28
|
+
#: be unlikely in real level names; a level containing it still works, since the
|
|
29
|
+
#: key is only ever built and compared by this module, never parsed.
|
|
30
|
+
LEAF_SEPARATOR = "␟"
|
|
31
|
+
|
|
32
|
+
#: Prefix marking a spacer category. Spacers hold no data and draw no tick.
|
|
33
|
+
SPACER_PREFIX = " gap"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class XGroup:
|
|
38
|
+
"""One higher-layer label and the leaf positions it covers.
|
|
39
|
+
|
|
40
|
+
``start``/``end`` are inclusive indices into :attr:`XPlan.order`, which is
|
|
41
|
+
what lets a renderer centre a label over its group and draw a bracket
|
|
42
|
+
beneath it without knowing anything about the data.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
label: str
|
|
46
|
+
#: 0 is the outermost layer. The leaf layer never appears here — it is the
|
|
47
|
+
#: tick labels.
|
|
48
|
+
depth: int
|
|
49
|
+
start: int
|
|
50
|
+
end: int
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def centre(self) -> float:
|
|
54
|
+
return (self.start + self.end) / 2.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class XPlan:
|
|
59
|
+
"""A composed nested axis."""
|
|
60
|
+
|
|
61
|
+
#: Leaf keys in drawn order, spacers included. Positions are indices here.
|
|
62
|
+
order: list[str] = field(default_factory=list)
|
|
63
|
+
#: Tick label per entry; "" for a spacer.
|
|
64
|
+
tick_labels: list[str] = field(default_factory=list)
|
|
65
|
+
#: Spans of every layer above the leaf, outermost first.
|
|
66
|
+
groups: list[XGroup] = field(default_factory=list)
|
|
67
|
+
#: How many factors share the axis.
|
|
68
|
+
n_layers: int = 1
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def depth(self) -> int:
|
|
72
|
+
"""Label rows below the axis, excluding the tick labels themselves."""
|
|
73
|
+
return max((group.depth for group in self.groups), default=-1) + 1
|
|
74
|
+
|
|
75
|
+
def position_of(self, key: str) -> int | None:
|
|
76
|
+
try:
|
|
77
|
+
return self.order.index(key)
|
|
78
|
+
except ValueError:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def leaf_key(values: Sequence[Any]) -> str:
|
|
83
|
+
"""The key identifying one leaf position, from its layers' values."""
|
|
84
|
+
return LEAF_SEPARATOR.join(str(value) for value in values)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_spacer(key: str) -> bool:
|
|
88
|
+
return str(key).startswith(SPACER_PREFIX)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def plan_x_axis(
|
|
92
|
+
combinations: Sequence[Sequence[Any]],
|
|
93
|
+
layer_orders: Sequence[Sequence[Any]],
|
|
94
|
+
) -> XPlan:
|
|
95
|
+
"""Lay out the leaves for the observed ``combinations``.
|
|
96
|
+
|
|
97
|
+
``combinations`` are the layer-value tuples that actually occur in the
|
|
98
|
+
data — **not** the Cartesian product of the layers. Real designs are ragged
|
|
99
|
+
(a sham subject with no post session) and reserving a position for a
|
|
100
|
+
combination nobody ran leaves a hole in the axis that reads as missing data.
|
|
101
|
+
|
|
102
|
+
``layer_orders`` gives each layer's declared level order, so the axis obeys
|
|
103
|
+
the same ordering rule as everything else here: zero-padded IDs sort
|
|
104
|
+
``01, 02, … 10``, and a legend does not reshuffle when a filter removes a
|
|
105
|
+
level's last row.
|
|
106
|
+
|
|
107
|
+
Gaps scale with the boundary's depth — a change of outer group opens a wider
|
|
108
|
+
gap than a change of the layer just above the leaf — which is what makes
|
|
109
|
+
three levels of nesting readable rather than a uniform picket fence.
|
|
110
|
+
"""
|
|
111
|
+
layer_orders = [list(order) for order in layer_orders]
|
|
112
|
+
n_layers = max(1, len(layer_orders))
|
|
113
|
+
combos = [tuple(combo) for combo in combinations]
|
|
114
|
+
if not combos:
|
|
115
|
+
return XPlan(n_layers=n_layers)
|
|
116
|
+
|
|
117
|
+
ranks = [
|
|
118
|
+
{str(level): position for position, level in enumerate(order)}
|
|
119
|
+
for order in layer_orders
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
def sort_key(combo: tuple) -> tuple:
|
|
123
|
+
key: list = []
|
|
124
|
+
for depth, value in enumerate(combo):
|
|
125
|
+
lookup = ranks[depth] if depth < len(ranks) else {}
|
|
126
|
+
# Levels the declared order never mentioned sort after the ones it
|
|
127
|
+
# did, in a stable way — never dropped.
|
|
128
|
+
key.extend(
|
|
129
|
+
(0, lookup[str(value)])
|
|
130
|
+
if str(value) in lookup
|
|
131
|
+
else (1, str(value))
|
|
132
|
+
)
|
|
133
|
+
return tuple(key)
|
|
134
|
+
|
|
135
|
+
ordered = sorted(dict.fromkeys(combos), key=sort_key)
|
|
136
|
+
|
|
137
|
+
order: list[str] = []
|
|
138
|
+
tick_labels: list[str] = []
|
|
139
|
+
# Where each group currently being accumulated started, keyed by depth.
|
|
140
|
+
open_groups: dict[int, tuple[str, int]] = {}
|
|
141
|
+
groups: list[XGroup] = []
|
|
142
|
+
spacer_count = 0
|
|
143
|
+
previous: tuple | None = None
|
|
144
|
+
|
|
145
|
+
for combo in ordered:
|
|
146
|
+
prefix = combo[:-1]
|
|
147
|
+
if previous is not None:
|
|
148
|
+
changed = [
|
|
149
|
+
depth
|
|
150
|
+
for depth in range(len(prefix))
|
|
151
|
+
if str(prefix[depth]) != str(previous[depth])
|
|
152
|
+
]
|
|
153
|
+
if changed:
|
|
154
|
+
# Close every group at or below the shallowest change, then open
|
|
155
|
+
# fresh ones. One spacer per closed layer: an outer boundary
|
|
156
|
+
# closes more layers, so its gap is wider.
|
|
157
|
+
shallowest = min(changed)
|
|
158
|
+
for depth in sorted(open_groups, reverse=True):
|
|
159
|
+
if depth >= shallowest:
|
|
160
|
+
label, start = open_groups.pop(depth)
|
|
161
|
+
groups.append(
|
|
162
|
+
XGroup(label, depth, start, len(order) - 1)
|
|
163
|
+
)
|
|
164
|
+
for _ in range(len(prefix) - shallowest):
|
|
165
|
+
order.append(f"{SPACER_PREFIX}{spacer_count}")
|
|
166
|
+
tick_labels.append("")
|
|
167
|
+
spacer_count += 1
|
|
168
|
+
|
|
169
|
+
for depth, value in enumerate(prefix):
|
|
170
|
+
if depth not in open_groups:
|
|
171
|
+
open_groups[depth] = (str(value), len(order))
|
|
172
|
+
|
|
173
|
+
order.append(leaf_key(combo))
|
|
174
|
+
tick_labels.append(str(combo[-1]))
|
|
175
|
+
previous = combo
|
|
176
|
+
|
|
177
|
+
for depth, (label, start) in open_groups.items():
|
|
178
|
+
groups.append(XGroup(label, depth, start, len(order) - 1))
|
|
179
|
+
|
|
180
|
+
groups.sort(key=lambda group: (group.depth, group.start))
|
|
181
|
+
return XPlan(
|
|
182
|
+
order=order, tick_labels=tick_labels, groups=groups, n_layers=n_layers
|
|
183
|
+
)
|
scistackplot/ylimits.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Y-axis limits, split by a chosen set of factors.
|
|
3
|
+
|
|
4
|
+
The question this answers is not "what is the range of this panel" — that is
|
|
5
|
+
easy, and it is what every panel already knew. It is **"what range should this
|
|
6
|
+
panel share with which other panels"**, and that is a property of the whole
|
|
7
|
+
figure set rather than of any one figure.
|
|
8
|
+
|
|
9
|
+
Which makes it awkward, because the interactive path deliberately builds one
|
|
10
|
+
figure at a time (``reduce.resolve_one``): a scope of ``[]`` — one range for
|
|
11
|
+
every panel of every figure — needs data from figures nobody has asked for.
|
|
12
|
+
Building them to find out would cost exactly what Stage 3 just removed.
|
|
13
|
+
|
|
14
|
+
So the limits are computed **off the table, not off the figures**, before any
|
|
15
|
+
reduction happens, in one pass:
|
|
16
|
+
|
|
17
|
+
* the drawn extent of a line/scatter/box/violin is the raw extent of the data,
|
|
18
|
+
so for those it is a min/max over the unexploded arrays — 48 rows of numpy
|
|
19
|
+
work for a figure set that would otherwise be 17 million;
|
|
20
|
+
* a band or a bar draws ``centre ± spread``, which genuinely needs the
|
|
21
|
+
reduction — but only its extremes, so it is one groupby on the sample index,
|
|
22
|
+
with no panel frames, no series keys and no sorting.
|
|
23
|
+
|
|
24
|
+
Both cover the whole fan-out at once, which is the point: paging through thirty
|
|
25
|
+
subjects must not recompute anything.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Any, Iterable
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
import pandas as pd
|
|
34
|
+
from scistacklog import Log
|
|
35
|
+
|
|
36
|
+
from .shape import Shape
|
|
37
|
+
from .spec import ErrorBand, PlotKind, PlotSpec, Role, Statistic
|
|
38
|
+
from .table import LongTable
|
|
39
|
+
|
|
40
|
+
LAYER = "scistackplot"
|
|
41
|
+
|
|
42
|
+
#: Fraction of the data range left as breathing room at each end. Matches what
|
|
43
|
+
#: ``reduce._shared_limits`` has always done, so switching to a scope does not
|
|
44
|
+
#: silently re-pad every existing figure.
|
|
45
|
+
PAD_FRACTION = 0.05
|
|
46
|
+
|
|
47
|
+
#: The key of the single group when nothing separates the limits.
|
|
48
|
+
GLOBAL_KEY: tuple = ()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def eligible_scope(
|
|
52
|
+
scope: Iterable[str],
|
|
53
|
+
roles: dict[str, Role],
|
|
54
|
+
table: LongTable,
|
|
55
|
+
) -> list[str]:
|
|
56
|
+
"""The requested scope, less anything that cannot separate a y axis.
|
|
57
|
+
|
|
58
|
+
Only ITERATE (one figure per level) and FACET (one panel per level) split
|
|
59
|
+
panels apart. A COLOR or FREE factor lives *inside* a panel, so asking to
|
|
60
|
+
separate limits by it asks one axis for two ranges — there is no figure that
|
|
61
|
+
could satisfy it.
|
|
62
|
+
|
|
63
|
+
Dropped entries are logged rather than raised on: a scope is a user's
|
|
64
|
+
checkbox state, and a factor that was FACET a moment ago and is COLOR now
|
|
65
|
+
should quietly stop separating limits instead of replacing the figure with
|
|
66
|
+
an error. The GUI offers only eligible factors anyway; this is the guard for
|
|
67
|
+
a spec that arrives from TOML, from an older session, or from a role the
|
|
68
|
+
user has since changed.
|
|
69
|
+
"""
|
|
70
|
+
wanted = list(dict.fromkeys(scope))
|
|
71
|
+
if not wanted:
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
allowed = {
|
|
75
|
+
name
|
|
76
|
+
for name, role in roles.items()
|
|
77
|
+
if role in (Role.ITERATE, Role.FACET) and table.has_factor(name)
|
|
78
|
+
}
|
|
79
|
+
kept = [name for name in wanted if name in allowed]
|
|
80
|
+
|
|
81
|
+
dropped = [name for name in wanted if name not in allowed]
|
|
82
|
+
if dropped:
|
|
83
|
+
Log.warn(
|
|
84
|
+
"y-limit scope ignores %s: only factors that separate PANELS "
|
|
85
|
+
"(iterate, facet) can separate y limits — %s. Limits will be shared "
|
|
86
|
+
"across %s.",
|
|
87
|
+
dropped,
|
|
88
|
+
{name: str(roles.get(name, "no role")) for name in dropped},
|
|
89
|
+
dropped,
|
|
90
|
+
layer=LAYER,
|
|
91
|
+
)
|
|
92
|
+
return kept
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def limits_by_scope(
|
|
96
|
+
table: LongTable,
|
|
97
|
+
spec: PlotSpec,
|
|
98
|
+
scope: list[str],
|
|
99
|
+
) -> dict[tuple, tuple[float, float]]:
|
|
100
|
+
"""``{scope values: (low, high)}`` for every group the scope names.
|
|
101
|
+
|
|
102
|
+
``scope`` must already be :func:`eligible_scope`-filtered. An empty scope
|
|
103
|
+
returns exactly one entry, keyed ``()`` — one range for the whole dataset.
|
|
104
|
+
|
|
105
|
+
The frame is whatever the caller hands over, which for the resolve path is
|
|
106
|
+
the **post-variant, post-filter** table: limits must describe what will be
|
|
107
|
+
drawn, so a variant selection that removes half the data has to move them.
|
|
108
|
+
"""
|
|
109
|
+
frame = table.frame
|
|
110
|
+
measure = spec.y_measure
|
|
111
|
+
if measure not in frame.columns or frame.empty:
|
|
112
|
+
return {}
|
|
113
|
+
|
|
114
|
+
present = [name for name in scope if name in frame.columns]
|
|
115
|
+
if _needs_reduction(spec):
|
|
116
|
+
extents = _aggregated_extents(frame, spec, table, present)
|
|
117
|
+
else:
|
|
118
|
+
extents = _raw_extents(frame, measure, present)
|
|
119
|
+
|
|
120
|
+
limits = {key: _padded(low, high) for key, (low, high) in extents.items()}
|
|
121
|
+
Log.debug(
|
|
122
|
+
"y limits over %s: %d group(s)",
|
|
123
|
+
present or "the whole dataset",
|
|
124
|
+
len(limits),
|
|
125
|
+
layer=LAYER,
|
|
126
|
+
)
|
|
127
|
+
return limits
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def limits_for(
|
|
131
|
+
limits: dict[tuple, tuple[float, float]],
|
|
132
|
+
key: dict[str, Any],
|
|
133
|
+
scope: list[str],
|
|
134
|
+
y_axis,
|
|
135
|
+
) -> tuple[float, float] | None:
|
|
136
|
+
"""The limits one panel draws: its group's range, with overrides applied.
|
|
137
|
+
|
|
138
|
+
``key`` is everything identifying the panel — its figure's ITERATE values
|
|
139
|
+
merged with its own FACET values — and the scope picks the part that
|
|
140
|
+
matters. A panel whose group is missing (a combination the data does not
|
|
141
|
+
hold) falls back to the global range if there is one, because a panel with
|
|
142
|
+
no limits at all silently autoscales and would be the one panel on the page
|
|
143
|
+
that cannot be compared with the others.
|
|
144
|
+
|
|
145
|
+
An override end always wins; both ends override means the data is never
|
|
146
|
+
consulted, which is what lets a manual range be set on an empty figure.
|
|
147
|
+
"""
|
|
148
|
+
if y_axis.is_manual:
|
|
149
|
+
# _ordered here too: BOTH ends typed by hand is exactly the case where a
|
|
150
|
+
# swapped pair reaches an axis untouched, because this path never
|
|
151
|
+
# consults the data and so never passed through the ordering below.
|
|
152
|
+
return _ordered(float(y_axis.minimum), float(y_axis.maximum))
|
|
153
|
+
|
|
154
|
+
group = tuple(key.get(name) for name in scope)
|
|
155
|
+
found = limits.get(group)
|
|
156
|
+
if found is None and scope:
|
|
157
|
+
found = limits.get(GLOBAL_KEY)
|
|
158
|
+
if found is None:
|
|
159
|
+
# Nothing computed and no global fallback: with one end pinned there is
|
|
160
|
+
# still nothing to pin it against, so let the renderer autoscale rather
|
|
161
|
+
# than invent the other end.
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
low, high = found
|
|
165
|
+
if y_axis.minimum is not None:
|
|
166
|
+
low = float(y_axis.minimum)
|
|
167
|
+
if y_axis.maximum is not None:
|
|
168
|
+
high = float(y_axis.maximum)
|
|
169
|
+
return _ordered(low, high)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def describe(scope: list[str], y_axis) -> str:
|
|
173
|
+
"""One line naming the rule, for the panel and the log.
|
|
174
|
+
|
|
175
|
+
A number on an axis that cannot be traced to a rule is indistinguishable
|
|
176
|
+
from a bug — "why is this 0.61?" has to have an answer the user can read.
|
|
177
|
+
"""
|
|
178
|
+
if y_axis.is_manual:
|
|
179
|
+
return "set by hand"
|
|
180
|
+
rule = "the same everywhere" if not scope else f"per {', '.join(scope)}"
|
|
181
|
+
if y_axis.minimum is not None:
|
|
182
|
+
rule += ", floor set by hand"
|
|
183
|
+
if y_axis.maximum is not None:
|
|
184
|
+
rule += ", ceiling set by hand"
|
|
185
|
+
return rule
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Extents
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _needs_reduction(spec: PlotSpec) -> bool:
|
|
194
|
+
"""Whether the drawn extent differs from the raw extent.
|
|
195
|
+
|
|
196
|
+
Only for the kinds that draw a summary: a band or a bar draws
|
|
197
|
+
``centre ± spread``, which can sit outside the data (a mean plus an SD) and
|
|
198
|
+
inside it (a mean of anything). Everything else draws the observations, or
|
|
199
|
+
box statistics that lie within them.
|
|
200
|
+
"""
|
|
201
|
+
return spec.kind in (PlotKind.BAND, PlotKind.BAR) and (
|
|
202
|
+
spec.aggregate.error is not ErrorBand.NONE
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _raw_extents(
|
|
207
|
+
frame: pd.DataFrame, measure: str, scope: list[str]
|
|
208
|
+
) -> dict[tuple, tuple[float, float]]:
|
|
209
|
+
"""Min/max per group, **without exploding anything**.
|
|
210
|
+
|
|
211
|
+
A 1-D measure is one array per cell, and the extent of a group of arrays is
|
|
212
|
+
the extent of their per-cell extents. Computing that cell by cell is 24
|
|
213
|
+
numpy reductions over arrays that already exist, where exploding first would
|
|
214
|
+
build 8.9 million rows to answer the same question.
|
|
215
|
+
"""
|
|
216
|
+
values = frame[measure]
|
|
217
|
+
lows, highs = _cell_extents(values)
|
|
218
|
+
|
|
219
|
+
usable = ~(np.isnan(lows) | np.isnan(highs))
|
|
220
|
+
if not usable.any():
|
|
221
|
+
return {}
|
|
222
|
+
|
|
223
|
+
if not scope:
|
|
224
|
+
return {GLOBAL_KEY: (float(lows[usable].min()), float(highs[usable].max()))}
|
|
225
|
+
|
|
226
|
+
keys = _scope_keys(frame, scope)
|
|
227
|
+
extents: dict[tuple, tuple[float, float]] = {}
|
|
228
|
+
for key, low, high, ok in zip(keys, lows, highs, usable, strict=True):
|
|
229
|
+
if not ok:
|
|
230
|
+
continue
|
|
231
|
+
seen = extents.get(key)
|
|
232
|
+
extents[key] = (
|
|
233
|
+
(low, high) if seen is None else (min(seen[0], low), max(seen[1], high))
|
|
234
|
+
)
|
|
235
|
+
# The global entry always exists as a fallback for a panel whose own group
|
|
236
|
+
# has no data (`limits_for`), and it is free: these are the same numbers.
|
|
237
|
+
if extents:
|
|
238
|
+
extents[GLOBAL_KEY] = (
|
|
239
|
+
min(low for low, _ in extents.values()),
|
|
240
|
+
max(high for _, high in extents.values()),
|
|
241
|
+
)
|
|
242
|
+
return extents
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _cell_extents(values: pd.Series) -> tuple[np.ndarray, np.ndarray]:
|
|
246
|
+
"""Per-row (min, max) of a measure column that may hold scalars or arrays."""
|
|
247
|
+
numeric = pd.to_numeric(values, errors="coerce")
|
|
248
|
+
if not numeric.isna().all():
|
|
249
|
+
# A plain scalar column: its own values are the extents, and this is one
|
|
250
|
+
# vectorized cast rather than a Python loop.
|
|
251
|
+
column = numeric.to_numpy(dtype=float, na_value=np.nan)
|
|
252
|
+
return column, column
|
|
253
|
+
|
|
254
|
+
lows = np.full(len(values), np.nan)
|
|
255
|
+
highs = np.full(len(values), np.nan)
|
|
256
|
+
for position, value in enumerate(values.to_numpy()):
|
|
257
|
+
array = _as_array(value)
|
|
258
|
+
# An all-NaN cell stays NaN rather than going through nanmin, which
|
|
259
|
+
# warns and returns NaN anyway — the caller drops it either way, and a
|
|
260
|
+
# RuntimeWarning per empty trial is noise in a real run.
|
|
261
|
+
if array is None or not array.size or np.isnan(array).all():
|
|
262
|
+
continue
|
|
263
|
+
lows[position] = np.nanmin(array)
|
|
264
|
+
highs[position] = np.nanmax(array)
|
|
265
|
+
return lows, highs
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _as_array(value: Any) -> np.ndarray | None:
|
|
269
|
+
"""One cell as floats, or None when it holds nothing numeric."""
|
|
270
|
+
if value is None or isinstance(value, (str, bytes)):
|
|
271
|
+
return None
|
|
272
|
+
if isinstance(value, (list, tuple, np.ndarray)):
|
|
273
|
+
try:
|
|
274
|
+
return np.asarray(value, dtype="float64")
|
|
275
|
+
except (TypeError, ValueError):
|
|
276
|
+
return None
|
|
277
|
+
try:
|
|
278
|
+
return np.asarray([float(value)])
|
|
279
|
+
except (TypeError, ValueError):
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _aggregated_extents(
|
|
284
|
+
frame: pd.DataFrame,
|
|
285
|
+
spec: PlotSpec,
|
|
286
|
+
table: LongTable,
|
|
287
|
+
scope: list[str],
|
|
288
|
+
) -> dict[tuple, tuple[float, float]]:
|
|
289
|
+
"""Extents of ``centre ± spread`` — the band a BAND/BAR actually draws.
|
|
290
|
+
|
|
291
|
+
This is the expensive branch and it is deliberately narrow: it reduces to
|
|
292
|
+
the same statistic ``reduce._summarize`` does, grouped by everything that
|
|
293
|
+
separates an x position, and then takes extremes. No panel frames, no series
|
|
294
|
+
keys, no sorting, and one pass for the whole fan-out rather than one per
|
|
295
|
+
figure.
|
|
296
|
+
"""
|
|
297
|
+
measure = spec.y_measure
|
|
298
|
+
working = frame
|
|
299
|
+
index_column = spec.index_column or table.index_column
|
|
300
|
+
|
|
301
|
+
if table.shape_of(measure) is Shape.SERIES_1D and not table.measure(measure).exploded:
|
|
302
|
+
working, index_column = _explode_for_limits(frame, measure, index_column)
|
|
303
|
+
|
|
304
|
+
# What separates one drawn point from another: the scope (which separates
|
|
305
|
+
# panels), plus the x position and the colour within a panel.
|
|
306
|
+
grouping = [
|
|
307
|
+
name
|
|
308
|
+
for name in dict.fromkeys(
|
|
309
|
+
[
|
|
310
|
+
*scope,
|
|
311
|
+
*[n for n, r in spec.roles.items() if r in (Role.X, Role.COLOR)],
|
|
312
|
+
*( [index_column] if index_column else [] ),
|
|
313
|
+
]
|
|
314
|
+
)
|
|
315
|
+
if name in working.columns
|
|
316
|
+
]
|
|
317
|
+
values = pd.to_numeric(working[measure], errors="coerce")
|
|
318
|
+
working = working.assign(**{measure: values}).dropna(subset=[measure])
|
|
319
|
+
if working.empty:
|
|
320
|
+
return {}
|
|
321
|
+
if not grouping:
|
|
322
|
+
centre, low, high = _summary_bounds(working[measure], spec)
|
|
323
|
+
return {GLOBAL_KEY: (float(min(low, centre)), float(max(high, centre)))}
|
|
324
|
+
|
|
325
|
+
grouped = working.groupby(grouping, dropna=False, sort=False)[measure]
|
|
326
|
+
centre = grouped.median() if spec.aggregate.statistic is Statistic.MEDIAN else grouped.mean()
|
|
327
|
+
low, high = _spread(grouped, centre, spec)
|
|
328
|
+
|
|
329
|
+
bounds = pd.DataFrame({"low": low, "high": high}).reset_index()
|
|
330
|
+
if not scope:
|
|
331
|
+
return {GLOBAL_KEY: (float(bounds["low"].min()), float(bounds["high"].max()))}
|
|
332
|
+
|
|
333
|
+
by_scope = bounds.groupby(scope, dropna=False, sort=False)
|
|
334
|
+
extents = {
|
|
335
|
+
_as_key(key): (float(part["low"].min()), float(part["high"].max()))
|
|
336
|
+
for key, part in by_scope
|
|
337
|
+
}
|
|
338
|
+
if extents:
|
|
339
|
+
extents[GLOBAL_KEY] = (
|
|
340
|
+
min(low for low, _ in extents.values()),
|
|
341
|
+
max(high for _, high in extents.values()),
|
|
342
|
+
)
|
|
343
|
+
return extents
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _explode_for_limits(
|
|
347
|
+
frame: pd.DataFrame, measure: str, index_column: str | None
|
|
348
|
+
) -> tuple[pd.DataFrame, str]:
|
|
349
|
+
"""A minimal explode: the measure, the index, and the factors — nothing else.
|
|
350
|
+
|
|
351
|
+
Deliberately not ``reduce._explode_1d``: that one logs at INFO (it is the
|
|
352
|
+
headline cost of a resolve) and raises when the index column already exists.
|
|
353
|
+
Here an existing index column just means the caller pre-exploded.
|
|
354
|
+
"""
|
|
355
|
+
column = index_column or "index"
|
|
356
|
+
if column in frame.columns:
|
|
357
|
+
return frame, column
|
|
358
|
+
working = frame.copy()
|
|
359
|
+
working[column] = working[measure].map(
|
|
360
|
+
lambda v: list(range(len(v))) if isinstance(v, (list, tuple, np.ndarray)) else []
|
|
361
|
+
)
|
|
362
|
+
return working.explode([measure, column], ignore_index=True), column
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _spread(grouped, centre, spec: PlotSpec):
|
|
366
|
+
"""``(low, high)`` per group — the same definitions ``reduce._summarize`` uses.
|
|
367
|
+
|
|
368
|
+
Kept deliberately parallel to that function: two definitions of an error
|
|
369
|
+
band would put the limits and the drawing at odds, and the symptom would be
|
|
370
|
+
a band clipped by its own axis.
|
|
371
|
+
"""
|
|
372
|
+
error = spec.aggregate.error
|
|
373
|
+
if error is ErrorBand.IQR:
|
|
374
|
+
return grouped.quantile(0.25), grouped.quantile(0.75)
|
|
375
|
+
sd = grouped.std(ddof=1).fillna(0.0)
|
|
376
|
+
count = grouped.count()
|
|
377
|
+
if error is ErrorBand.SD:
|
|
378
|
+
spread = sd
|
|
379
|
+
elif error is ErrorBand.SEM:
|
|
380
|
+
spread = sd / np.sqrt(count.where(count > 0, 1))
|
|
381
|
+
else: # CI95
|
|
382
|
+
spread = 1.96 * sd / np.sqrt(count.where(count > 0, 1))
|
|
383
|
+
return centre - spread, centre + spread
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _summary_bounds(values: pd.Series, spec: PlotSpec):
|
|
387
|
+
"""The ungrouped case: one centre and one band over everything."""
|
|
388
|
+
centre = (
|
|
389
|
+
values.median() if spec.aggregate.statistic is Statistic.MEDIAN else values.mean()
|
|
390
|
+
)
|
|
391
|
+
error = spec.aggregate.error
|
|
392
|
+
if error is ErrorBand.IQR:
|
|
393
|
+
return centre, values.quantile(0.25), values.quantile(0.75)
|
|
394
|
+
sd = values.std(ddof=1)
|
|
395
|
+
sd = 0.0 if pd.isna(sd) else sd
|
|
396
|
+
count = max(len(values), 1)
|
|
397
|
+
if error is ErrorBand.SD:
|
|
398
|
+
spread = sd
|
|
399
|
+
elif error is ErrorBand.SEM:
|
|
400
|
+
spread = sd / np.sqrt(count)
|
|
401
|
+
else:
|
|
402
|
+
spread = 1.96 * sd / np.sqrt(count)
|
|
403
|
+
return centre, centre - spread, centre + spread
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
# Keys and padding
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _scope_keys(frame: pd.DataFrame, scope: list[str]) -> list[tuple]:
|
|
412
|
+
"""One key tuple per row, matching what a panel's ``key`` will produce."""
|
|
413
|
+
columns = [frame[name].to_numpy() for name in scope]
|
|
414
|
+
return list(zip(*columns, strict=True)) if len(columns) > 1 else [
|
|
415
|
+
(value,) for value in columns[0]
|
|
416
|
+
]
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _as_key(key: Any) -> tuple:
|
|
420
|
+
return key if isinstance(key, tuple) else (key,)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _ordered(low: float, high: float) -> tuple[float, float]:
|
|
424
|
+
"""Bounds in the order an axis wants them.
|
|
425
|
+
|
|
426
|
+
A hand-typed minimum above the computed maximum is a typo, not an inverted
|
|
427
|
+
axis: matplotlib would silently flip the axis and the figure would read
|
|
428
|
+
upside down with nothing to say why.
|
|
429
|
+
|
|
430
|
+
Swapping is reported rather than done quietly — the user typed one of those
|
|
431
|
+
two numbers and the figure is about to disagree with it, so the log is the
|
|
432
|
+
only place that can say which way round the axis actually ended up.
|
|
433
|
+
"""
|
|
434
|
+
if low <= high:
|
|
435
|
+
return (low, high)
|
|
436
|
+
Log.warn(
|
|
437
|
+
"y limits arrived inverted (%s above %s) — drawing them the other way "
|
|
438
|
+
"round rather than flipping the axis",
|
|
439
|
+
low,
|
|
440
|
+
high,
|
|
441
|
+
layer=LAYER,
|
|
442
|
+
)
|
|
443
|
+
return (high, low)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _padded(low: float, high: float) -> tuple[float, float]:
|
|
447
|
+
if low == high:
|
|
448
|
+
pad = abs(low) * PAD_FRACTION or 1.0
|
|
449
|
+
return (low - pad, high + pad)
|
|
450
|
+
pad = (high - low) * PAD_FRACTION
|
|
451
|
+
return (low - pad, high + pad)
|