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,438 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dftk.commands.pivot_cmd — dftk pivot subcommand.
|
|
3
|
+
|
|
4
|
+
Wraps pandas pivot_table with optional bootstrap resampling and three
|
|
5
|
+
sampling strategies for hierarchical data.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from dftk.commands.base import BaseCommand
|
|
15
|
+
from dftk.common.io import check_cols, io
|
|
16
|
+
from dftk.common.seed import normalize_seed
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_EPILOG = """\
|
|
21
|
+
AGGREGATION FUNCTIONS (-f / --aggfunc)
|
|
22
|
+
---------------------------------------
|
|
23
|
+
Pass one or more names from this list:
|
|
24
|
+
|
|
25
|
+
count min max mean median mode sum std var skew kurt prod
|
|
26
|
+
cilo cihi (confidence-interval bounds, level set by --confidencelevel)
|
|
27
|
+
bitwise_and bitwise_or bitwise_xor
|
|
28
|
+
|
|
29
|
+
Bare names apply the same function(s) to every value column:
|
|
30
|
+
|
|
31
|
+
dftk pivot data.tsv -v sales cost -i region -f mean std
|
|
32
|
+
|
|
33
|
+
Per-column functions use COL:FUNC syntax — all items must use this form if
|
|
34
|
+
any do:
|
|
35
|
+
|
|
36
|
+
dftk pivot data.tsv -v sales cost -i region -f sales:mean cost:sum
|
|
37
|
+
|
|
38
|
+
When -f is omitted, pandas default aggregation (mean) is used.
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
BOOTSTRAP SAMPLING MODES (--bootstrap N)
|
|
42
|
+
-----------------------------------------
|
|
43
|
+
Three sampling strategies are available, determined by which flags are given.
|
|
44
|
+
|
|
45
|
+
1. Full resampling (--fullsampling)
|
|
46
|
+
Sample every row in the dataset with replacement, ignoring group
|
|
47
|
+
structure. Use this when there is no meaningful grouping to preserve.
|
|
48
|
+
|
|
49
|
+
dftk pivot data.tsv -v value -i subject -f mean \\
|
|
50
|
+
--bootstrap 500 --fullsampling
|
|
51
|
+
|
|
52
|
+
2. Group-based row sampling (--samplinggroup COLS)
|
|
53
|
+
Resample rows with replacement *within* each group defined by
|
|
54
|
+
COLS. Group sizes are preserved across samples.
|
|
55
|
+
|
|
56
|
+
dftk pivot data.tsv -v value -i timepoint -g condition -f mean \\
|
|
57
|
+
--bootstrap 500 --samplinggroup subject
|
|
58
|
+
|
|
59
|
+
If neither --fullsampling nor --samplinggroup nor --samplingcols is
|
|
60
|
+
given, the sampling groups default to the union of --index and
|
|
61
|
+
--groupcols (the pivot dimensions themselves).
|
|
62
|
+
|
|
63
|
+
3. Hierarchical (parent/child) sampling (--samplingcols COLS)
|
|
64
|
+
Use when each logical unit has multiple data rows (e.g. a subject has
|
|
65
|
+
many measurements). Sampling parents and pulling all their children
|
|
66
|
+
keeps the within-unit structure intact.
|
|
67
|
+
|
|
68
|
+
Steps:
|
|
69
|
+
a. Build a sampling set of unique combinations of --samplingcols
|
|
70
|
+
(the parent identifiers).
|
|
71
|
+
b. Bootstrap-resample that set with replacement — optionally within
|
|
72
|
+
groups defined by --samplinggroup.
|
|
73
|
+
c. Merge the resampled set back against the full DataFrame, pulling
|
|
74
|
+
in all child rows for each selected parent.
|
|
75
|
+
|
|
76
|
+
If parent P is drawn twice, all of P's rows appear twice in the
|
|
77
|
+
bootstrap sample; if Q is not drawn, none of Q's rows appear.
|
|
78
|
+
|
|
79
|
+
# Sample subjects (parents) within each sex group;
|
|
80
|
+
# each subject has multiple visit rows (children).
|
|
81
|
+
dftk pivot data.tsv -v score -i visit -g condition -f mean \\
|
|
82
|
+
--bootstrap 500 \\
|
|
83
|
+
--samplingcols subject_id \\
|
|
84
|
+
--samplinggroup sex
|
|
85
|
+
|
|
86
|
+
Note: --samplingcols values are combined with --samplinggroup values
|
|
87
|
+
to form the merge key, so the group column must also be in the data.
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
PIPE MODE
|
|
91
|
+
---------
|
|
92
|
+
Use -o alone to pass data to the next dftk command without writing to a
|
|
93
|
+
file:
|
|
94
|
+
|
|
95
|
+
cat data.tsv \\
|
|
96
|
+
| dftk pivot - -v value -i subject -g condition -f mean -o \\
|
|
97
|
+
| dftk stat - -c condition_A condition_B
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Helpers
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _ci(name: str, level: float, method: str):
|
|
107
|
+
"""Return a named percentile function for use as a pandas agg function."""
|
|
108
|
+
|
|
109
|
+
def cifunction(a):
|
|
110
|
+
return np.percentile(a, q=level, method=method)
|
|
111
|
+
|
|
112
|
+
cifunction.__name__ = name
|
|
113
|
+
return cifunction
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _bitwise(op: str):
|
|
117
|
+
"""Return a named bitwise-reduce function."""
|
|
118
|
+
|
|
119
|
+
def bitfunction(a):
|
|
120
|
+
if op == "and":
|
|
121
|
+
return np.bitwise_and.reduce(a)
|
|
122
|
+
if op == "or":
|
|
123
|
+
return np.bitwise_or.reduce(a)
|
|
124
|
+
if op == "xor":
|
|
125
|
+
return np.bitwise_xor.reduce(a)
|
|
126
|
+
|
|
127
|
+
bitfunction.__name__ = op
|
|
128
|
+
return bitfunction
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _prepare_funcs(
|
|
132
|
+
aggfuncs: list[str] | None, confidencelevel: float, confidencemethod: str
|
|
133
|
+
):
|
|
134
|
+
"""Convert aggfunc name strings into a pandas-compatible aggfunc argument.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
None → pandas default (mean)
|
|
138
|
+
list → same function(s) applied to all value columns
|
|
139
|
+
dict → per-column functions, keyed by column name
|
|
140
|
+
"""
|
|
141
|
+
if aggfuncs is None:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
funclist: list = []
|
|
145
|
+
funcdict: dict = {}
|
|
146
|
+
|
|
147
|
+
for item in aggfuncs:
|
|
148
|
+
colname = None
|
|
149
|
+
funcname = item
|
|
150
|
+
if ":" in item:
|
|
151
|
+
colname, funcname = item.split(":", 1)
|
|
152
|
+
funcdict.setdefault(colname, [])
|
|
153
|
+
|
|
154
|
+
if funcname in {
|
|
155
|
+
"count",
|
|
156
|
+
"min",
|
|
157
|
+
"max",
|
|
158
|
+
"mean",
|
|
159
|
+
"median",
|
|
160
|
+
"mode",
|
|
161
|
+
"sum",
|
|
162
|
+
"std",
|
|
163
|
+
"var",
|
|
164
|
+
"skew",
|
|
165
|
+
"kurt",
|
|
166
|
+
"prod",
|
|
167
|
+
}:
|
|
168
|
+
func = funcname
|
|
169
|
+
elif funcname == "cilo":
|
|
170
|
+
lo = (100.0 - confidencelevel) / 2.0
|
|
171
|
+
func = _ci("cilo", lo, confidencemethod)
|
|
172
|
+
elif funcname == "cihi":
|
|
173
|
+
hi = 100.0 - (100.0 - confidencelevel) / 2.0
|
|
174
|
+
func = _ci("cihi", hi, confidencemethod)
|
|
175
|
+
elif funcname in {"bitwise_and", "bitwise_or", "bitwise_xor"}:
|
|
176
|
+
func = _bitwise(funcname.split("_", 1)[1])
|
|
177
|
+
else:
|
|
178
|
+
try:
|
|
179
|
+
func = getattr(np, funcname)
|
|
180
|
+
except AttributeError:
|
|
181
|
+
raise ValueError( # noqa: E501
|
|
182
|
+
f"Unknown aggregation function: {funcname!r}"
|
|
183
|
+
) from None
|
|
184
|
+
|
|
185
|
+
if funcdict and colname is None:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
"Mixed aggfunc spec: either all items must use COL:FUNC "
|
|
188
|
+
"syntax or none must."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if colname is not None:
|
|
192
|
+
funcdict[colname].append(func)
|
|
193
|
+
else:
|
|
194
|
+
funclist.append(func)
|
|
195
|
+
|
|
196
|
+
return funcdict if funcdict else funclist
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _flatten_columns(cols) -> list[str]:
|
|
200
|
+
"""Flatten MultiIndex column tuples to underscore-joined strings."""
|
|
201
|
+
result = []
|
|
202
|
+
for col in cols:
|
|
203
|
+
if isinstance(col, str):
|
|
204
|
+
result.append(col)
|
|
205
|
+
else:
|
|
206
|
+
result.append("_".join(str(x) for x in col if str(x) != ""))
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _do_pivot(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
|
|
211
|
+
"""Run pivot_table and flatten column names."""
|
|
212
|
+
funcs = _prepare_funcs(
|
|
213
|
+
getattr(args, "aggfunc", None),
|
|
214
|
+
getattr(args, "confidencelevel", 95.0),
|
|
215
|
+
getattr(args, "confidencemethod", "linear"),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
index = args.index or None
|
|
219
|
+
columns = args.groupcols or None
|
|
220
|
+
|
|
221
|
+
pivoted = df.pivot_table(
|
|
222
|
+
values=args.values,
|
|
223
|
+
index=index,
|
|
224
|
+
columns=columns,
|
|
225
|
+
aggfunc=funcs if funcs is not None else "mean",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
cols = pivoted.columns
|
|
229
|
+
pivoted.columns = _flatten_columns(cols.values if hasattr(cols, "values") else cols)
|
|
230
|
+
|
|
231
|
+
if getattr(args, "fillzero", False) and hasattr(pivoted.index, "levels"):
|
|
232
|
+
pidx = pd.MultiIndex.from_product(
|
|
233
|
+
pivoted.index.levels, names=pivoted.index.names
|
|
234
|
+
)
|
|
235
|
+
pivoted = pivoted.reindex(pidx, fill_value=0, copy=False)
|
|
236
|
+
|
|
237
|
+
return pivoted.reset_index()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
# Command
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class PivotCommand(BaseCommand):
|
|
246
|
+
@property
|
|
247
|
+
def name(self) -> str:
|
|
248
|
+
return "pivot"
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def help(self) -> str:
|
|
252
|
+
return "Pivot / aggregate a DataFrame (pivot_table)"
|
|
253
|
+
|
|
254
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
255
|
+
import argparse as ap
|
|
256
|
+
|
|
257
|
+
parser.formatter_class = ap.RawDescriptionHelpFormatter
|
|
258
|
+
parser.epilog = _EPILOG
|
|
259
|
+
|
|
260
|
+
io.parser_read(parser)
|
|
261
|
+
|
|
262
|
+
g = parser.add_argument_group("pivot options")
|
|
263
|
+
g.add_argument(
|
|
264
|
+
"-v",
|
|
265
|
+
"--values",
|
|
266
|
+
help="Value column(s) to aggregate.",
|
|
267
|
+
nargs="+",
|
|
268
|
+
required=True,
|
|
269
|
+
metavar="COL",
|
|
270
|
+
)
|
|
271
|
+
g.add_argument(
|
|
272
|
+
"-i",
|
|
273
|
+
"--index",
|
|
274
|
+
help="Column(s) that become row labels in the pivot table.",
|
|
275
|
+
nargs="*",
|
|
276
|
+
default=[],
|
|
277
|
+
metavar="COL",
|
|
278
|
+
)
|
|
279
|
+
g.add_argument(
|
|
280
|
+
"-g",
|
|
281
|
+
"--groupcols",
|
|
282
|
+
help="Column(s) whose unique values are spread into new column headers.",
|
|
283
|
+
nargs="*",
|
|
284
|
+
default=[],
|
|
285
|
+
metavar="COL",
|
|
286
|
+
)
|
|
287
|
+
g.add_argument(
|
|
288
|
+
"-f",
|
|
289
|
+
"--aggfunc",
|
|
290
|
+
help=(
|
|
291
|
+
"Aggregation function(s). "
|
|
292
|
+
"Use bare names (mean std) or COL:FUNC pairs (sales:mean cost:sum). "
|
|
293
|
+
"See --help for full list. Default: mean."
|
|
294
|
+
),
|
|
295
|
+
nargs="*",
|
|
296
|
+
default=None,
|
|
297
|
+
metavar="FUNC",
|
|
298
|
+
)
|
|
299
|
+
g.add_argument(
|
|
300
|
+
"-z",
|
|
301
|
+
"--fillzero",
|
|
302
|
+
help="Reindex to all (index × columns) combinations, fill missing with 0.",
|
|
303
|
+
action="store_true",
|
|
304
|
+
)
|
|
305
|
+
g.add_argument(
|
|
306
|
+
"--confidencelevel",
|
|
307
|
+
help="Confidence level for cilo/cihi aggfuncs, in percent (default: 95.0).",
|
|
308
|
+
type=float,
|
|
309
|
+
default=95.0,
|
|
310
|
+
metavar="PCT",
|
|
311
|
+
)
|
|
312
|
+
g.add_argument(
|
|
313
|
+
"--confidencemethod",
|
|
314
|
+
help="Interpolation method for cilo/cihi (default: linear).",
|
|
315
|
+
default="linear",
|
|
316
|
+
metavar="METHOD",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
bs = parser.add_argument_group("bootstrap options")
|
|
320
|
+
bs.add_argument(
|
|
321
|
+
"--bootstrap",
|
|
322
|
+
help=(
|
|
323
|
+
"Number of bootstrap samples. "
|
|
324
|
+
"Produces a single DataFrame with a samplenum column."
|
|
325
|
+
),
|
|
326
|
+
type=int,
|
|
327
|
+
default=None,
|
|
328
|
+
metavar="N",
|
|
329
|
+
)
|
|
330
|
+
bs.add_argument(
|
|
331
|
+
"--randomseed",
|
|
332
|
+
help="Random seed. Accepts an integer or a string (hashed to uint32).",
|
|
333
|
+
default=None,
|
|
334
|
+
metavar="SEED",
|
|
335
|
+
)
|
|
336
|
+
bs.add_argument(
|
|
337
|
+
"--fullsampling",
|
|
338
|
+
help="Resample all rows with replacement, ignoring group structure.",
|
|
339
|
+
action="store_true",
|
|
340
|
+
)
|
|
341
|
+
bs.add_argument(
|
|
342
|
+
"--samplinggroup",
|
|
343
|
+
help=(
|
|
344
|
+
"Column(s) defining groups within which rows are resampled. "
|
|
345
|
+
"Also used as the within-group dimension when --samplingcols is given."
|
|
346
|
+
),
|
|
347
|
+
nargs="*",
|
|
348
|
+
default=[],
|
|
349
|
+
metavar="COL",
|
|
350
|
+
)
|
|
351
|
+
bs.add_argument(
|
|
352
|
+
"--samplingcols",
|
|
353
|
+
help=(
|
|
354
|
+
"Column(s) identifying the sampling unit (e.g. a subject ID). "
|
|
355
|
+
"Enables hierarchical bootstrap: unique units are resampled and "
|
|
356
|
+
"all their rows are pulled in via merge. "
|
|
357
|
+
"See --help for a detailed example."
|
|
358
|
+
),
|
|
359
|
+
nargs="*",
|
|
360
|
+
default=[],
|
|
361
|
+
metavar="COL",
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
io.parser_output(parser)
|
|
365
|
+
|
|
366
|
+
def execute(self, args: argparse.Namespace) -> None:
|
|
367
|
+
args.randomseed = normalize_seed(args.randomseed)
|
|
368
|
+
np.random.seed(args.randomseed)
|
|
369
|
+
|
|
370
|
+
df = io.read(args)
|
|
371
|
+
|
|
372
|
+
check_cols(df, args.values, "-v/--values")
|
|
373
|
+
check_cols(df, args.index, "-i/--index")
|
|
374
|
+
check_cols(df, args.groupcols, "-g/--groupcols")
|
|
375
|
+
check_cols(df, args.samplingcols, "--samplingcols")
|
|
376
|
+
check_cols(df, args.samplinggroup, "--samplinggroup")
|
|
377
|
+
|
|
378
|
+
if args.bootstrap is None:
|
|
379
|
+
result = _do_pivot(df, args)
|
|
380
|
+
io.printdf(result, args)
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
# ------------------------------------------------------------------
|
|
384
|
+
# Bootstrap
|
|
385
|
+
# ------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
logger.info(
|
|
388
|
+
f"Bootstrap pivot: {args.bootstrap} samples, seed={args.randomseed}"
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# Determine sampling mode and build sampling set if needed.
|
|
392
|
+
if args.fullsampling:
|
|
393
|
+
mode = "full"
|
|
394
|
+
s = None
|
|
395
|
+
samplinggroup: list[str] = []
|
|
396
|
+
elif args.samplingcols:
|
|
397
|
+
mode = "hierarchical"
|
|
398
|
+
# The merge key is samplingcols + samplinggroup so the group
|
|
399
|
+
# column is preserved through the merge.
|
|
400
|
+
merge_cols = args.samplingcols + args.samplinggroup
|
|
401
|
+
s = df[merge_cols].drop_duplicates().reset_index(drop=True)
|
|
402
|
+
samplinggroup = args.samplinggroup
|
|
403
|
+
else:
|
|
404
|
+
mode = "grouped"
|
|
405
|
+
s = None
|
|
406
|
+
samplinggroup = args.samplinggroup or (args.index + args.groupcols)
|
|
407
|
+
|
|
408
|
+
logger.info(f"Sampling mode: {mode}")
|
|
409
|
+
|
|
410
|
+
frames: list[pd.DataFrame] = []
|
|
411
|
+
|
|
412
|
+
for ns in range(1, args.bootstrap + 1):
|
|
413
|
+
if mode == "full":
|
|
414
|
+
bf = df.sample(frac=1, replace=True)
|
|
415
|
+
|
|
416
|
+
elif mode == "hierarchical":
|
|
417
|
+
if samplinggroup:
|
|
418
|
+
ss = (
|
|
419
|
+
s.groupby(samplinggroup, group_keys=False)[s.columns.tolist()]
|
|
420
|
+
.apply(lambda x: x.sample(frac=1.0, replace=True))
|
|
421
|
+
.reset_index(drop=True)
|
|
422
|
+
)
|
|
423
|
+
else:
|
|
424
|
+
ss = s.sample(frac=1.0, replace=True).reset_index(drop=True)
|
|
425
|
+
bf = pd.merge(ss, df)
|
|
426
|
+
|
|
427
|
+
else: # grouped
|
|
428
|
+
bf = (
|
|
429
|
+
df.groupby(samplinggroup, group_keys=False)[df.columns.tolist()]
|
|
430
|
+
.apply(lambda x: x.sample(frac=1.0, replace=True))
|
|
431
|
+
.reset_index(drop=True)
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
result = _do_pivot(bf, args)
|
|
435
|
+
result["samplenum"] = ns
|
|
436
|
+
frames.append(result)
|
|
437
|
+
|
|
438
|
+
io.printdf(pd.concat(frames, ignore_index=True), args)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dftk.commands.print_cmd — the ``dftk print`` subcommand.
|
|
3
|
+
|
|
4
|
+
Reads any dftk-compatible input (TSV file, TSV on stdin, or a parquet pipe
|
|
5
|
+
path on stdin) and writes TSV to stdout. Acts as the terminal stage of a
|
|
6
|
+
dftk pipeline:
|
|
7
|
+
|
|
8
|
+
dftk stat -g grp -s val -o \\
|
|
9
|
+
| dftk pivot -i grp -v val_mean -o \\
|
|
10
|
+
| dftk print
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from dftk.commands.base import BaseCommand
|
|
17
|
+
from dftk.common.io import io
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PrintCommand(BaseCommand):
|
|
21
|
+
"""Read any dftk input and write TSV to stdout."""
|
|
22
|
+
|
|
23
|
+
name = "print"
|
|
24
|
+
help = "Convert any input (TSV or parquet pipe) to TSV on stdout"
|
|
25
|
+
|
|
26
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
27
|
+
# Only the read-side arguments — no -o/--output, since the whole
|
|
28
|
+
# point of this command is to force TSV to stdout.
|
|
29
|
+
io.parser_read(parser)
|
|
30
|
+
|
|
31
|
+
def execute(self, args: argparse.Namespace) -> None:
|
|
32
|
+
df = io.read(args)
|
|
33
|
+
try:
|
|
34
|
+
df.to_csv(sys.stdout, sep="\t", index=False)
|
|
35
|
+
except (OSError, BrokenPipeError):
|
|
36
|
+
sys.stderr.close()
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dftk.commands.query_cmd — dftk query subcommand.
|
|
3
|
+
|
|
4
|
+
Filter (and optionally reshape) a dataframe using either pandas query
|
|
5
|
+
expressions or full DuckDB SQL.
|
|
6
|
+
|
|
7
|
+
Two modes
|
|
8
|
+
---------
|
|
9
|
+
pandas mode (-q / --query, repeatable)
|
|
10
|
+
Applies pandas DataFrame.query() expressions in sequence. Each
|
|
11
|
+
expression is a boolean row filter; all expressions are ANDed together.
|
|
12
|
+
|
|
13
|
+
dftk query data.tsv -q "x > 0" -q "group == 'A'"
|
|
14
|
+
|
|
15
|
+
sql mode (--sql)
|
|
16
|
+
Passes a full SQL statement to DuckDB. The input data is available as
|
|
17
|
+
a virtual table named "data" (override with --table). Any SQL that
|
|
18
|
+
DuckDB supports is valid: SELECT, aggregation, window functions, CTEs.
|
|
19
|
+
|
|
20
|
+
dftk query data.tsv --sql "SELECT grp, AVG(x) AS mean FROM data GROUP BY grp"
|
|
21
|
+
|
|
22
|
+
When the input is a named .parquet file, DuckDB queries it natively
|
|
23
|
+
without loading the full file into memory — suitable for large datasets.
|
|
24
|
+
|
|
25
|
+
Requires: pip install duckdb
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
|
|
32
|
+
import pandas as pd
|
|
33
|
+
|
|
34
|
+
from dftk.commands.base import BaseCommand
|
|
35
|
+
from dftk.common.io import io
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
import duckdb
|
|
39
|
+
|
|
40
|
+
_HAS_DUCKDB = True
|
|
41
|
+
except ImportError:
|
|
42
|
+
duckdb = None
|
|
43
|
+
_HAS_DUCKDB = False
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Core functions
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _pandas_query(df: pd.DataFrame, expressions: list[str]) -> pd.DataFrame:
|
|
54
|
+
"""Apply each expression as a pandas boolean row filter (AND logic)."""
|
|
55
|
+
for expr in expressions:
|
|
56
|
+
try:
|
|
57
|
+
df = df.query(expr)
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
raise ValueError(f"pandas query failed ({expr!r}): {exc}") from exc
|
|
60
|
+
return df
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _sql_query(args: argparse.Namespace, sql: str, table: str) -> pd.DataFrame:
|
|
64
|
+
"""Run *sql* against the input data registered as a DuckDB virtual table.
|
|
65
|
+
|
|
66
|
+
For named .parquet inputs the file is queried as a lazy DuckDB relation —
|
|
67
|
+
DuckDB pushes down filters and projections so the full dataset need not
|
|
68
|
+
fit in memory. All other inputs (TSV files, stdin, parquet-pipe) are
|
|
69
|
+
read into a pandas DataFrame first and then registered with DuckDB.
|
|
70
|
+
"""
|
|
71
|
+
if not _HAS_DUCKDB:
|
|
72
|
+
raise ImportError("--sql requires the duckdb package: pip install duckdb")
|
|
73
|
+
|
|
74
|
+
conn = duckdb.connect()
|
|
75
|
+
filename: str | None = getattr(args, "DATAFILE", None)
|
|
76
|
+
|
|
77
|
+
# Named parquet: let DuckDB read it lazily (potentially out-of-core).
|
|
78
|
+
direct_parquet = (
|
|
79
|
+
filename
|
|
80
|
+
and filename not in (None, "-")
|
|
81
|
+
and filename.endswith(".parquet")
|
|
82
|
+
and os.path.isfile(filename)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if direct_parquet:
|
|
86
|
+
rel = conn.read_parquet(filename)
|
|
87
|
+
conn.register(table, rel)
|
|
88
|
+
else:
|
|
89
|
+
# stdin, TSV files, or parquet-pipe: read into pandas first.
|
|
90
|
+
df = io.read(args)
|
|
91
|
+
conn.register(table, df)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
return conn.execute(sql).df()
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
raise ValueError(f"DuckDB SQL failed: {exc}") from exc
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Command
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
_EPILOG = """\
|
|
104
|
+
PANDAS QUERY MODE (-q / --query)
|
|
105
|
+
----------------------------------
|
|
106
|
+
Each expression is applied as a boolean row filter in sequence; multiple
|
|
107
|
+
-q flags are ANDed together. Syntax mirrors Python comparisons on column
|
|
108
|
+
names:
|
|
109
|
+
|
|
110
|
+
dftk query data.tsv -q "x > 0"
|
|
111
|
+
dftk query data.tsv -q "group == 'A'" -q "score >= 10"
|
|
112
|
+
dftk query data.tsv -q "x > 0 and y < 100"
|
|
113
|
+
dftk query data.tsv -q "label.str.startswith('foo')"
|
|
114
|
+
|
|
115
|
+
Backtick-quoting handles column names with spaces or special characters:
|
|
116
|
+
|
|
117
|
+
dftk query data.tsv -q "`my col` > 0"
|
|
118
|
+
|
|
119
|
+
This is a thin wrapper around pandas DataFrame.query().
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
SQL MODE (--sql)
|
|
123
|
+
------------------
|
|
124
|
+
A full SQL statement is passed to DuckDB. The input data is available as
|
|
125
|
+
a virtual table named "data" (or the name given by --table).
|
|
126
|
+
|
|
127
|
+
Simple filtering:
|
|
128
|
+
|
|
129
|
+
dftk query data.tsv --sql "SELECT * FROM data WHERE x > 0"
|
|
130
|
+
cat data.tsv | dftk query --sql "SELECT * FROM data WHERE x > 0" -o
|
|
131
|
+
|
|
132
|
+
Aggregation (replaces dftk pivot for simple cases):
|
|
133
|
+
|
|
134
|
+
dftk query data.tsv --sql \\
|
|
135
|
+
"SELECT group, AVG(x) AS mean, STDDEV(x) AS std, COUNT(*) AS n
|
|
136
|
+
FROM data GROUP BY group"
|
|
137
|
+
|
|
138
|
+
Window functions:
|
|
139
|
+
|
|
140
|
+
dftk query data.tsv --sql \\
|
|
141
|
+
"SELECT *, ROW_NUMBER() OVER (PARTITION BY group ORDER BY x DESC) AS rank
|
|
142
|
+
FROM data"
|
|
143
|
+
|
|
144
|
+
CTEs:
|
|
145
|
+
|
|
146
|
+
dftk query data.tsv --sql \\
|
|
147
|
+
"WITH ranked AS (
|
|
148
|
+
SELECT *, RANK() OVER (PARTITION BY group ORDER BY score DESC) AS rank
|
|
149
|
+
FROM data
|
|
150
|
+
)
|
|
151
|
+
SELECT * FROM ranked WHERE rank <= 3"
|
|
152
|
+
|
|
153
|
+
OUT-OF-CORE LARGE FILES
|
|
154
|
+
------------------------
|
|
155
|
+
When the input is a named .parquet file, DuckDB queries it natively without
|
|
156
|
+
loading the full dataset into memory. Filter pushdown and column pruning
|
|
157
|
+
mean that only the rows and columns needed by the query are read:
|
|
158
|
+
|
|
159
|
+
dftk query huge.parquet --sql "SELECT * FROM data WHERE x > 0"
|
|
160
|
+
dftk query huge.parquet --sql \\
|
|
161
|
+
"SELECT group, AVG(x) AS mean FROM data GROUP BY group"
|
|
162
|
+
|
|
163
|
+
For piped input (stdin or parquet-pipe), the data is loaded into pandas
|
|
164
|
+
first and then registered with DuckDB — in-memory, same as pandas mode.
|
|
165
|
+
Use a named checkpoint file (-o huge.parquet) when you need out-of-core
|
|
166
|
+
queries on a large intermediate result:
|
|
167
|
+
|
|
168
|
+
dftk eval "y = x * 2" huge.tsv -o huge.parquet
|
|
169
|
+
dftk query huge.parquet --sql "SELECT * FROM data WHERE y > 1000"
|
|
170
|
+
|
|
171
|
+
Requires: pip install duckdb
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class QueryCommand(BaseCommand):
|
|
176
|
+
name = "query"
|
|
177
|
+
help = "Filter rows with pandas expressions (-q) or full DuckDB SQL (--sql)."
|
|
178
|
+
|
|
179
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
180
|
+
parser.formatter_class = argparse.RawDescriptionHelpFormatter
|
|
181
|
+
parser.epilog = _EPILOG
|
|
182
|
+
|
|
183
|
+
self.add_io_arguments(parser)
|
|
184
|
+
|
|
185
|
+
g = parser.add_argument_group("query options")
|
|
186
|
+
g.add_argument(
|
|
187
|
+
"-q",
|
|
188
|
+
"--query",
|
|
189
|
+
action="append",
|
|
190
|
+
default=[],
|
|
191
|
+
metavar="EXPR",
|
|
192
|
+
help=(
|
|
193
|
+
"Pandas boolean expression for row filtering. "
|
|
194
|
+
"Repeatable; all expressions are ANDed together. "
|
|
195
|
+
"Cannot be combined with --sql."
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
g.add_argument(
|
|
199
|
+
"--sql",
|
|
200
|
+
default=None,
|
|
201
|
+
metavar="SQL",
|
|
202
|
+
help=(
|
|
203
|
+
"Full DuckDB SQL query. The input is registered as a virtual "
|
|
204
|
+
"table named 'data' (see --table). Named .parquet inputs are "
|
|
205
|
+
"queried natively without full load into memory. "
|
|
206
|
+
"Cannot be combined with -q. Requires duckdb."
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
g.add_argument(
|
|
210
|
+
"--table",
|
|
211
|
+
default="data",
|
|
212
|
+
metavar="NAME",
|
|
213
|
+
help=(
|
|
214
|
+
"Name of the virtual table in --sql queries (default: data). "
|
|
215
|
+
"Change this if your data has a column named 'data'."
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def execute(self, args: argparse.Namespace) -> None:
|
|
220
|
+
if args.sql is not None and args.query:
|
|
221
|
+
raise ValueError("Cannot combine -q/--query and --sql.")
|
|
222
|
+
|
|
223
|
+
if args.sql is not None:
|
|
224
|
+
df = _sql_query(args, args.sql, args.table)
|
|
225
|
+
else:
|
|
226
|
+
df = io.read(args)
|
|
227
|
+
if args.query:
|
|
228
|
+
df = _pandas_query(df, args.query)
|
|
229
|
+
|
|
230
|
+
io.printdf(df, args)
|