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,657 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dftk.commands.eval_cmd — dftk eval subcommand.
|
|
3
|
+
|
|
4
|
+
Evaluates formulas, constants, and string/row functions against a DataFrame.
|
|
5
|
+
Three formula types are supported, each with its own flag:
|
|
6
|
+
|
|
7
|
+
-f pandas eval expressions (df.eval, falls back to special_function)
|
|
8
|
+
-c constant column assignments
|
|
9
|
+
-s string / row functions
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import fnmatch
|
|
14
|
+
import hashlib
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from functools import partial
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
from scipy import special as _sp
|
|
23
|
+
|
|
24
|
+
from dftk.commands.base import BaseCommand
|
|
25
|
+
from dftk.common import seq as _seq
|
|
26
|
+
from dftk.common import stats as _stats
|
|
27
|
+
from dftk.common.io import io
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# Optional dependency: python-Levenshtein
|
|
32
|
+
try:
|
|
33
|
+
import Levenshtein as _lev
|
|
34
|
+
|
|
35
|
+
_HAS_LEVENSHTEIN = True
|
|
36
|
+
except ImportError:
|
|
37
|
+
_HAS_LEVENSHTEIN = False
|
|
38
|
+
|
|
39
|
+
_EPILOG = """\
|
|
40
|
+
FORMULA EXPRESSIONS (-f)
|
|
41
|
+
-------------------------
|
|
42
|
+
Each -f argument is first passed to pandas df.eval(). This handles
|
|
43
|
+
arithmetic and boolean expressions directly on column names:
|
|
44
|
+
|
|
45
|
+
dftk eval data.tsv -f "z = x + y * 2"
|
|
46
|
+
dftk eval data.tsv -f "flag = (pval < 0.05) & (effect > 0)"
|
|
47
|
+
|
|
48
|
+
If df.eval() fails (e.g. for function calls), the expression is retried
|
|
49
|
+
as a special function call using the syntax:
|
|
50
|
+
|
|
51
|
+
dest = func(col1, col2, ...)
|
|
52
|
+
|
|
53
|
+
Available special functions:
|
|
54
|
+
|
|
55
|
+
Row aggregation (applied across named columns):
|
|
56
|
+
sum mean std min max median
|
|
57
|
+
|
|
58
|
+
Conditional assignment:
|
|
59
|
+
where(cond_col, true_val, false_val)
|
|
60
|
+
— np.where-style conditional; cond_col must be a boolean column.
|
|
61
|
+
true_val and false_val can each be: a column name, a quoted
|
|
62
|
+
string literal ("foo"), or a numeric literal.
|
|
63
|
+
Example:
|
|
64
|
+
dftk eval data.tsv \
|
|
65
|
+
-f "big = value > 100" \
|
|
66
|
+
-f "label = where(big, \"large\", \"small\")"
|
|
67
|
+
|
|
68
|
+
Bitwise:
|
|
69
|
+
applymask(col, mask_col) — col & mask_col
|
|
70
|
+
overlap(lo1, hi1, lo2, hi2) — length of interval overlap
|
|
71
|
+
|
|
72
|
+
Column aggregation (glob pattern):
|
|
73
|
+
colsum(col*) — sum of all columns matching the glob pattern
|
|
74
|
+
|
|
75
|
+
NumPy math (element-wise):
|
|
76
|
+
sign(col) cbrt(col) log1p(col) expm1(col)
|
|
77
|
+
|
|
78
|
+
scipy.special — log-gamma / digamma:
|
|
79
|
+
gamma(col) lgamma(col) digamma(col)
|
|
80
|
+
beta(a, b) betaln(a, b)
|
|
81
|
+
|
|
82
|
+
scipy.special — error functions:
|
|
83
|
+
erf(col) erfc(col) erfinv(col)
|
|
84
|
+
|
|
85
|
+
scipy.special — sigmoid / logit:
|
|
86
|
+
expit(col) logit(col)
|
|
87
|
+
|
|
88
|
+
scipy.special — Bessel functions (fixed order):
|
|
89
|
+
j0(col) j1(col) — Bessel J, order 0 / 1
|
|
90
|
+
y0(col) y1(col) — Bessel Y, order 0 / 1
|
|
91
|
+
i0(col) i1(col) — modified Bessel I, order 0 / 1
|
|
92
|
+
k0(col) k1(col) — modified Bessel K, order 0 / 1
|
|
93
|
+
|
|
94
|
+
scipy.special — Bessel functions (parametric order):
|
|
95
|
+
jn(n, col) — Bessel J, integer order n
|
|
96
|
+
yn(n, col) — Bessel Y, integer order n
|
|
97
|
+
iv(v, col) — modified Bessel I, real order v
|
|
98
|
+
kv(v, col) — modified Bessel K, real order v
|
|
99
|
+
(n/v is a numeric literal, e.g. result = jn(3, x))
|
|
100
|
+
|
|
101
|
+
Path functions (applied element-wise):
|
|
102
|
+
basename dirname exists getsize realpath
|
|
103
|
+
|
|
104
|
+
Statistical functions (see common/stats.py):
|
|
105
|
+
binom_test(k, n, p)
|
|
106
|
+
fisher_test(a, b, c, d) — p-value
|
|
107
|
+
fisher_OR(a, b, c, d) — odds ratio
|
|
108
|
+
boschloo_test(a, b, c, d) — p-value
|
|
109
|
+
boschloo_OR(a, b, c, d) — odds ratio
|
|
110
|
+
pval2se(effect, pval)
|
|
111
|
+
t2pval(t, df)
|
|
112
|
+
chi2_to_neglogp(chi2, df)
|
|
113
|
+
neglogp_to_chi2(neglogp, df)
|
|
114
|
+
pval_to_chi2(pval, df)
|
|
115
|
+
generalized_poisson_nll(x, lam, theta)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
CONSTANT COLUMNS (-c)
|
|
119
|
+
----------------------
|
|
120
|
+
Add a column with a fixed value:
|
|
121
|
+
|
|
122
|
+
dftk eval data.tsv -c "batch = 3"
|
|
123
|
+
dftk eval data.tsv -c "label = control"
|
|
124
|
+
|
|
125
|
+
The value is coerced to numeric when possible.
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
STRING / ROW FUNCTIONS (-s)
|
|
129
|
+
----------------------------
|
|
130
|
+
Apply a string or row-level function using the same dest = func(cols) syntax:
|
|
131
|
+
|
|
132
|
+
String operations:
|
|
133
|
+
translate(col, from, to)
|
|
134
|
+
len(col)
|
|
135
|
+
join(col1, col2, ...) — joined with --joinsep (default: "")
|
|
136
|
+
lower(col) upper(col)
|
|
137
|
+
zfill(col, width)
|
|
138
|
+
find(col, substr)
|
|
139
|
+
match(col, pattern_col)
|
|
140
|
+
substr(col, start, length)
|
|
141
|
+
substring(col, start_col, length)
|
|
142
|
+
replace(col, old, new)
|
|
143
|
+
leftsplit(col, sep, idx) — split on first sep; idx=0 left part, 1 right
|
|
144
|
+
rightsplit(col, sep, idx) — split on last sep
|
|
145
|
+
sort(col) — sort characters in string
|
|
146
|
+
ord(col) — minimum ord value of characters
|
|
147
|
+
template(...) — Python str.format_map across all columns
|
|
148
|
+
|
|
149
|
+
Path operations:
|
|
150
|
+
basename(col) stem(col) realpath(col) isfile(col) exists(col)
|
|
151
|
+
|
|
152
|
+
Crypto / hash:
|
|
153
|
+
md5(col)
|
|
154
|
+
|
|
155
|
+
DNA / sequence (see common/seq.py):
|
|
156
|
+
gccontent(col)
|
|
157
|
+
basecount(col)
|
|
158
|
+
motifcount(pattern, seq_col)
|
|
159
|
+
readoffset(read_pos, ref_pos, cigar, seq, count)
|
|
160
|
+
|
|
161
|
+
Edit distance (requires python-Levenshtein):
|
|
162
|
+
levenshtein(col1, col2)
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# Formula parser
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _parse_formula(formula: str) -> tuple[str, str, list[str], str]:
|
|
171
|
+
"""Parse 'dest = func(col1, col2, ...)' into (dest, func, cols, body).
|
|
172
|
+
|
|
173
|
+
Returns the raw argument string as *body* so callers can use it as a
|
|
174
|
+
glob pattern (e.g. for colsum).
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
dest, right = (x.strip() for x in formula.split("=", 1))
|
|
178
|
+
func, right = (x.strip() for x in right.split("(", 1))
|
|
179
|
+
body, _ = (x.strip() for x in right.rsplit(")", 1))
|
|
180
|
+
except Exception:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"Bad formula syntax: {formula!r} Expected: dest = func(col1, col2, ...)"
|
|
183
|
+
) from None
|
|
184
|
+
cols = [x.strip() for x in body.split(",")]
|
|
185
|
+
return dest, func, cols, body
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Special functions (used as pandas agg fallback inside -f)
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
_STATS_DISPATCH = {
|
|
193
|
+
"binom_test": _stats.binom_test,
|
|
194
|
+
"fisher_test": _stats.fisher_test,
|
|
195
|
+
"fisher_OR": _stats.fisher_OR,
|
|
196
|
+
"boschloo_test": _stats.boschloo_test,
|
|
197
|
+
"boschloo_OR": _stats.boschloo_OR,
|
|
198
|
+
"pval2se": _stats.pval2se,
|
|
199
|
+
"t2pval": _stats.t2pval,
|
|
200
|
+
"chi2_to_neglogp": _stats.chi2_to_neglogp,
|
|
201
|
+
"neglogp_to_chi2": _stats.neglogp_to_chi2,
|
|
202
|
+
"pval_to_chi2": _stats.pval_to_chi2,
|
|
203
|
+
"generalized_poisson_nll": _stats.generalized_poisson_nll,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
_PATH_FUNCS = {"basename", "dirname", "exists", "getsize", "realpath"}
|
|
207
|
+
_NP_FUNCS = {"sign", "cbrt", "log1p", "expm1"}
|
|
208
|
+
_ROW_AGG_FUNCS = {"sum", "mean", "std", "min", "max", "median"}
|
|
209
|
+
_ROW_IDX_FUNCS = {"idxmax", "idxmin"}
|
|
210
|
+
|
|
211
|
+
# scipy.special single-column functions (element-wise, vectorised)
|
|
212
|
+
_SCIPY_SINGLE = {
|
|
213
|
+
"gamma": _sp.gamma, # Γ(x)
|
|
214
|
+
"lgamma": _sp.gammaln, # log Γ(x)
|
|
215
|
+
"digamma": _sp.digamma, # ψ(x) (log-derivative of Γ)
|
|
216
|
+
"erf": _sp.erf, # error function
|
|
217
|
+
"erfc": _sp.erfc, # complementary error function
|
|
218
|
+
"erfinv": _sp.erfinv, # inverse error function
|
|
219
|
+
"expit": _sp.expit, # logistic sigmoid 1/(1+exp(-x))
|
|
220
|
+
"logit": _sp.logit, # log(x/(1-x))
|
|
221
|
+
# Bessel J (first kind), fixed order
|
|
222
|
+
"j0": _sp.j0,
|
|
223
|
+
"j1": _sp.j1,
|
|
224
|
+
# Bessel Y (second kind), fixed order
|
|
225
|
+
"y0": _sp.y0,
|
|
226
|
+
"y1": _sp.y1,
|
|
227
|
+
# Modified Bessel I, fixed order
|
|
228
|
+
"i0": _sp.i0,
|
|
229
|
+
"i1": _sp.i1,
|
|
230
|
+
# Modified Bessel K, fixed order
|
|
231
|
+
"k0": _sp.k0,
|
|
232
|
+
"k1": _sp.k1,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# scipy.special two-column functions: func(col_a, col_b)
|
|
236
|
+
_SCIPY_DUAL = {
|
|
237
|
+
"beta": lambda x: _sp.beta(x[0], x[1]), # B(a, b)
|
|
238
|
+
"betaln": lambda x: _sp.betaln(x[0], x[1]), # log B(a, b)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
# scipy.special parametric Bessel: func_name(order_literal, col)
|
|
242
|
+
# Covers all-order variants: jn(n,x), yn(n,x), iv(v,x), kv(v,x)
|
|
243
|
+
_SCIPY_BESSEL_ORDER = {"jn", "yn", "iv", "kv"}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class _FrameFunc:
|
|
247
|
+
"""Callable that applies a named pandas DataFrame method along axis=1.
|
|
248
|
+
|
|
249
|
+
Used for idxmax/idxmin, which need the column labels and therefore cannot
|
|
250
|
+
go through the standard ``func(row.to_numpy())`` dispatch path.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
def __init__(self, method: str) -> None:
|
|
254
|
+
self.method = method
|
|
255
|
+
|
|
256
|
+
def __call__(self, df_subset):
|
|
257
|
+
return getattr(df_subset, self.method)(axis=1)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _resolve_where_val(val: str, df: pd.DataFrame):
|
|
261
|
+
"""Resolve a where() argument to a scalar, Series, or literal string.
|
|
262
|
+
|
|
263
|
+
Resolution order:
|
|
264
|
+
1. Quoted string literal ("foo" or 'foo')
|
|
265
|
+
2. Column name present in *df*
|
|
266
|
+
3. Integer literal
|
|
267
|
+
4. Float literal
|
|
268
|
+
5. Bare string (returned as-is)
|
|
269
|
+
"""
|
|
270
|
+
s = val.strip()
|
|
271
|
+
if (s.startswith('"') and s.endswith('"')) or (
|
|
272
|
+
s.startswith("'") and s.endswith("'")
|
|
273
|
+
):
|
|
274
|
+
return s[1:-1]
|
|
275
|
+
if s in df.columns:
|
|
276
|
+
return df[s]
|
|
277
|
+
try:
|
|
278
|
+
return int(s)
|
|
279
|
+
except ValueError:
|
|
280
|
+
pass
|
|
281
|
+
try:
|
|
282
|
+
return float(s)
|
|
283
|
+
except ValueError:
|
|
284
|
+
pass
|
|
285
|
+
return s
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class _WhereFunc:
|
|
289
|
+
"""Callable for ``where(cond_col, true_val, false_val)`` using ``np.where``.
|
|
290
|
+
|
|
291
|
+
Receives the full DataFrame so that *true_val* and *false_val* can be
|
|
292
|
+
resolved as column references, quoted string literals, or numeric literals.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
def __init__(self, cond_col: str, true_val: str, false_val: str) -> None:
|
|
296
|
+
self.cond_col = cond_col
|
|
297
|
+
self.true_val = true_val
|
|
298
|
+
self.false_val = false_val
|
|
299
|
+
|
|
300
|
+
def __call__(self, df: pd.DataFrame):
|
|
301
|
+
cond = df[self.cond_col]
|
|
302
|
+
true_res = _resolve_where_val(self.true_val, df)
|
|
303
|
+
false_res = _resolve_where_val(self.false_val, df)
|
|
304
|
+
return np.where(cond, true_res, false_res)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
_VALID_SPECIAL = (
|
|
308
|
+
{
|
|
309
|
+
"sum",
|
|
310
|
+
"mean",
|
|
311
|
+
"std",
|
|
312
|
+
"min",
|
|
313
|
+
"max",
|
|
314
|
+
"median",
|
|
315
|
+
"overlap",
|
|
316
|
+
"applymask",
|
|
317
|
+
"colsum",
|
|
318
|
+
"where",
|
|
319
|
+
}
|
|
320
|
+
| _ROW_IDX_FUNCS
|
|
321
|
+
| _PATH_FUNCS
|
|
322
|
+
| _NP_FUNCS
|
|
323
|
+
| set(_STATS_DISPATCH)
|
|
324
|
+
| set(_SCIPY_SINGLE)
|
|
325
|
+
| set(_SCIPY_DUAL)
|
|
326
|
+
| _SCIPY_BESSEL_ORDER
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _special_function(formula: str, df_columns: list[str]):
|
|
331
|
+
"""Return (dest, func, cols) for a special-function formula."""
|
|
332
|
+
dest, func_name, cols, body = _parse_formula(formula)
|
|
333
|
+
|
|
334
|
+
if func_name not in _VALID_SPECIAL:
|
|
335
|
+
raise ValueError(
|
|
336
|
+
f"Function {func_name!r} is not available as a "
|
|
337
|
+
"special function. See dftk eval --help."
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if func_name == "where":
|
|
341
|
+
if len(cols) != 3:
|
|
342
|
+
raise ValueError(
|
|
343
|
+
"where() requires exactly 3 arguments: "
|
|
344
|
+
"where(cond_col, true_val, false_val)"
|
|
345
|
+
)
|
|
346
|
+
return dest, _WhereFunc(cols[0], cols[1], cols[2]), []
|
|
347
|
+
|
|
348
|
+
if func_name == "overlap":
|
|
349
|
+
func = partial(lambda x: max(0, min(x[1], x[3]) - max(x[0] - 1, x[2] - 1)))
|
|
350
|
+
elif func_name == "applymask":
|
|
351
|
+
func = partial(lambda x: x[0] & x[1])
|
|
352
|
+
elif func_name == "colsum":
|
|
353
|
+
cols = fnmatch.filter(df_columns, body)
|
|
354
|
+
func = np.sum
|
|
355
|
+
elif func_name in _ROW_AGG_FUNCS:
|
|
356
|
+
func = getattr(np, func_name)
|
|
357
|
+
# Each argument may be a glob pattern: expand to matching column names
|
|
358
|
+
cols = [c for pat in cols for c in (fnmatch.filter(df_columns, pat) or [pat])]
|
|
359
|
+
elif func_name in _ROW_IDX_FUNCS:
|
|
360
|
+
func = _FrameFunc(func_name)
|
|
361
|
+
cols = [c for pat in cols for c in (fnmatch.filter(df_columns, pat) or [pat])]
|
|
362
|
+
elif func_name in _NP_FUNCS:
|
|
363
|
+
func = getattr(np, func_name)
|
|
364
|
+
elif func_name in _PATH_FUNCS:
|
|
365
|
+
func = np.frompyfunc(getattr(os.path, func_name), 1, 1)
|
|
366
|
+
elif func_name in _STATS_DISPATCH:
|
|
367
|
+
func = _STATS_DISPATCH[func_name]
|
|
368
|
+
elif func_name in _SCIPY_SINGLE:
|
|
369
|
+
func = _SCIPY_SINGLE[func_name]
|
|
370
|
+
elif func_name in _SCIPY_DUAL:
|
|
371
|
+
func = _SCIPY_DUAL[func_name]
|
|
372
|
+
elif func_name in _SCIPY_BESSEL_ORDER:
|
|
373
|
+
try:
|
|
374
|
+
order = float(cols[0])
|
|
375
|
+
except (ValueError, IndexError):
|
|
376
|
+
raise ValueError(
|
|
377
|
+
f"{func_name}() first argument must be a numeric order literal, "
|
|
378
|
+
f"e.g. result = {func_name}(2, xcol)"
|
|
379
|
+
) from None
|
|
380
|
+
func = partial(getattr(_sp, func_name), order)
|
|
381
|
+
cols = cols[1:]
|
|
382
|
+
else:
|
|
383
|
+
func = func_name # bare string passed to pandas agg
|
|
384
|
+
|
|
385
|
+
return dest, func, cols
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
# String / row functions (used by -s)
|
|
390
|
+
# ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _string_function(formula: str, joinsep: str):
|
|
394
|
+
"""Return (dest, func, cols) for a string/row-function formula."""
|
|
395
|
+
dest, func_name, cols, _body = _parse_formula(formula)
|
|
396
|
+
|
|
397
|
+
if func_name == "translate":
|
|
398
|
+
trs = str.maketrans(*cols[1:])
|
|
399
|
+
return dest, partial(lambda x: x.translate(trs)), cols[0:1]
|
|
400
|
+
if func_name == "len":
|
|
401
|
+
return dest, partial(lambda x: len(str(x))), cols
|
|
402
|
+
if func_name == "join":
|
|
403
|
+
return dest, partial(lambda x: joinsep.join(str(v) for v in x)), cols
|
|
404
|
+
if func_name == "lower":
|
|
405
|
+
return dest, partial(lambda x: str(x).lower()), cols
|
|
406
|
+
if func_name == "upper":
|
|
407
|
+
return dest, partial(lambda x: str(x).upper()), cols
|
|
408
|
+
if func_name == "zfill":
|
|
409
|
+
return dest, partial(lambda x: str(x).zfill(int(cols[1]))), cols[0:1]
|
|
410
|
+
if func_name == "find":
|
|
411
|
+
return dest, partial(lambda x: str(x).find(str(cols[1]))), cols[0:1]
|
|
412
|
+
if func_name == "match":
|
|
413
|
+
return dest, partial(lambda x: str(x[0]).find(str(x[1]))), cols
|
|
414
|
+
if func_name == "substr":
|
|
415
|
+
start, length = int(cols[1]), int(cols[2])
|
|
416
|
+
return dest, partial(lambda x: str(x)[start : start + length]), cols[0:1]
|
|
417
|
+
if func_name == "substring":
|
|
418
|
+
length = int(cols[2])
|
|
419
|
+
return dest, partial(lambda x: str(x[0])[x[1] : x[1] + length]), cols[0:2]
|
|
420
|
+
if func_name == "replace":
|
|
421
|
+
return dest, partial(lambda x: str(x).replace(cols[1], cols[2])), cols[0:1]
|
|
422
|
+
|
|
423
|
+
if func_name == "leftsplit":
|
|
424
|
+
sep, idx = cols[1], int(cols[2])
|
|
425
|
+
|
|
426
|
+
def _leftsplit(x):
|
|
427
|
+
s = str(x)
|
|
428
|
+
if sep in s:
|
|
429
|
+
parts = s.split(sep, 1)
|
|
430
|
+
return parts[0] if idx == 0 else parts[1]
|
|
431
|
+
return s if idx == 0 else ""
|
|
432
|
+
|
|
433
|
+
return dest, _leftsplit, cols[0:1]
|
|
434
|
+
|
|
435
|
+
if func_name == "rightsplit":
|
|
436
|
+
sep, idx = cols[1], int(cols[2])
|
|
437
|
+
|
|
438
|
+
def _rightsplit(x):
|
|
439
|
+
s = str(x)
|
|
440
|
+
if sep in s:
|
|
441
|
+
parts = s.rsplit(sep, 1)
|
|
442
|
+
return parts[0] if idx == 0 else parts[1]
|
|
443
|
+
return s if idx == 0 else ""
|
|
444
|
+
|
|
445
|
+
return dest, _rightsplit, cols[0:1]
|
|
446
|
+
|
|
447
|
+
if func_name == "levenshtein":
|
|
448
|
+
if not _HAS_LEVENSHTEIN:
|
|
449
|
+
raise ImportError(
|
|
450
|
+
"levenshtein() requires the python-Levenshtein package. "
|
|
451
|
+
"Install it with: uv add python-Levenshtein"
|
|
452
|
+
)
|
|
453
|
+
return dest, partial(lambda x: _lev.distance(str(x[0]), str(x[1]))), cols[0:2]
|
|
454
|
+
|
|
455
|
+
if func_name == "sort":
|
|
456
|
+
return dest, partial(lambda x: "".join(sorted(str(x)))), cols
|
|
457
|
+
if func_name == "ord":
|
|
458
|
+
|
|
459
|
+
def _myord(x):
|
|
460
|
+
try:
|
|
461
|
+
return min(ord(c) for c in str(x))
|
|
462
|
+
except (TypeError, ValueError):
|
|
463
|
+
return 0
|
|
464
|
+
|
|
465
|
+
return dest, _myord, cols[0:1]
|
|
466
|
+
if func_name == "template":
|
|
467
|
+
return dest, partial(lambda x: _body.format(**x)), []
|
|
468
|
+
if func_name == "md5":
|
|
469
|
+
return (
|
|
470
|
+
dest,
|
|
471
|
+
partial(lambda x: hashlib.md5(str(x).encode()).hexdigest()),
|
|
472
|
+
cols[0:1],
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# Path operations
|
|
476
|
+
if func_name == "basename":
|
|
477
|
+
return dest, partial(lambda x: os.path.basename(str(x))), cols
|
|
478
|
+
if func_name == "stem":
|
|
479
|
+
return dest, partial(lambda x: Path(str(x)).stem), cols
|
|
480
|
+
if func_name == "realpath":
|
|
481
|
+
return dest, partial(lambda x: str(Path(str(x)).resolve())), cols
|
|
482
|
+
if func_name == "isfile":
|
|
483
|
+
return dest, partial(lambda x: int(os.path.isfile(str(x)))), cols
|
|
484
|
+
if func_name == "exists":
|
|
485
|
+
return dest, partial(lambda x: int(os.path.exists(str(x)))), cols
|
|
486
|
+
|
|
487
|
+
# DNA / sequence operations
|
|
488
|
+
if func_name == "gccontent":
|
|
489
|
+
return dest, partial(lambda x: _seq.gc_content(x)), cols
|
|
490
|
+
if func_name == "basecount":
|
|
491
|
+
return dest, partial(lambda x: _seq.letter_total(x)), cols
|
|
492
|
+
if func_name == "motifcount":
|
|
493
|
+
pattern = cols[0]
|
|
494
|
+
return dest, partial(lambda x: _seq.count_motif(pattern, x)), cols[1:]
|
|
495
|
+
if func_name == "readoffset":
|
|
496
|
+
return dest, partial(lambda x: _seq.read_offset(*x)), cols
|
|
497
|
+
|
|
498
|
+
raise ValueError(
|
|
499
|
+
f"String function {func_name!r} is not available. See dftk eval --help."
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# ---------------------------------------------------------------------------
|
|
504
|
+
# Core eval logic
|
|
505
|
+
# ---------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _eval(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
|
|
509
|
+
joinsep: str = getattr(args, "joinsep", "")
|
|
510
|
+
intbool: bool = getattr(args, "intbool", False)
|
|
511
|
+
existing = set(df.columns)
|
|
512
|
+
|
|
513
|
+
# -c: constant columns
|
|
514
|
+
for expr in getattr(args, "constant", []):
|
|
515
|
+
colname, constval_str = (x.strip() for x in expr.split("=", 1))
|
|
516
|
+
try:
|
|
517
|
+
constval: int | float | str = int(constval_str)
|
|
518
|
+
except ValueError:
|
|
519
|
+
try:
|
|
520
|
+
constval = float(constval_str)
|
|
521
|
+
except ValueError:
|
|
522
|
+
constval = constval_str
|
|
523
|
+
df[colname] = constval
|
|
524
|
+
|
|
525
|
+
# -f: pandas eval with special-function fallback
|
|
526
|
+
for formula in getattr(args, "formula", []):
|
|
527
|
+
try:
|
|
528
|
+
df.eval(formula, inplace=True)
|
|
529
|
+
except (ValueError, SyntaxError):
|
|
530
|
+
logger.debug(f"df.eval failed for {formula!r}, trying special_function")
|
|
531
|
+
dest, func, cols = _special_function(formula, list(df.columns))
|
|
532
|
+
if len(df) == 0:
|
|
533
|
+
df[dest] = True
|
|
534
|
+
elif isinstance(func, _WhereFunc):
|
|
535
|
+
df[dest] = func(df)
|
|
536
|
+
elif isinstance(func, _FrameFunc):
|
|
537
|
+
# Frame-level functions (idxmax/idxmin) need column labels
|
|
538
|
+
df[dest] = func(df[cols])
|
|
539
|
+
elif len(cols) == 1:
|
|
540
|
+
# Element-wise on a single column (e.g. np.sign, os.path.*)
|
|
541
|
+
df[dest] = df[cols[0]].apply(func)
|
|
542
|
+
else:
|
|
543
|
+
# Multi-column row function: pass numpy array so positional
|
|
544
|
+
# indexing (row[0], row[1], ...) works in pandas 2.x
|
|
545
|
+
df[dest] = df[cols].apply(lambda row, f=func: f(row.to_numpy()), axis=1)
|
|
546
|
+
except Exception:
|
|
547
|
+
if len(df) == 0:
|
|
548
|
+
dest = formula.split("=", 1)[0].strip()
|
|
549
|
+
df[dest] = True
|
|
550
|
+
else:
|
|
551
|
+
logger.error(f"Could not evaluate formula: {formula!r}")
|
|
552
|
+
raise
|
|
553
|
+
|
|
554
|
+
# -s: string / row functions
|
|
555
|
+
for strfunc in getattr(args, "strfunc", []):
|
|
556
|
+
try:
|
|
557
|
+
dest, func, cols = _string_function(strfunc, joinsep)
|
|
558
|
+
if len(df) == 0:
|
|
559
|
+
df[dest] = True
|
|
560
|
+
elif len(cols) == 0:
|
|
561
|
+
df[dest] = df.apply(func, axis=1)
|
|
562
|
+
elif len(cols) == 1:
|
|
563
|
+
df[dest] = df[cols[0]].apply(func)
|
|
564
|
+
else:
|
|
565
|
+
df[dest] = df[cols].apply(func, axis=1)
|
|
566
|
+
except Exception:
|
|
567
|
+
if len(df) == 0:
|
|
568
|
+
dest = strfunc.split("=", 1)[0].strip()
|
|
569
|
+
df[dest] = True
|
|
570
|
+
else:
|
|
571
|
+
logger.error(f"Could not evaluate string function: {strfunc!r}")
|
|
572
|
+
raise
|
|
573
|
+
|
|
574
|
+
# --intbool: convert newly created boolean columns to int
|
|
575
|
+
if intbool:
|
|
576
|
+
new_cols = set(df.columns) - existing
|
|
577
|
+
for col in new_cols:
|
|
578
|
+
if df[col].dtype == np.bool_:
|
|
579
|
+
df[col] = df[col].astype(np.int64)
|
|
580
|
+
|
|
581
|
+
return df
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
# ---------------------------------------------------------------------------
|
|
585
|
+
# Command
|
|
586
|
+
# ---------------------------------------------------------------------------
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
class EvalCommand(BaseCommand):
|
|
590
|
+
@property
|
|
591
|
+
def name(self) -> str:
|
|
592
|
+
return "eval"
|
|
593
|
+
|
|
594
|
+
@property
|
|
595
|
+
def help(self) -> str:
|
|
596
|
+
return "Evaluate formulas, constants, and string functions on a DataFrame"
|
|
597
|
+
|
|
598
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
599
|
+
import argparse as ap
|
|
600
|
+
|
|
601
|
+
parser.formatter_class = ap.RawDescriptionHelpFormatter
|
|
602
|
+
parser.epilog = _EPILOG
|
|
603
|
+
|
|
604
|
+
io.parser_read(parser)
|
|
605
|
+
|
|
606
|
+
g = parser.add_argument_group("formula evaluation")
|
|
607
|
+
g.add_argument(
|
|
608
|
+
"-f",
|
|
609
|
+
"--formula",
|
|
610
|
+
help=(
|
|
611
|
+
"pandas eval expression, or special function call "
|
|
612
|
+
"(dest = func(col1, col2, ...)). Repeatable."
|
|
613
|
+
),
|
|
614
|
+
default=[],
|
|
615
|
+
action="append",
|
|
616
|
+
metavar="EXPR",
|
|
617
|
+
)
|
|
618
|
+
g.add_argument(
|
|
619
|
+
"-c",
|
|
620
|
+
"--constant",
|
|
621
|
+
help=(
|
|
622
|
+
"Add a constant column (dest = value). "
|
|
623
|
+
"Value is coerced to numeric when possible. Repeatable."
|
|
624
|
+
),
|
|
625
|
+
default=[],
|
|
626
|
+
action="append",
|
|
627
|
+
metavar="EXPR",
|
|
628
|
+
)
|
|
629
|
+
g.add_argument(
|
|
630
|
+
"-s",
|
|
631
|
+
"--strfunc",
|
|
632
|
+
help=(
|
|
633
|
+
"String or row function (dest = func(col1, col2, ...)). "
|
|
634
|
+
"Repeatable. See --help for full function list."
|
|
635
|
+
),
|
|
636
|
+
default=[],
|
|
637
|
+
action="append",
|
|
638
|
+
metavar="EXPR",
|
|
639
|
+
)
|
|
640
|
+
g.add_argument(
|
|
641
|
+
"--joinsep",
|
|
642
|
+
help="Separator used by the join() string function (default: empty).",
|
|
643
|
+
default="",
|
|
644
|
+
metavar="SEP",
|
|
645
|
+
)
|
|
646
|
+
g.add_argument(
|
|
647
|
+
"--intbool",
|
|
648
|
+
help="Convert newly created boolean columns to int (False=0, True=1).",
|
|
649
|
+
action="store_true",
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
io.parser_output(parser)
|
|
653
|
+
|
|
654
|
+
def execute(self, args: argparse.Namespace) -> None:
|
|
655
|
+
df = io.read(args)
|
|
656
|
+
df = _eval(df, args)
|
|
657
|
+
io.printdf(df, args)
|