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/__init__.py +72 -0
- tablelab/__main__.py +64 -0
- tablelab/app.py +431 -0
- tablelab/charts.py +242 -0
- tablelab/code_export.py +545 -0
- tablelab/column_inspector.py +303 -0
- tablelab/figures.py +101 -0
- tablelab/file_readers.py +129 -0
- tablelab/filepicker.py +195 -0
- tablelab/helpers/__init__.py +50 -0
- tablelab/helpers/data.py +30 -0
- tablelab/helpers/plotting.py +31 -0
- tablelab/helpers/widgets.py +5 -0
- tablelab/missing_values.py +97 -0
- tablelab/paged_preview.py +152 -0
- tablelab/pipeline.py +202 -0
- tablelab/query_app.py +1343 -0
- tablelab/query_expressions.py +91 -0
- tablelab/table.py +128 -0
- tablelab-0.5.0.dist-info/METADATA +465 -0
- tablelab-0.5.0.dist-info/RECORD +24 -0
- tablelab-0.5.0.dist-info/WHEEL +4 -0
- tablelab-0.5.0.dist-info/entry_points.txt +2 -0
- tablelab-0.5.0.dist-info/licenses/LICENSE +21 -0
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))
|