dataframe-toolkit 0.4.1__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.
- dataframe_toolkit-0.4.1.dist-info/METADATA +230 -0
- dataframe_toolkit-0.4.1.dist-info/RECORD +42 -0
- dataframe_toolkit-0.4.1.dist-info/WHEEL +4 -0
- dataframe_toolkit-0.4.1.dist-info/entry_points.txt +3 -0
- dftk/__init__.py +4 -0
- dftk/cli.py +72 -0
- dftk/commands/__init__.py +100 -0
- dftk/commands/annotate_cmd.py +165 -0
- dftk/commands/base.py +85 -0
- dftk/commands/binx_cmd.py +130 -0
- dftk/commands/clean_cmd.py +51 -0
- dftk/commands/concat_cmd.py +165 -0
- dftk/commands/corr_cmd.py +281 -0
- dftk/commands/dataset_cmd.py +181 -0
- dftk/commands/describe_cmd.py +360 -0
- dftk/commands/eval_cmd.py +657 -0
- dftk/commands/fit_cmd.py +265 -0
- dftk/commands/func_cmd.py +186 -0
- dftk/commands/help.py +63 -0
- dftk/commands/hist_cmd.py +321 -0
- dftk/commands/interp_cmd.py +271 -0
- dftk/commands/line_cmd.py +320 -0
- dftk/commands/melt_cmd.py +78 -0
- dftk/commands/merge_cmd.py +373 -0
- dftk/commands/pivot_cmd.py +438 -0
- dftk/commands/print_cmd.py +36 -0
- dftk/commands/query_cmd.py +230 -0
- dftk/commands/randvar_cmd.py +199 -0
- dftk/commands/sample_cmd.py +126 -0
- dftk/commands/scale_cmd.py +299 -0
- dftk/commands/scat_cmd.py +278 -0
- dftk/commands/split_cmd.py +130 -0
- dftk/commands/stat_cmd.py +289 -0
- dftk/commands/test_cmd.py +235 -0
- dftk/commands/wstat_cmd.py +163 -0
- dftk/common/__init__.py +8 -0
- dftk/common/io.py +690 -0
- dftk/common/plot.py +566 -0
- dftk/common/seed.py +25 -0
- dftk/common/seq.py +150 -0
- dftk/common/stats.py +214 -0
- dftk/io.py +10 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dftk.commands.scat_cmd — dftk scat subcommand.
|
|
3
|
+
|
|
4
|
+
Scatter plot with optional OLS / robust regression line overlay.
|
|
5
|
+
|
|
6
|
+
Port of dfscat.py with improvements:
|
|
7
|
+
- --groupcol colour-codes points by a column
|
|
8
|
+
- --subgraphcol splits into a subplot grid
|
|
9
|
+
- --size / --fontsize publication-quality figure presets
|
|
10
|
+
- --fit overlays an OLS (or robust) regression line via fit_cmd.regress_it
|
|
11
|
+
- --sizecol point size from a data column (was --area)
|
|
12
|
+
- --colorcol continuous colour mapping from a data column (was --color)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
import matplotlib.lines as mlines
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from dftk.commands.base import BaseCommand
|
|
22
|
+
from dftk.common.io import check_cols, io
|
|
23
|
+
from dftk.common.plot import (
|
|
24
|
+
add_figure_arguments,
|
|
25
|
+
add_group_arguments,
|
|
26
|
+
add_legend_arguments,
|
|
27
|
+
add_xy_arguments,
|
|
28
|
+
apply_labels,
|
|
29
|
+
apply_limits,
|
|
30
|
+
apply_style,
|
|
31
|
+
apply_ticks,
|
|
32
|
+
make_figure,
|
|
33
|
+
make_grouplabel,
|
|
34
|
+
save_or_show,
|
|
35
|
+
subgraph_groups,
|
|
36
|
+
subgraph_layout,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Fit overlay
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _fit_overlay(ax, x_series, y_series, args):
|
|
45
|
+
"""Fit a regression line and overlay it on ax. Prints summary to stdout."""
|
|
46
|
+
import pandas as pd
|
|
47
|
+
|
|
48
|
+
from dftk.commands.fit_cmd import regress_it
|
|
49
|
+
|
|
50
|
+
df_fit = pd.DataFrame({"_x": x_series.values, "_y": y_series.values}).dropna()
|
|
51
|
+
fit_args = argparse.Namespace(
|
|
52
|
+
formula="_y ~ _x" if not args.nointercept else "_y ~ _x - 1",
|
|
53
|
+
weights=None,
|
|
54
|
+
robust=getattr(args, "robust", False),
|
|
55
|
+
)
|
|
56
|
+
res = regress_it(df_fit, fit_args)
|
|
57
|
+
print(res.summary(), file=sys.stderr)
|
|
58
|
+
|
|
59
|
+
xmin, xmax = ax.get_xlim()
|
|
60
|
+
X_plot = np.linspace(xmin, xmax, 200)
|
|
61
|
+
|
|
62
|
+
if args.nointercept:
|
|
63
|
+
slope = res.params["_x"]
|
|
64
|
+
Y_plot = slope * X_plot
|
|
65
|
+
label_text = f"{slope:+.3g} × {args.xcol}"
|
|
66
|
+
else:
|
|
67
|
+
intercept = res.params["Intercept"]
|
|
68
|
+
slope = res.params["_x"]
|
|
69
|
+
Y_plot = intercept + slope * X_plot
|
|
70
|
+
label_text = f"{intercept:.3g}{slope:+.3g} × {args.xcol}"
|
|
71
|
+
|
|
72
|
+
if getattr(args, "pvalue", False):
|
|
73
|
+
pval = res.pvalues.get("_x", res.pvalues.iloc[-1])
|
|
74
|
+
label_text += f" -log10(p)={-np.log10(pval):.2g}"
|
|
75
|
+
|
|
76
|
+
ax.plot(X_plot, Y_plot, "r--", linewidth=1)
|
|
77
|
+
ax.legend(
|
|
78
|
+
handles=[mlines.Line2D([], [], color="red", linestyle="--", label=label_text)],
|
|
79
|
+
loc=0,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Single-axes scatter (reused per subplot)
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _scatter_ax(ax, df, args, title_suffix: str = ""):
|
|
89
|
+
"""Draw scatter on a single Axes, with optional --groupcol colouring."""
|
|
90
|
+
import matplotlib.pyplot as plt
|
|
91
|
+
|
|
92
|
+
xcol = args.xcol
|
|
93
|
+
ycol = args.ycol
|
|
94
|
+
legendloc = tuple(args.legendloc) if args.legendloc else "best"
|
|
95
|
+
|
|
96
|
+
scatter_kw = dict(marker=getattr(args, "marker", "o"), alpha=0.7)
|
|
97
|
+
|
|
98
|
+
if args.groupcol is not None:
|
|
99
|
+
for groupname, gdf in subgraph_groups(
|
|
100
|
+
df, args.groupcol, getattr(args, "groupcolorder", None)
|
|
101
|
+
):
|
|
102
|
+
label = make_grouplabel(
|
|
103
|
+
groupname, args.groupcol, getattr(args, "groupcolformat", None)
|
|
104
|
+
)
|
|
105
|
+
kw = dict(scatter_kw)
|
|
106
|
+
if args.sizecol:
|
|
107
|
+
kw["s"] = gdf[args.sizecol]
|
|
108
|
+
ax.scatter(gdf[xcol], gdf[ycol], label=label, **kw)
|
|
109
|
+
ax.legend(loc=legendloc, title=args.legendtitle)
|
|
110
|
+
else:
|
|
111
|
+
kw = dict(scatter_kw)
|
|
112
|
+
if args.sizecol:
|
|
113
|
+
kw["s"] = df[args.sizecol]
|
|
114
|
+
if args.colorcol:
|
|
115
|
+
sc = ax.scatter(
|
|
116
|
+
df[xcol], df[ycol], c=df[args.colorcol], cmap="viridis", **kw
|
|
117
|
+
)
|
|
118
|
+
plt.colorbar(sc, ax=ax, label=args.colorcol)
|
|
119
|
+
else:
|
|
120
|
+
label = args.legend or ""
|
|
121
|
+
ax.scatter(df[xcol], df[ycol], label=label if label else None, **kw)
|
|
122
|
+
if label:
|
|
123
|
+
ax.legend(loc=legendloc, title=args.legendtitle)
|
|
124
|
+
|
|
125
|
+
if getattr(args, "fit", False) and args.groupcol is None:
|
|
126
|
+
_fit_overlay(ax, df[xcol], df[ycol], args)
|
|
127
|
+
|
|
128
|
+
apply_limits(ax, args)
|
|
129
|
+
apply_ticks(ax, args)
|
|
130
|
+
apply_labels(ax, args, default_xlabel=xcol or "", default_ylabel=ycol or "")
|
|
131
|
+
if title_suffix:
|
|
132
|
+
existing = ax.get_title()
|
|
133
|
+
ax.set_title(f"{existing} [{title_suffix}]" if existing else title_suffix)
|
|
134
|
+
|
|
135
|
+
ax.grid(True, linewidth=0.4, alpha=0.5)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# Command
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
_EPILOG = """\
|
|
143
|
+
EXAMPLES
|
|
144
|
+
--------
|
|
145
|
+
Basic scatter:
|
|
146
|
+
|
|
147
|
+
dftk scat data.tsv -x weight -y height
|
|
148
|
+
|
|
149
|
+
Colour by group:
|
|
150
|
+
|
|
151
|
+
dftk scat data.tsv -x x -y y -g group
|
|
152
|
+
|
|
153
|
+
Subplot grid by condition, colour by treatment:
|
|
154
|
+
|
|
155
|
+
dftk scat data.tsv -x x -y y --subgraphcol condition -g treatment
|
|
156
|
+
|
|
157
|
+
Fit OLS line:
|
|
158
|
+
|
|
159
|
+
dftk scat data.tsv -x x -y y --fit
|
|
160
|
+
|
|
161
|
+
Publication figure (Nature single column, PDF):
|
|
162
|
+
|
|
163
|
+
dftk scat data.tsv -x x -y y --size single --fontsize publication -f fig.pdf
|
|
164
|
+
|
|
165
|
+
Bubble chart (point size from column):
|
|
166
|
+
|
|
167
|
+
dftk scat data.tsv -x x -y y --sizecol area_col
|
|
168
|
+
|
|
169
|
+
Continuous colour mapping:
|
|
170
|
+
|
|
171
|
+
dftk scat data.tsv -x x -y y --colorcol score
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class ScatCommand(BaseCommand):
|
|
176
|
+
name = "scat"
|
|
177
|
+
help = "Scatter plot with optional regression line overlay."
|
|
178
|
+
|
|
179
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
180
|
+
parser.formatter_class = argparse.RawDescriptionHelpFormatter
|
|
181
|
+
parser.epilog = _EPILOG
|
|
182
|
+
|
|
183
|
+
self.add_read_arguments(parser)
|
|
184
|
+
add_xy_arguments(parser)
|
|
185
|
+
add_figure_arguments(parser)
|
|
186
|
+
add_group_arguments(parser)
|
|
187
|
+
add_legend_arguments(parser)
|
|
188
|
+
|
|
189
|
+
s = parser.add_argument_group("scatter options")
|
|
190
|
+
s.add_argument(
|
|
191
|
+
"-m",
|
|
192
|
+
"--marker",
|
|
193
|
+
default="o",
|
|
194
|
+
metavar="MARKER",
|
|
195
|
+
help="Matplotlib marker style (default: o).",
|
|
196
|
+
)
|
|
197
|
+
s.add_argument(
|
|
198
|
+
"--sizecol",
|
|
199
|
+
default=None,
|
|
200
|
+
metavar="COL",
|
|
201
|
+
help="Column to use for point sizes (bubble chart).",
|
|
202
|
+
)
|
|
203
|
+
s.add_argument(
|
|
204
|
+
"--colorcol",
|
|
205
|
+
default=None,
|
|
206
|
+
metavar="COL",
|
|
207
|
+
help="Column for continuous colour mapping.",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
f = parser.add_argument_group("fit options")
|
|
211
|
+
f.add_argument(
|
|
212
|
+
"--fit",
|
|
213
|
+
action="store_true",
|
|
214
|
+
help="Overlay an OLS regression line. Prints summary to stderr.",
|
|
215
|
+
)
|
|
216
|
+
f.add_argument(
|
|
217
|
+
"-r",
|
|
218
|
+
"--robust",
|
|
219
|
+
action="store_true",
|
|
220
|
+
help="Use robust regression (RLM) for --fit.",
|
|
221
|
+
)
|
|
222
|
+
f.add_argument(
|
|
223
|
+
"--nointercept", action="store_true", help="Suppress intercept in --fit."
|
|
224
|
+
)
|
|
225
|
+
f.add_argument(
|
|
226
|
+
"--pvalue",
|
|
227
|
+
action="store_true",
|
|
228
|
+
help="Add −log₁₀(p) to the fit legend label.",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def execute(self, args: argparse.Namespace) -> None:
|
|
232
|
+
if not args.xcol or not args.ycol:
|
|
233
|
+
raise ValueError("--xcol (-x) and --ycol (-y) are required.")
|
|
234
|
+
|
|
235
|
+
df = io.read(args)
|
|
236
|
+
check_cols(df, [args.xcol, args.ycol], "-x/-y")
|
|
237
|
+
check_cols(df, args.groupcol, "-g/--groupcol")
|
|
238
|
+
check_cols(df, args.subgraphcol, "--subgraphcol")
|
|
239
|
+
if args.sizecol:
|
|
240
|
+
check_cols(df, [args.sizecol], "--sizecol")
|
|
241
|
+
if args.colorcol:
|
|
242
|
+
check_cols(df, [args.colorcol], "--colorcol")
|
|
243
|
+
|
|
244
|
+
# Use non-interactive backend when saving to file
|
|
245
|
+
if args.file:
|
|
246
|
+
import matplotlib
|
|
247
|
+
|
|
248
|
+
matplotlib.use("Agg")
|
|
249
|
+
|
|
250
|
+
import matplotlib.pyplot as plt
|
|
251
|
+
|
|
252
|
+
if args.usetex:
|
|
253
|
+
plt.rc("text", usetex=True)
|
|
254
|
+
|
|
255
|
+
apply_style(args)
|
|
256
|
+
|
|
257
|
+
if args.subgraphcol is not None:
|
|
258
|
+
groups = subgraph_groups(
|
|
259
|
+
df, args.subgraphcol, getattr(args, "subgraphorder", None)
|
|
260
|
+
)
|
|
261
|
+
nrows, ncols = subgraph_layout(len(groups), args.ncols)
|
|
262
|
+
fig, axes = make_figure(args, nrows=nrows, ncols=ncols)
|
|
263
|
+
for idx, (groupname, gdf) in enumerate(groups):
|
|
264
|
+
ax = axes[idx // ncols][idx % ncols]
|
|
265
|
+
suffix = make_grouplabel(
|
|
266
|
+
groupname, args.subgraphcol, getattr(args, "subgraphformat", None)
|
|
267
|
+
)
|
|
268
|
+
_scatter_ax(ax, gdf, args, title_suffix=suffix)
|
|
269
|
+
# Hide unused axes
|
|
270
|
+
for idx in range(len(groups), nrows * ncols):
|
|
271
|
+
axes[idx // ncols][idx % ncols].set_visible(False)
|
|
272
|
+
fig.tight_layout()
|
|
273
|
+
else:
|
|
274
|
+
fig, axes = make_figure(args)
|
|
275
|
+
_scatter_ax(axes[0][0], df, args)
|
|
276
|
+
fig.tight_layout()
|
|
277
|
+
|
|
278
|
+
save_or_show(fig, args)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dftk.commands.split_cmd — dftk split subcommand.
|
|
3
|
+
|
|
4
|
+
Port of dfsplit.py: split a dataframe into one TSV file per group.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import copy
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from dftk.commands.base import BaseCommand
|
|
12
|
+
from dftk.common.io import check_cols, io
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _make_filename(groupname, prefix: str, suffix: str) -> str:
|
|
16
|
+
tag = (
|
|
17
|
+
"_".join(str(x) for x in groupname)
|
|
18
|
+
if isinstance(groupname, tuple)
|
|
19
|
+
else str(groupname)
|
|
20
|
+
)
|
|
21
|
+
return f"{prefix}{tag}{suffix}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _SafeDict(dict):
|
|
25
|
+
"""Format-map dict that leaves unknown keys unreplaced."""
|
|
26
|
+
|
|
27
|
+
def __missing__(self, key):
|
|
28
|
+
return "{" + key + "}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _split(df, args: argparse.Namespace) -> None:
|
|
32
|
+
for groupname, groupdf in df.groupby(args.groups):
|
|
33
|
+
if args.template is not None:
|
|
34
|
+
key = (groupname,) if not isinstance(groupname, tuple) else groupname
|
|
35
|
+
group_dict = dict(zip(args.groups, key, strict=False))
|
|
36
|
+
filename = args.template.format_map(_SafeDict(group_dict))
|
|
37
|
+
else:
|
|
38
|
+
filename = _make_filename(groupname, args.prefix, args.suffix)
|
|
39
|
+
|
|
40
|
+
if args.noclobber and os.path.exists(filename):
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
file_args = copy.copy(args)
|
|
44
|
+
file_args.output = filename
|
|
45
|
+
io.printdf(groupdf, file_args)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_EPILOG = """\
|
|
49
|
+
FILENAME PATTERNS
|
|
50
|
+
-----------------
|
|
51
|
+
By default output files are named: <prefix><group_key><suffix>
|
|
52
|
+
|
|
53
|
+
dftk split data.tsv -g country -p out/ -s .tsv
|
|
54
|
+
# → out/Iceland.tsv, out/Norway.tsv, ...
|
|
55
|
+
|
|
56
|
+
With --template, use {col} placeholders for the group column values:
|
|
57
|
+
|
|
58
|
+
dftk split data.tsv -g year month --template data_{year}_{month}.tsv
|
|
59
|
+
|
|
60
|
+
Unknown placeholders are left as-is (safe format_map).
|
|
61
|
+
|
|
62
|
+
OPTIONS
|
|
63
|
+
-------
|
|
64
|
+
--noclobber Skip files that already exist instead of overwriting.
|
|
65
|
+
|
|
66
|
+
Note: -o/--output is not used by split; output paths are always determined
|
|
67
|
+
by -p/--prefix, -s/--suffix, or --template.
|
|
68
|
+
|
|
69
|
+
EXAMPLES
|
|
70
|
+
--------
|
|
71
|
+
dftk split data.tsv -g group
|
|
72
|
+
dftk split data.tsv -g country year -p results/ -s .tsv
|
|
73
|
+
dftk split data.tsv -g category --template out/{category}.tsv
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SplitCommand(BaseCommand):
|
|
78
|
+
@property
|
|
79
|
+
def name(self) -> str:
|
|
80
|
+
return "split"
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def help(self) -> str:
|
|
84
|
+
return "Split a dataframe into one file per group."
|
|
85
|
+
|
|
86
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
87
|
+
parser.formatter_class = argparse.RawDescriptionHelpFormatter
|
|
88
|
+
parser.epilog = _EPILOG
|
|
89
|
+
|
|
90
|
+
self.add_io_arguments(parser)
|
|
91
|
+
|
|
92
|
+
g = parser.add_argument_group("split options")
|
|
93
|
+
g.add_argument(
|
|
94
|
+
"-g",
|
|
95
|
+
"--groups",
|
|
96
|
+
nargs="+",
|
|
97
|
+
required=True,
|
|
98
|
+
metavar="COL",
|
|
99
|
+
help="Column(s) to group and split by.",
|
|
100
|
+
)
|
|
101
|
+
g.add_argument(
|
|
102
|
+
"-p",
|
|
103
|
+
"--prefix",
|
|
104
|
+
default="split-",
|
|
105
|
+
metavar="PREFIX",
|
|
106
|
+
help="Filename prefix (default: 'split-').",
|
|
107
|
+
)
|
|
108
|
+
g.add_argument(
|
|
109
|
+
"-s",
|
|
110
|
+
"--suffix",
|
|
111
|
+
default="",
|
|
112
|
+
metavar="SUFFIX",
|
|
113
|
+
help="Filename suffix / extension (default: none).",
|
|
114
|
+
)
|
|
115
|
+
g.add_argument(
|
|
116
|
+
"--template",
|
|
117
|
+
default=None,
|
|
118
|
+
metavar="TEMPLATE",
|
|
119
|
+
help="Filename template with {col} placeholders, e.g. out/{group}.tsv.",
|
|
120
|
+
)
|
|
121
|
+
g.add_argument(
|
|
122
|
+
"--noclobber",
|
|
123
|
+
action="store_true",
|
|
124
|
+
help="Skip output files that already exist.",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def execute(self, args: argparse.Namespace) -> None:
|
|
128
|
+
df = io.read(args)
|
|
129
|
+
check_cols(df, args.groups, "-g/--groups")
|
|
130
|
+
_split(df, args)
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dftk.commands.stat_cmd — the ``dftk stat`` subcommand.
|
|
3
|
+
|
|
4
|
+
Computes descriptive statistics for one or more numeric columns, optionally
|
|
5
|
+
within groups. Output always includes a ``name`` column identifying which
|
|
6
|
+
input column each row describes, giving a consistent schema regardless of
|
|
7
|
+
how many columns are requested.
|
|
8
|
+
|
|
9
|
+
Output columns
|
|
10
|
+
--------------
|
|
11
|
+
[groupcol(s),] name, count, sum, mean, std, min, max, median,
|
|
12
|
+
cilo, cihi, sem, skew, kurt
|
|
13
|
+
|
|
14
|
+
Bootstrap mode
|
|
15
|
+
--------------
|
|
16
|
+
With ``--bootstrap N`` the full stat computation is repeated N times on
|
|
17
|
+
resampled data. Each iteration appends a ``samplenum`` column and output
|
|
18
|
+
blocks are streamed without repeating the header, making it easy to pipe
|
|
19
|
+
into ``dftk pivot`` to aggregate across samples.
|
|
20
|
+
|
|
21
|
+
Example
|
|
22
|
+
-------
|
|
23
|
+
dftk stat data.tsv -c height weight -g sex
|
|
24
|
+
dftk stat data.tsv -c value -g group --bootstrap 1000 --randomseed 42 -o \\
|
|
25
|
+
| dftk pivot -i group -v value_mean -o \\
|
|
26
|
+
| dftk print
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import logging
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
import pandas as pd
|
|
34
|
+
|
|
35
|
+
from dftk.commands.base import BaseCommand
|
|
36
|
+
from dftk.common.io import check_cols, io
|
|
37
|
+
from dftk.common.seed import normalize_seed
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger("dftk")
|
|
40
|
+
|
|
41
|
+
_CI_METHODS = ["linear", "lower", "higher", "midpoint", "nearest"]
|
|
42
|
+
|
|
43
|
+
# Canonical order of statistic columns in the output.
|
|
44
|
+
_STAT_NAMES = [
|
|
45
|
+
"count",
|
|
46
|
+
"sum",
|
|
47
|
+
"mean",
|
|
48
|
+
"std",
|
|
49
|
+
"min",
|
|
50
|
+
"max",
|
|
51
|
+
"median",
|
|
52
|
+
"cilo",
|
|
53
|
+
"cihi",
|
|
54
|
+
"sem",
|
|
55
|
+
"skew",
|
|
56
|
+
"kurt",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Helpers
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _samplefraction(x: str) -> float:
|
|
66
|
+
"""argparse type validator for --samplefrac."""
|
|
67
|
+
try:
|
|
68
|
+
x = float(x)
|
|
69
|
+
except ValueError:
|
|
70
|
+
raise argparse.ArgumentTypeError(f"{x!r} is not a valid float") from None
|
|
71
|
+
if not (0.0 <= x <= 1.0):
|
|
72
|
+
raise argparse.ArgumentTypeError(f"{x} is not in range [0.0, 1.0]")
|
|
73
|
+
return x
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _ci(name: str, level: float, method: str):
|
|
77
|
+
"""Return a named percentile aggregation function for use with pandas agg.
|
|
78
|
+
|
|
79
|
+
The function name is set explicitly so pandas uses it as the output
|
|
80
|
+
column name rather than the generic ``<lambda>`` or ``cifunction``.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def cifunction(a):
|
|
84
|
+
return np.percentile(a, q=level, method=method)
|
|
85
|
+
|
|
86
|
+
cifunction.__name__ = name
|
|
87
|
+
return cifunction
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Core computation
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _compute_stats(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
|
|
96
|
+
"""Return a dataframe of descriptive statistics.
|
|
97
|
+
|
|
98
|
+
Always produces a ``name`` column identifying the source column, giving
|
|
99
|
+
a consistent schema regardless of how many columns are in ``args.cols``.
|
|
100
|
+
"""
|
|
101
|
+
cols: list[str] = args.cols
|
|
102
|
+
groupcol: list[str] | None = args.groupcol
|
|
103
|
+
|
|
104
|
+
conf_lo = (100.0 - args.confidencelevel) / 2
|
|
105
|
+
conf_hi = 100.0 - conf_lo
|
|
106
|
+
cilo_fn = _ci("cilo", conf_lo, args.confidencemethod)
|
|
107
|
+
cihi_fn = _ci("cihi", conf_hi, args.confidencemethod)
|
|
108
|
+
|
|
109
|
+
agg_funcs = [
|
|
110
|
+
"count",
|
|
111
|
+
"sum",
|
|
112
|
+
"mean",
|
|
113
|
+
"std",
|
|
114
|
+
"min",
|
|
115
|
+
"max",
|
|
116
|
+
"median",
|
|
117
|
+
cilo_fn,
|
|
118
|
+
cihi_fn,
|
|
119
|
+
"sem",
|
|
120
|
+
"skew",
|
|
121
|
+
"kurt",
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
if groupcol is None:
|
|
125
|
+
# Ungrouped — agg returns shape (n_stats, n_cols); transpose to
|
|
126
|
+
# (n_cols, n_stats) then reset the column index to get a 'name' row.
|
|
127
|
+
stats = (
|
|
128
|
+
df[cols].agg(agg_funcs).T.reset_index().rename(columns={"index": "name"})
|
|
129
|
+
)
|
|
130
|
+
return stats[["name"] + _STAT_NAMES]
|
|
131
|
+
|
|
132
|
+
else:
|
|
133
|
+
# Grouped — agg returns MultiIndex columns (col, stat).
|
|
134
|
+
# Reshape to long format so every (group, col) combination is a row.
|
|
135
|
+
name_col = "name"
|
|
136
|
+
if name_col in groupcol:
|
|
137
|
+
name_col = "name_mangled"
|
|
138
|
+
logger.warning(
|
|
139
|
+
'A group column is named "name"; '
|
|
140
|
+
'stat name column renamed to "name_mangled"'
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
stats = (
|
|
144
|
+
df.groupby(groupcol)[cols]
|
|
145
|
+
.agg(agg_funcs)
|
|
146
|
+
.rename(columns={"amin": "min", "amax": "max"}) # numpy compat
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# swaplevel: (col, stat) → (stat, col)
|
|
150
|
+
# stack: fold the col level into the row index
|
|
151
|
+
# reset_index + rename: flatten to a plain DataFrame
|
|
152
|
+
level_col = f"level_{len(groupcol)}"
|
|
153
|
+
stats = (
|
|
154
|
+
stats.swaplevel(0, 1, axis=1)
|
|
155
|
+
.stack(future_stack=True)
|
|
156
|
+
.reset_index()
|
|
157
|
+
.rename(columns={level_col: name_col})
|
|
158
|
+
.sort_values(by=[name_col] + groupcol)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
out_cols = groupcol + [name_col] + _STAT_NAMES
|
|
162
|
+
return stats[out_cols].reset_index(drop=True)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# Command
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class StatCommand(BaseCommand):
|
|
171
|
+
"""Compute descriptive statistics for one or more columns."""
|
|
172
|
+
|
|
173
|
+
name = "stat"
|
|
174
|
+
help = "Compute descriptive statistics (mean, std, CI, …) for one or more columns"
|
|
175
|
+
|
|
176
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
177
|
+
self.add_io_arguments(parser)
|
|
178
|
+
|
|
179
|
+
g = parser.add_argument_group("statistics")
|
|
180
|
+
g.add_argument(
|
|
181
|
+
"-c",
|
|
182
|
+
"--cols",
|
|
183
|
+
help="Column(s) to compute statistics on",
|
|
184
|
+
nargs="+",
|
|
185
|
+
required=True,
|
|
186
|
+
metavar="COL",
|
|
187
|
+
)
|
|
188
|
+
g.add_argument(
|
|
189
|
+
"-g",
|
|
190
|
+
"--groupcol",
|
|
191
|
+
help="Group-by column(s)",
|
|
192
|
+
nargs="+",
|
|
193
|
+
default=None,
|
|
194
|
+
metavar="COL",
|
|
195
|
+
)
|
|
196
|
+
g.add_argument(
|
|
197
|
+
"--confidencelevel",
|
|
198
|
+
help="Confidence level in percent (default: 95.0)",
|
|
199
|
+
type=float,
|
|
200
|
+
default=95.0,
|
|
201
|
+
metavar="PCT",
|
|
202
|
+
)
|
|
203
|
+
g.add_argument(
|
|
204
|
+
"--confidencemethod",
|
|
205
|
+
help="Percentile interpolation method (default: linear)",
|
|
206
|
+
choices=_CI_METHODS,
|
|
207
|
+
default="linear",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
bs = parser.add_argument_group("bootstrap")
|
|
211
|
+
bs.add_argument(
|
|
212
|
+
"--bootstrap",
|
|
213
|
+
help="Number of bootstrap samples to draw",
|
|
214
|
+
type=int,
|
|
215
|
+
default=None,
|
|
216
|
+
metavar="N",
|
|
217
|
+
)
|
|
218
|
+
bs.add_argument(
|
|
219
|
+
"--randomseed",
|
|
220
|
+
help=(
|
|
221
|
+
"Random seed — integer, or a string which is MD5-hashed "
|
|
222
|
+
"to a reproducible integer"
|
|
223
|
+
),
|
|
224
|
+
default=None,
|
|
225
|
+
metavar="SEED",
|
|
226
|
+
)
|
|
227
|
+
size_grp = bs.add_mutually_exclusive_group()
|
|
228
|
+
size_grp.add_argument(
|
|
229
|
+
"--samplesize",
|
|
230
|
+
help="Rows per bootstrap sample (default: same size as input)",
|
|
231
|
+
type=int,
|
|
232
|
+
default=None,
|
|
233
|
+
metavar="N",
|
|
234
|
+
)
|
|
235
|
+
size_grp.add_argument(
|
|
236
|
+
"--samplefrac",
|
|
237
|
+
help="Fraction of rows per bootstrap sample, 0.0–1.0 (default: 1.0)",
|
|
238
|
+
type=_samplefraction,
|
|
239
|
+
default=1.0,
|
|
240
|
+
metavar="FRAC",
|
|
241
|
+
)
|
|
242
|
+
bs.add_argument(
|
|
243
|
+
"--noreplace",
|
|
244
|
+
help="Sample without replacement (default: with replacement)",
|
|
245
|
+
dest="replace",
|
|
246
|
+
action="store_false",
|
|
247
|
+
)
|
|
248
|
+
parser.set_defaults(replace=True)
|
|
249
|
+
|
|
250
|
+
def execute(self, args: argparse.Namespace) -> None:
|
|
251
|
+
args.randomseed = normalize_seed(args.randomseed)
|
|
252
|
+
np.random.seed(args.randomseed)
|
|
253
|
+
|
|
254
|
+
df = io.read(args)
|
|
255
|
+
if df.empty:
|
|
256
|
+
raise ValueError("Input data is empty")
|
|
257
|
+
|
|
258
|
+
check_cols(df, args.cols, "-c/--cols")
|
|
259
|
+
check_cols(df, args.groupcol, "-g/--groupcol")
|
|
260
|
+
|
|
261
|
+
if args.bootstrap is None:
|
|
262
|
+
result = _compute_stats(df, args)
|
|
263
|
+
io.printdf(result, args)
|
|
264
|
+
|
|
265
|
+
else:
|
|
266
|
+
logger.info(
|
|
267
|
+
f"Bootstrapping: {args.bootstrap} samples, "
|
|
268
|
+
f"seed={args.randomseed}, replace={args.replace}"
|
|
269
|
+
)
|
|
270
|
+
sample_kwargs: dict = {"replace": args.replace}
|
|
271
|
+
if args.samplesize is not None:
|
|
272
|
+
sample_kwargs["n"] = args.samplesize
|
|
273
|
+
else:
|
|
274
|
+
sample_kwargs["frac"] = args.samplefrac
|
|
275
|
+
|
|
276
|
+
frames = []
|
|
277
|
+
for i in range(1, args.bootstrap + 1):
|
|
278
|
+
if args.groupcol is None:
|
|
279
|
+
sample = df.sample(**sample_kwargs)
|
|
280
|
+
else:
|
|
281
|
+
sample = df.groupby(args.groupcol, group_keys=False).apply(
|
|
282
|
+
lambda x: x.sample(**sample_kwargs)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
result = _compute_stats(sample, args)
|
|
286
|
+
result["samplenum"] = i
|
|
287
|
+
frames.append(result)
|
|
288
|
+
|
|
289
|
+
io.printdf(pd.concat(frames, ignore_index=True), args)
|