tablelab 0.5.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.
tablelab/charts.py ADDED
@@ -0,0 +1,242 @@
1
+ """Charts for one series: the run chart, and the other views worth a menu entry.
2
+
3
+ One series means one hue, so there is no legend - the title names the series and
4
+ any reference line is labelled where it sits. Everything that is not data (grid,
5
+ spines, ticks) stays recessive.
6
+
7
+ `grid` is three-valued rather than a flag, because "no grid" and "the run chart's
8
+ own grid" are different things: "y" draws the horizontal lines a run chart is
9
+ read against, True adds the vertical ones the app's grid checkbox asks for, and
10
+ False takes both away.
11
+ """
12
+ import matplotlib
13
+ import matplotlib.pyplot as plt
14
+ import numpy as np
15
+ import pandas as pd
16
+
17
+ SERIES = "#2a78d6" # the single categorical hue
18
+ FILL = "#bcd7f4" # the same hue, lightened, for areas rather than lines
19
+ INK = "#0b0b0b" # titles
20
+ MUTED = "#52514e" # axis labels, ticks, the centre line
21
+ GRID = "#e4e3df" # grid and spines
22
+
23
+ # Above this many points, per-point markers stop carrying information and only
24
+ # thicken the line.
25
+ MAX_MARKERS = 60
26
+
27
+ # The chart menu, in the order it is offered: (label, key).
28
+ KINDS = [("Run chart", "run"),
29
+ ("Sorted run chart", "sorted"),
30
+ ("Histogram", "histogram"),
31
+ ("Box plot", "box"),
32
+ ("ECDF", "ecdf")]
33
+
34
+ # Kinds a centre line means something for - a box plot already draws its median.
35
+ CENTRED = {"run", "sorted", "histogram", "ecdf"}
36
+
37
+ # matplotlib 3.10 replaced boxplot's `vert=False` with `orientation=`, and 3.13
38
+ # drops the old spelling. The package supports both sides of that change.
39
+ _HORIZONTAL = ({"orientation": "horizontal"}
40
+ if tuple(int(p) for p in matplotlib.__version__.split(".")[:2]) >= (3, 10)
41
+ else {"vert": False})
42
+
43
+
44
+ # -- the charts --------------------------------------------------------------
45
+ # All five take the same arguments so the app can dispatch on a menu value.
46
+ # `ylabel` names the series; the charts that put the series on the x axis
47
+ # (histogram, ECDF, box plot) use it there instead.
48
+
49
+ def run_chart(values, title="", xlabel="index", ylabel="value",
50
+ centre="median", ax=None, figsize=(10, 4), grid="y"):
51
+ """Plot `values` in order. Returns the Axes.
52
+
53
+ `centre` draws a reference line: "median" (the run chart convention), "mean",
54
+ or None. Pass `ax` to draw into an existing figure - the app reuses one.
55
+ """
56
+ values = _clean(values)
57
+ ax = _axes(ax, figsize)
58
+
59
+ marker = "o" if len(values) <= MAX_MARKERS else None
60
+ ax.plot(values.index, values.to_numpy(), color=SERIES,
61
+ linewidth=2, marker=marker, markersize=5)
62
+
63
+ _centre_line(ax, values, centre, vertical=False)
64
+ _frame(ax, title, xlabel, ylabel, grid)
65
+ return ax
66
+
67
+
68
+ def sorted_run_chart(values, title="", xlabel="index", ylabel="value",
69
+ centre="median", ax=None, figsize=(10, 4), grid="y"):
70
+ """The same values in ascending order.
71
+
72
+ Dropping the row order is the point: what is left is the range, the gaps and
73
+ how far the extremes sit from the body, none of which a run chart shows
74
+ plainly.
75
+ """
76
+ values = _clean(values).sort_values().reset_index(drop=True)
77
+ ax = _axes(ax, figsize)
78
+
79
+ marker = "o" if len(values) <= MAX_MARKERS else None
80
+ ax.plot(values.index, values.to_numpy(), color=SERIES,
81
+ linewidth=2, marker=marker, markersize=5)
82
+
83
+ _centre_line(ax, values, centre, vertical=False)
84
+ _frame(ax, title, "rank (sorted)", ylabel, grid)
85
+ return ax
86
+
87
+
88
+ def histogram(values, title="", xlabel="index", ylabel="value",
89
+ centre="median", ax=None, figsize=(10, 4), grid="y"):
90
+ """Where the values sit, with the order thrown away."""
91
+ values = _clean(values)
92
+ ax = _axes(ax, figsize)
93
+
94
+ ax.hist(values.to_numpy(), bins=_bin_count(values), color=FILL,
95
+ edgecolor=SERIES, linewidth=0.8)
96
+
97
+ _centre_line(ax, values, centre, vertical=True)
98
+ _frame(ax, title, ylabel, "count", grid)
99
+ return ax
100
+
101
+
102
+ def box_plot(values, title="", xlabel="index", ylabel="value",
103
+ centre="median", ax=None, figsize=(10, 4), grid="y"):
104
+ """Median, quartiles, whiskers and the points outside them.
105
+
106
+ `centre` is accepted and ignored - the box draws its own median, and a second
107
+ line over it would say the same thing twice.
108
+ """
109
+ values = _clean(values)
110
+ ax = _axes(ax, figsize)
111
+
112
+ ax.boxplot(values.to_numpy(), widths=0.5, patch_artist=True, **_HORIZONTAL,
113
+ boxprops={"facecolor": FILL, "edgecolor": SERIES},
114
+ medianprops={"color": INK, "linewidth": 1.5},
115
+ whiskerprops={"color": SERIES}, capprops={"color": SERIES},
116
+ flierprops={"marker": "o", "markersize": 4,
117
+ "markerfacecolor": "none", "markeredgecolor": MUTED})
118
+ ax.set_yticks([])
119
+
120
+ _frame(ax, title, ylabel, "", grid)
121
+ return ax
122
+
123
+
124
+ def ecdf(values, title="", xlabel="index", ylabel="value",
125
+ centre="median", ax=None, figsize=(10, 4), grid="y"):
126
+ """The proportion of values at or below each value.
127
+
128
+ Every point is drawn, so unlike a histogram there is no bin width to choose
129
+ and nothing is smoothed away.
130
+ """
131
+ values = _clean(values)
132
+ ax = _axes(ax, figsize)
133
+
134
+ ordered = np.sort(values.to_numpy())
135
+ ax.step(ordered, np.arange(1, len(ordered) + 1) / len(ordered),
136
+ where="post", color=SERIES, linewidth=2)
137
+ ax.set_ylim(0, 1.02)
138
+
139
+ _centre_line(ax, values, centre, vertical=True)
140
+ _frame(ax, title, ylabel, "cumulative proportion", grid)
141
+ return ax
142
+
143
+
144
+ _BY_KIND = {"run": run_chart, "sorted": sorted_run_chart, "histogram": histogram,
145
+ "box": box_plot, "ecdf": ecdf}
146
+
147
+
148
+ def draw(kind, values, **kwargs):
149
+ """Draw one of `KINDS` by its key. Returns the Axes."""
150
+ try:
151
+ chart = _BY_KIND[kind]
152
+ except KeyError:
153
+ raise ValueError(
154
+ f"no chart kind {kind!r}; expected one of {sorted(_BY_KIND)}") from None
155
+ return chart(values, **kwargs)
156
+
157
+
158
+ # -- grid --------------------------------------------------------------------
159
+
160
+ def set_grid(ax, grid="y"):
161
+ """Apply the three-valued `grid` to one Axes: True, False, or "y"/"x"."""
162
+ ax.grid(False) # first, and with no line kwargs, or matplotlib
163
+ if grid: # takes the kwargs alone as a reason to turn it on
164
+ ax.grid(True, axis="both" if grid is True else grid,
165
+ color=GRID, linewidth=1)
166
+ ax.set_axisbelow(True)
167
+
168
+
169
+ def apply_grid(fig, on):
170
+ """Toggle the grid across a figure that is already on screen.
171
+
172
+ Redrawing the chart instead would reset an interactive figure's zoom, which
173
+ is exactly what someone ticking the box mid-inspection does not want.
174
+ """
175
+ for ax in fig.axes:
176
+ set_grid(ax, True if on else "y")
177
+ fig.canvas.draw_idle()
178
+
179
+
180
+ # -- shared furniture --------------------------------------------------------
181
+
182
+ def _clean(values):
183
+ """As a float Series with a fresh index and no gaps - charts cannot plot NaN."""
184
+ return (pd.Series(values).astype(float).dropna().reset_index(drop=True))
185
+
186
+
187
+ def _axes(ax, figsize):
188
+ return ax if ax is not None else plt.subplots(figsize=figsize)[1]
189
+
190
+
191
+ def _centre_line(ax, values, centre, vertical):
192
+ """A dashed reference line at the median or mean, labelled where it sits.
193
+
194
+ A direct label beats a legend box for a single line.
195
+ """
196
+ if not centre or values.empty:
197
+ return
198
+ level = float(getattr(values, centre)())
199
+ text = f"{centre} {level:.3g}"
200
+ if vertical:
201
+ ax.axvline(level, color=MUTED, linewidth=1, linestyle="--", alpha=0.7)
202
+ ax.annotate(text, xy=(level, 1.0), xycoords=("data", "axes fraction"),
203
+ xytext=(0, 4), textcoords="offset points",
204
+ ha="center", fontsize=9, color=MUTED)
205
+ else:
206
+ ax.axhline(level, color=MUTED, linewidth=1, linestyle="--", alpha=0.7)
207
+ ax.annotate(text, xy=(1.0, level), xycoords=("axes fraction", "data"),
208
+ xytext=(4, 0), textcoords="offset points",
209
+ va="center", fontsize=9, color=MUTED)
210
+
211
+
212
+ def _frame(ax, title, xlabel, ylabel, grid):
213
+ ax.set_title(title, fontsize=13, color=INK, loc="left", pad=12)
214
+ ax.set_xlabel(xlabel, fontsize=10, color=MUTED)
215
+ ax.set_ylabel(ylabel, fontsize=10, color=MUTED)
216
+
217
+ set_grid(ax, grid)
218
+ for side in ("top", "right"):
219
+ ax.spines[side].set_visible(False)
220
+ for side in ("left", "bottom"):
221
+ ax.spines[side].set_color(GRID)
222
+ ax.tick_params(colors=MUTED, labelsize=9)
223
+
224
+ # Leave room on the right for a horizontal centre line's direct label.
225
+ ax.figure.tight_layout(rect=(0, 0, 0.88, 1))
226
+
227
+
228
+ def _bin_count(values):
229
+ """Freedman-Diaconis: bin width follows the spread, not just the count.
230
+
231
+ Falls back to sqrt(n) when the IQR is zero (a near-constant series), which
232
+ would otherwise ask for infinitely many bins.
233
+ """
234
+ v = values.to_numpy()
235
+ if len(v) < 2:
236
+ return 1
237
+ q1, q3 = np.percentile(v, [25, 75])
238
+ width = 2 * (q3 - q1) / len(v) ** (1 / 3)
239
+ span = v.max() - v.min()
240
+ if width <= 0 or span <= 0:
241
+ return max(int(np.sqrt(len(v))), 1)
242
+ return int(np.clip(np.ceil(span / width), 1, 100))