dataframe-toolkit 0.9.1__tar.gz → 0.11.0__tar.gz

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.
Files changed (50) hide show
  1. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/PKG-INFO +35 -1
  2. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/README.md +34 -0
  3. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/pyproject.toml +1 -1
  4. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/pyproject.toml.orig +1 -1
  5. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/__init__.py +2 -0
  6. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/eval_cmd.py +148 -23
  7. dataframe_toolkit-0.11.0/src/dftk/commands/explode_cmd.py +180 -0
  8. dataframe_toolkit-0.11.0/src/dftk/commands/implode_cmd.py +189 -0
  9. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/__init__.py +0 -0
  10. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/cli.py +0 -0
  11. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/annotate_cmd.py +0 -0
  12. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/base.py +0 -0
  13. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/binx_cmd.py +0 -0
  14. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/chi_cmd.py +0 -0
  15. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/clean_cmd.py +0 -0
  16. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/cleannames_cmd.py +0 -0
  17. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/concat_cmd.py +0 -0
  18. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/corr_cmd.py +0 -0
  19. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/dataset_cmd.py +0 -0
  20. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/describe_cmd.py +0 -0
  21. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/fit_cmd.py +0 -0
  22. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/func_cmd.py +0 -0
  23. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/help.py +0 -0
  24. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/hist_cmd.py +0 -0
  25. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/info_cmd.py +0 -0
  26. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/interp_cmd.py +0 -0
  27. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/line_cmd.py +0 -0
  28. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/melt_cmd.py +0 -0
  29. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/merge_cmd.py +0 -0
  30. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/pivot_cmd.py +0 -0
  31. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/print_cmd.py +0 -0
  32. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/query_cmd.py +0 -0
  33. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/randvar_cmd.py +0 -0
  34. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/sample_cmd.py +0 -0
  35. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/scale_cmd.py +0 -0
  36. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/scat_cmd.py +0 -0
  37. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/segid_cmd.py +0 -0
  38. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/split_cmd.py +0 -0
  39. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/stat_cmd.py +0 -0
  40. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/test_cmd.py +0 -0
  41. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/transpose_cmd.py +0 -0
  42. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/commands/wstat_cmd.py +0 -0
  43. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/common/__init__.py +0 -0
  44. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/common/copula.py +0 -0
  45. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/common/io.py +0 -0
  46. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/common/plot.py +0 -0
  47. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/common/seed.py +0 -0
  48. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/common/seq.py +0 -0
  49. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/common/stats.py +0 -0
  50. {dataframe_toolkit-0.9.1 → dataframe_toolkit-0.11.0}/src/dftk/io.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dataframe-toolkit
3
- Version: 0.9.1
3
+ Version: 0.11.0
4
4
  Summary: A command-line toolkit for DataFrame analysis and manipulation
5
5
  Keywords: dataframe,statistics,data analysis,CLI,TSV,pandas
6
6
  Author: Gunnar Pálsson
@@ -116,6 +116,8 @@ dftk dataset tips -o \
116
116
  | `merge` | Join two tables on key columns (inner/left/right/outer) |
117
117
  | `concat` | Concatenate two or more tables row-wise |
118
118
  | `melt` | Reshape wide-to-long (`pd.melt`) |
119
+ | `explode` | Split separated values in a cell (`a,b`) into one row per value |
120
+ | `implode` | Join a column's values within groups into one cell, the inverse of `explode` |
119
121
  | `pivot` | Reshape long-to-wide with per-cell aggregation |
120
122
  | `func` | Column transforms: cumsum, group mean/sum/min/max/count/median/std, rank, qcut:N |
121
123
  | `scale` | Normalise: z-score, min-shift, sum/max/mean scaling, Blom rank, regression residuals |
@@ -222,6 +224,38 @@ printf "sample\tfluor\ns1\t60\ns2\t110\n" \
222
224
  | dftk interp - --ref stdcurve.tsv -x fluor --refx fluor -v conc -d conc_ng_ul
223
225
  ```
224
226
 
227
+ ### Filling missing values
228
+
229
+ `eval -f` takes pandas column methods (`COL.method(...)`), and `fillna`,
230
+ `ffill`, `bfill` and `interpolate` fill empty cells. Defaults from another
231
+ table, after a merge (a column in both files gets the suffix `_r`):
232
+
233
+ ```bash
234
+ dftk merge data.tsv -r defaults.tsv -k type -t left -o \
235
+ | dftk eval ... -f "depth = depth.fillna(depth_r)" --drop depth_r
236
+ ```
237
+
238
+ The group mean, a fixed value, or the last value above (`ffill`) within
239
+ each group:
240
+
241
+ ```bash
242
+ dftk dataset penguins -o | dftk eval ... \
243
+ -f "mean = body_mass_g.groupby(species).transform('mean')" \
244
+ -f "body_mass_g = body_mass_g.fillna(mean)" --drop mean \
245
+ -f "sex = sex.fillna('unknown')"
246
+
247
+ dftk dataset healthexp -o \
248
+ | dftk eval ... -f "le = Life_Expectancy.where(Year % 10 != 5)" -o \
249
+ | dftk eval ... -f "le_filled = le.groupby(Country).ffill()"
250
+ ```
251
+
252
+ `ffill`, `bfill` and `interpolate` use the rows in the order they are in:
253
+ sort in a step of its own first (`dftk eval data.tsv --sortasc site day -o
254
+ | dftk eval ... -f ...`), and use `groupby(...)` when the table holds
255
+ several series, or values are taken from the neighbouring group.
256
+ `interpolate()` has no grouped form and works by row position. `dftk eval
257
+ -h` covers these cases, with examples.
258
+
225
259
  ### Bootstrap confidence intervals
226
260
 
227
261
  Bootstrap mode repeats the full stat computation N times on resampled data
@@ -87,6 +87,8 @@ dftk dataset tips -o \
87
87
  | `merge` | Join two tables on key columns (inner/left/right/outer) |
88
88
  | `concat` | Concatenate two or more tables row-wise |
89
89
  | `melt` | Reshape wide-to-long (`pd.melt`) |
90
+ | `explode` | Split separated values in a cell (`a,b`) into one row per value |
91
+ | `implode` | Join a column's values within groups into one cell, the inverse of `explode` |
90
92
  | `pivot` | Reshape long-to-wide with per-cell aggregation |
91
93
  | `func` | Column transforms: cumsum, group mean/sum/min/max/count/median/std, rank, qcut:N |
92
94
  | `scale` | Normalise: z-score, min-shift, sum/max/mean scaling, Blom rank, regression residuals |
@@ -193,6 +195,38 @@ printf "sample\tfluor\ns1\t60\ns2\t110\n" \
193
195
  | dftk interp - --ref stdcurve.tsv -x fluor --refx fluor -v conc -d conc_ng_ul
194
196
  ```
195
197
 
198
+ ### Filling missing values
199
+
200
+ `eval -f` takes pandas column methods (`COL.method(...)`), and `fillna`,
201
+ `ffill`, `bfill` and `interpolate` fill empty cells. Defaults from another
202
+ table, after a merge (a column in both files gets the suffix `_r`):
203
+
204
+ ```bash
205
+ dftk merge data.tsv -r defaults.tsv -k type -t left -o \
206
+ | dftk eval ... -f "depth = depth.fillna(depth_r)" --drop depth_r
207
+ ```
208
+
209
+ The group mean, a fixed value, or the last value above (`ffill`) within
210
+ each group:
211
+
212
+ ```bash
213
+ dftk dataset penguins -o | dftk eval ... \
214
+ -f "mean = body_mass_g.groupby(species).transform('mean')" \
215
+ -f "body_mass_g = body_mass_g.fillna(mean)" --drop mean \
216
+ -f "sex = sex.fillna('unknown')"
217
+
218
+ dftk dataset healthexp -o \
219
+ | dftk eval ... -f "le = Life_Expectancy.where(Year % 10 != 5)" -o \
220
+ | dftk eval ... -f "le_filled = le.groupby(Country).ffill()"
221
+ ```
222
+
223
+ `ffill`, `bfill` and `interpolate` use the rows in the order they are in:
224
+ sort in a step of its own first (`dftk eval data.tsv --sortasc site day -o
225
+ | dftk eval ... -f ...`), and use `groupby(...)` when the table holds
226
+ several series, or values are taken from the neighbouring group.
227
+ `interpolate()` has no grouped form and works by row position. `dftk eval
228
+ -h` covers these cases, with examples.
229
+
196
230
  ### Bootstrap confidence intervals
197
231
 
198
232
  Bootstrap mode repeats the full stat computation N times on resampled data
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataframe-toolkit"
3
- version = "0.9.1"
3
+ version = "0.11.0"
4
4
  description = "A command-line toolkit for DataFrame analysis and manipulation"
5
5
  readme = "README.md"
6
6
  keywords = [
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataframe-toolkit"
3
- version = "0.9.1"
3
+ version = "0.11.0"
4
4
  description = "A command-line toolkit for DataFrame analysis and manipulation"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -30,6 +30,8 @@ COMMAND_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [
30
30
  ("merge", "merge_cmd:MergeCommand"),
31
31
  ("concat", "concat_cmd:ConcatCommand"),
32
32
  ("melt", "melt_cmd:MeltCommand"),
33
+ ("explode", "explode_cmd:ExplodeCommand"),
34
+ ("implode", "implode_cmd:ImplodeCommand"),
33
35
  ("pivot", "pivot_cmd:PivotCommand"),
34
36
  ("func", "func_cmd:FuncCommand"),
35
37
  ("scale", "scale_cmd:ScaleCommand"),
@@ -14,6 +14,7 @@ import fnmatch
14
14
  import hashlib
15
15
  import logging
16
16
  import os
17
+ import re
17
18
  from functools import partial
18
19
  from pathlib import Path
19
20
 
@@ -44,6 +45,88 @@ arithmetic and boolean expressions directly on column names:
44
45
  dftk dataset tips -o | dftk eval ... -f "bill_and_tip = total_bill + tip * 2"
45
46
  dftk dataset tips -o | dftk eval ... -f "good_tip = (tip > 3) & (total_bill < 30)"
46
47
 
48
+ Column methods are pandas Series methods, written COL.method(...); text
49
+ methods are under .str (name.str.lower(), not name.lower() or tolower()):
50
+
51
+ x = x.abs() x = x.round(1) x = x.clip(lower=0) y = name.str.upper()
52
+
53
+
54
+ FILLING MISSING VALUES (-f)
55
+ ---------------------------
56
+ fillna() fills the empty cells of a column; the other cells are kept:
57
+
58
+ dftk eval data.tsv -f "x = x.fillna(y)" from another column
59
+ -f "x = x.fillna(0)" -f "sex = sex.fillna('unknown')" a fixed value
60
+ -f "x = x.fillna(y).fillna(0)" y first, then 0
61
+ -f "x_filled = x.fillna(y)" keep x, add a new column
62
+ -f "missing = x.isna()" mark the empty cells
63
+
64
+ Defaults from another table: a column that is in both files gets the
65
+ suffix _r from merge, so fill from it and drop it. Here the defaults are
66
+ each species' median body mass:
67
+
68
+ dftk dataset penguins -o | dftk pivot ... -i species -v body_mass_g \\
69
+ -f median --move median_body_mass_g:body_mass_g -o defaults.parquet
70
+ dftk dataset penguins -o \\
71
+ | dftk merge ... -r defaults.parquet -k species -t left -o \\
72
+ | dftk eval ... -f "body_mass_g = body_mass_g.fillna(body_mass_g_r)" \\
73
+ --drop body_mass_g_r
74
+
75
+ The group mean (or 'median', 'min', ...), in one step:
76
+
77
+ dftk dataset penguins -o | dftk eval ... \\
78
+ -f "mean = body_mass_g.groupby(species).transform('mean')" \\
79
+ -f "body_mass_g = body_mass_g.fillna(mean)" --drop mean
80
+
81
+ From neighbouring rows (the co2 dataset is a weekly series with gaps):
82
+
83
+ dftk dataset co2 -o | dftk eval ... -f "ffill = co2.ffill()" \\
84
+ -f "bfill = co2.bfill()" -f "linear = co2.interpolate()"
85
+
86
+ ffill() the last value above; the first rows stay empty
87
+ bfill() the next value below; the last rows stay empty
88
+ ffill(limit=1) fill at most one row after each value
89
+ interpolate() a straight line between the values above and below, by
90
+ row position: rows must be evenly spaced (for x-based
91
+ lookup from a reference curve, see dftk interp)
92
+
93
+ Row order matters: these methods use the rows in the order they are in.
94
+ Sort first, in a step of its own (--sortasc is applied to the output, so
95
+ in the same command it comes after the fill):
96
+
97
+ dftk eval data.tsv --sortasc site day -o | dftk eval ... -f "..."
98
+
99
+ Several series in one table (sites, countries, samples) must be filled
100
+ within each group, or values are taken from the neighbouring group:
101
+
102
+ -f "x = x.groupby(site).ffill()"
103
+ -f "x = x.groupby(site).bfill()"
104
+ -f "x = x.groupby(site).ffill().fillna(x.groupby(site).bfill())"
105
+
106
+ A group's rows don't have to be next to each other, but within each group
107
+ they must be in order. In healthexp, rows are sorted by Year with the
108
+ countries mixed together, so a grouped ffill is right as it is:
109
+
110
+ dftk dataset healthexp -o \\
111
+ | dftk eval ... -f "le = Life_Expectancy.where(Year % 10 != 5)" -o \\
112
+ | dftk eval ... -f "le_filled = le.groupby(Country).ffill()"
113
+
114
+ interpolate() has no grouped form. Sort so that each group's rows are
115
+ together; then gaps inside a group are right, but a gap at the start or
116
+ end of a group is filled across the boundary. To leave those empty, keep
117
+ only values with something before and after them in the same group:
118
+
119
+ dftk dataset healthexp -o \\
120
+ | dftk eval ... -f "le = Life_Expectancy.where(Year % 10 != 5)" \\
121
+ --sortasc Country Year -o \\
122
+ | dftk eval ... -f "before = le.groupby(Country).ffill().notna()" \\
123
+ -f "after = le.groupby(Country).bfill().notna()" \\
124
+ -f "le_linear = le.interpolate().where(before & after)" \\
125
+ --drop before after
126
+
127
+ Only empty cells are filled; missing rows (a year with no row at all) are
128
+ not added.
129
+
47
130
  If df.eval() fails (e.g. for function calls), the expression is retried
48
131
  as a special function call using the syntax:
49
132
 
@@ -516,6 +599,68 @@ def _string_function(formula: str, joinsep: str):
516
599
  # ---------------------------------------------------------------------------
517
600
 
518
601
 
602
+ def _formula_error(formula: str, exc: Exception) -> str:
603
+ """Return a short message for a mistake pandas found in an -f formula."""
604
+ msg = str(exc)
605
+ m = re.search(r"'Series' object has no attribute '(\w+)'", msg)
606
+ if m:
607
+ name = m.group(1)
608
+ col = re.search(rf"(\w+)\.{name}\b", formula)
609
+ col = col.group(1) if col else "COL"
610
+ if hasattr(pd.Series.str, name):
611
+ hint = f"Text methods are under .str: {col}.str.{name}()."
612
+ else:
613
+ hint = f"Text methods are under .str, e.g. {col}.str.lower()."
614
+ return (
615
+ f"-f {formula!r}: column has no method {name!r}. {hint} See dftk eval -h."
616
+ )
617
+ return f"-f {formula!r}: {msg}"
618
+
619
+
620
+ def _apply_special_function(df: pd.DataFrame, formula: str) -> None:
621
+ """Evaluate a special-function formula and store its column in *df*."""
622
+ dest, func, cols = _special_function(formula, list(df.columns))
623
+ if len(df) == 0:
624
+ df[dest] = True
625
+ elif isinstance(func, _WhereFunc):
626
+ df[dest] = func(df)
627
+ elif isinstance(func, _FrameFunc):
628
+ # Frame-level functions (idxmax/idxmin) need column labels
629
+ df[dest] = func(df[cols])
630
+ elif len(cols) == 1:
631
+ # Element-wise on a single column (e.g. np.sign, os.path.*)
632
+ df[dest] = df[cols[0]].apply(func)
633
+ else:
634
+ # Multi-column row function: pass numpy array so positional
635
+ # indexing (row[0], row[1], ...) works in pandas 2.x
636
+ df[dest] = df[cols].apply(lambda row, f=func: f(row.to_numpy()), axis=1)
637
+
638
+
639
+ def _eval_formula(df: pd.DataFrame, formula: str) -> pd.DataFrame:
640
+ """Evaluate one -f formula: pandas df.eval(), else a special function."""
641
+ try:
642
+ # Not inplace: that writes into the existing column, which must keep
643
+ # its dtype, so x = x.fillna(y) or x = x.isna() failed whenever the
644
+ # result had another dtype
645
+ return df.eval(formula)
646
+ except (AttributeError, TypeError, NameError, KeyError) as exc:
647
+ # pandas ran the expression: a mistake in it, not a dftk bug
648
+ raise ValueError(_formula_error(formula, exc)) from exc
649
+ except (ValueError, SyntaxError) as eval_exc:
650
+ if re.match(r"\s*\w+\s*=\s*\w+\.", formula):
651
+ # dest = col.method(...) is never a special function
652
+ raise ValueError(_formula_error(formula, eval_exc)) from eval_exc
653
+ logger.debug(f"df.eval failed for {formula!r}, trying special_function")
654
+ try:
655
+ _apply_special_function(df, formula)
656
+ except ValueError as exc:
657
+ raise ValueError(f"{exc} (pandas df.eval: {eval_exc})") from exc
658
+ except Exception:
659
+ logger.error(f"Could not evaluate formula: {formula!r}")
660
+ raise
661
+ return df
662
+
663
+
519
664
  def _eval(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
520
665
  joinsep: str = getattr(args, "joinsep", "")
521
666
  intbool: bool = getattr(args, "intbool", False)
@@ -536,31 +681,11 @@ def _eval(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
536
681
  # -f: pandas eval with special-function fallback
537
682
  for formula in getattr(args, "formula", []):
538
683
  try:
539
- df.eval(formula, inplace=True)
540
- except (ValueError, SyntaxError):
541
- logger.debug(f"df.eval failed for {formula!r}, trying special_function")
542
- dest, func, cols = _special_function(formula, list(df.columns))
543
- if len(df) == 0:
544
- df[dest] = True
545
- elif isinstance(func, _WhereFunc):
546
- df[dest] = func(df)
547
- elif isinstance(func, _FrameFunc):
548
- # Frame-level functions (idxmax/idxmin) need column labels
549
- df[dest] = func(df[cols])
550
- elif len(cols) == 1:
551
- # Element-wise on a single column (e.g. np.sign, os.path.*)
552
- df[dest] = df[cols[0]].apply(func)
553
- else:
554
- # Multi-column row function: pass numpy array so positional
555
- # indexing (row[0], row[1], ...) works in pandas 2.x
556
- df[dest] = df[cols].apply(lambda row, f=func: f(row.to_numpy()), axis=1)
684
+ df = _eval_formula(df, formula)
557
685
  except Exception:
558
- if len(df) == 0:
559
- dest = formula.split("=", 1)[0].strip()
560
- df[dest] = True
561
- else:
562
- logger.error(f"Could not evaluate formula: {formula!r}")
686
+ if len(df) != 0:
563
687
  raise
688
+ df[formula.split("=", 1)[0].strip()] = True
564
689
 
565
690
  # -s: string / row functions
566
691
  for strfunc in getattr(args, "strfunc", []):
@@ -0,0 +1,180 @@
1
+ """
2
+ dftk.commands.explode_cmd — dftk explode subcommand.
3
+
4
+ Split a column of separated values (e.g. "GO:1, GO:2") into one row per
5
+ value, repeating the other columns on each new row (pandas str.split +
6
+ explode). Several columns are split in parallel: their n-th items end up
7
+ on the same row, so every row must have the same number of items in them.
8
+ """
9
+
10
+ import argparse
11
+
12
+ import pandas as pd
13
+
14
+ from dftk.commands.base import BaseCommand
15
+ from dftk.common.io import check_cols, io
16
+
17
+ _EPILOG = """\
18
+ EXAMPLE
19
+ -------
20
+ gene go tissue
21
+ BRCA1 GO:1, GO:2 breast
22
+ TP53 GO:3 colon
23
+
24
+ dftk explode data.tsv -c go
25
+
26
+ gene go tissue
27
+ BRCA1 GO:1 breast
28
+ BRCA1 GO:2 breast
29
+ TP53 GO:3 colon
30
+
31
+ Spaces around each item are removed. An empty cell stays as one row with
32
+ an empty value; --dropempty removes such rows. If all the new values in a
33
+ split column are numbers, the column becomes numeric (integers stay
34
+ integers); columns named in --readasobject stay text.
35
+
36
+ Several columns are split in parallel, item by item. For every
37
+ combination instead, run explode once per column:
38
+
39
+ id a b
40
+ 1 x,y 10,20
41
+
42
+ dftk explode data.tsv -c a b -> (1, x, 10), (1, y, 20)
43
+
44
+ Runnable version: collect each species' islands into one comma-separated
45
+ cell with SQL, then split them back into rows, numbering each item:
46
+
47
+ dftk dataset penguins -o \\
48
+ | dftk query ... -o --sql "SELECT species,
49
+ string_agg(DISTINCT island, ',' ORDER BY island) AS islands
50
+ FROM data GROUP BY species ORDER BY species" \\
51
+ | dftk explode ... -c islands --poscol n
52
+
53
+ Another separator, keeping only some columns:
54
+
55
+ dftk dataset tips -o \\
56
+ | dftk query ... -o --sql "SELECT day,
57
+ string_agg(DISTINCT size::VARCHAR, '|' ORDER BY size::VARCHAR) AS sizes
58
+ FROM data GROUP BY day ORDER BY day" \\
59
+ | dftk explode ... -c sizes --sep '|' --select sizes day
60
+ """
61
+
62
+
63
+ def _split_cell(value, sep: str):
64
+ if pd.isna(value):
65
+ return value
66
+ return [item.strip() for item in str(value).split(sep)]
67
+
68
+
69
+ def _to_numeric(s: pd.Series) -> pd.Series:
70
+ """Return *s* as integers or floats if every non-empty value is one."""
71
+ values = s.dropna()
72
+ if values.empty:
73
+ return s
74
+ text = values.astype(str)
75
+ if text.str.fullmatch(r"[+-]?\d+").all():
76
+ return s.astype("Int64") if s.isna().any() else s.astype("int64")
77
+ try:
78
+ return pd.to_numeric(s)
79
+ except (ValueError, TypeError):
80
+ return s
81
+
82
+
83
+ def _explode(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
84
+ cols = args.cols
85
+ if not args.sep:
86
+ raise ValueError("--sep must not be empty")
87
+ if len(set(cols)) != len(cols):
88
+ raise ValueError(f"-c/--cols lists a column more than once: {cols}")
89
+ if args.poscol is not None and args.poscol in df.columns:
90
+ raise ValueError(f"--poscol {args.poscol!r} is already a column")
91
+
92
+ df = df.reset_index(drop=True)
93
+ for col in cols:
94
+ df[col] = df[col].map(lambda v: _split_cell(v, args.sep)).astype(object)
95
+
96
+ if len(cols) > 1:
97
+ counts = pd.DataFrame(
98
+ {c: df[c].map(lambda v: len(v) if isinstance(v, list) else 1) for c in cols}
99
+ )
100
+ bad = counts.index[counts.nunique(axis=1) > 1]
101
+ if len(bad):
102
+ shown = "; ".join(
103
+ f"row {i + 1} ("
104
+ + ", ".join(f"{c}: {counts.at[i, c]}" for c in cols)
105
+ + ")"
106
+ for i in bad[:5]
107
+ )
108
+ more = f" and {len(bad) - 5} more" if len(bad) > 5 else ""
109
+ raise ValueError(
110
+ f"Rows with different numbers of items in -c/--cols: {shown}{more}. "
111
+ "Run explode once per column for every combination."
112
+ )
113
+
114
+ result = df.explode(cols)
115
+ if args.poscol is not None:
116
+ result[args.poscol] = result.groupby(level=0).cumcount() + 1
117
+ result = result.reset_index(drop=True)
118
+
119
+ for col in cols:
120
+ result[col] = result[col].replace("", pd.NA)
121
+ if args.dropempty:
122
+ result = result[result[cols].notna().any(axis=1)].reset_index(drop=True)
123
+
124
+ readasobject = getattr(args, "readasobject", None)
125
+ for col in cols:
126
+ if readasobject is not None and (not readasobject or col in readasobject):
127
+ continue
128
+ result[col] = _to_numeric(result[col])
129
+ return result
130
+
131
+
132
+ class ExplodeCommand(BaseCommand):
133
+ @property
134
+ def name(self) -> str:
135
+ return "explode"
136
+
137
+ @property
138
+ def help(self) -> str:
139
+ return "Split separated values in a column into one row per value"
140
+
141
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
142
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
143
+ parser.epilog = _EPILOG
144
+
145
+ self.add_io_arguments(parser)
146
+
147
+ g = parser.add_argument_group("explode options")
148
+ g.add_argument(
149
+ "-c",
150
+ "--cols",
151
+ nargs="+",
152
+ required=True,
153
+ metavar="COL",
154
+ help="Column(s) to split. Several columns are split in parallel "
155
+ "and must have the same number of items on each row.",
156
+ )
157
+ g.add_argument(
158
+ "--sep",
159
+ default=",",
160
+ metavar="SEP",
161
+ help="Separator between items, a plain string, not a regex (default: ,).",
162
+ )
163
+ g.add_argument(
164
+ "--poscol",
165
+ default=None,
166
+ metavar="NAME",
167
+ help="Add a column NAME with each item's position in its cell (1, 2, ...).",
168
+ )
169
+ g.add_argument(
170
+ "--dropempty",
171
+ action="store_true",
172
+ help="Drop rows whose split columns are all empty "
173
+ "(default: keep them, so no input row is lost).",
174
+ )
175
+
176
+ def execute(self, args: argparse.Namespace) -> None:
177
+ df = io.read(args)
178
+ check_cols(df, args.cols, "-c/--cols")
179
+ result = _explode(df, args)
180
+ io.printdf(result, args)
@@ -0,0 +1,189 @@
1
+ """
2
+ dftk.commands.implode_cmd — dftk implode subcommand.
3
+
4
+ The inverse of explode: join the values of a column within each group of
5
+ rows into one cell ("GO:1,GO:2"). Several columns are joined in the same
6
+ row order, so explode -c a b followed by implode -c a b gives back the
7
+ original rows.
8
+ """
9
+
10
+ import argparse
11
+
12
+ import pandas as pd
13
+
14
+ from dftk.commands.base import BaseCommand
15
+ from dftk.common.io import check_cols, io
16
+
17
+ _EPILOG = """\
18
+ EXAMPLE
19
+ -------
20
+ gene go tissue
21
+ BRCA1 GO:1 breast
22
+ BRCA1 GO:2 breast
23
+ TP53 GO:3 colon
24
+
25
+ dftk implode data.tsv -c go
26
+
27
+ gene go tissue
28
+ BRCA1 GO:1,GO:2 breast
29
+ TP53 GO:3 colon
30
+
31
+ By default rows are grouped by all the other columns, so rows are only
32
+ joined when everything else in them is the same. With -i, rows are grouped
33
+ by those columns only and the other columns are dropped. Rows with an
34
+ empty key form a group of their own.
35
+
36
+ Values are joined in the order of the rows; --sortvalues sorts them and
37
+ --unique drops repeats. Empty values are left out, and a group with none
38
+ gets an empty cell. Whole numbers are written without ".0" when every
39
+ value in the column is one (pandas reads integers with gaps as floats).
40
+
41
+ Several columns are joined row by row, so their n-th items still belong
42
+ together; --unique and --sortvalues then work on whole rows.
43
+
44
+ Runnable version: each species' islands, one cell per species:
45
+
46
+ dftk dataset penguins -o \\
47
+ | dftk implode ... -c island -i species --unique --sortvalues
48
+
49
+ Numbers, with another separator: the party sizes seen on each day:
50
+
51
+ dftk dataset tips -o \\
52
+ | dftk implode ... -c size -i day --unique --sortvalues --sep '|'
53
+
54
+ Undoing explode: --poscol puts the items back in their original order,
55
+ here after shuffling the rows, and is dropped from the output:
56
+
57
+ dftk dataset penguins -o \\
58
+ | dftk implode ... -c island -i species --unique -o \\
59
+ | dftk explode ... -c island --poscol n -o \\
60
+ | dftk sample ... -f 1 --randomseed 1 -o \\
61
+ | dftk implode ... -c island --poscol n
62
+ """
63
+
64
+
65
+ def _as_text(s: pd.Series) -> pd.Series:
66
+ """Return *s* as strings, keeping missing values missing."""
67
+ values = s.dropna()
68
+ if (
69
+ pd.api.types.is_float_dtype(s)
70
+ and len(values)
71
+ and (values == values.round()).all()
72
+ ):
73
+ # Integers with gaps are read as floats: write 10, not 10.0
74
+ s = s.astype("Int64")
75
+ text = s.astype(object).where(s.notna())
76
+ text = text.map(lambda v: v if pd.isna(v) else str(v))
77
+ return text.where(text != "")
78
+
79
+
80
+ def _implode(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
81
+ cols = args.cols
82
+ poscol = args.poscol
83
+ if len(set(cols)) != len(cols):
84
+ raise ValueError(f"-c/--cols lists a column more than once: {cols}")
85
+ if poscol is not None and poscol in cols:
86
+ raise ValueError(f"--poscol {poscol!r} is also in -c/--cols")
87
+
88
+ if args.indexcols is not None:
89
+ keys = list(args.indexcols)
90
+ overlap = [c for c in keys if c in cols or c == poscol]
91
+ if overlap:
92
+ raise ValueError(
93
+ f"-i/--indexcols overlaps -c/--cols or --poscol: {overlap}"
94
+ )
95
+ else:
96
+ keys = [c for c in df.columns if c not in cols and c != poscol]
97
+
98
+ order = [c for c in df.columns if c in keys or c in cols]
99
+ work = df[keys + cols + ([poscol] if poscol else [])].reset_index(drop=True)
100
+ if keys:
101
+ work["_gid"] = work.groupby(keys, sort=False, dropna=False).ngroup()
102
+ else:
103
+ work["_gid"] = 0
104
+
105
+ if args.sortvalues:
106
+ work = work.sort_values(["_gid", *cols], kind="stable", na_position="last")
107
+ elif poscol:
108
+ work = work.sort_values(["_gid", poscol], kind="stable")
109
+ else:
110
+ work = work.sort_values("_gid", kind="stable")
111
+ if args.unique:
112
+ work = work.drop_duplicates(subset=["_gid", *cols])
113
+
114
+ for col in cols:
115
+ work[col] = _as_text(work[col])
116
+ grouped = work.groupby("_gid", sort=True)
117
+ joined = grouped[cols].agg(lambda s: args.sep.join(s.dropna()))
118
+ joined = joined.where(joined != "")
119
+ result = grouped[keys].first().join(joined) if keys else joined
120
+ return result.reset_index(drop=True)[order]
121
+
122
+
123
+ class ImplodeCommand(BaseCommand):
124
+ @property
125
+ def name(self) -> str:
126
+ return "implode"
127
+
128
+ @property
129
+ def help(self) -> str:
130
+ return "Join a column's values within groups of rows into one cell"
131
+
132
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
133
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
134
+ parser.epilog = _EPILOG
135
+
136
+ self.add_io_arguments(parser)
137
+
138
+ g = parser.add_argument_group("implode options")
139
+ g.add_argument(
140
+ "-c",
141
+ "--cols",
142
+ nargs="+",
143
+ required=True,
144
+ metavar="COL",
145
+ help="Column(s) whose values are joined. Several columns are "
146
+ "joined in the same row order.",
147
+ )
148
+ g.add_argument(
149
+ "-i",
150
+ "--indexcols",
151
+ nargs="+",
152
+ default=None,
153
+ metavar="COL",
154
+ help="Group by these columns and drop the others "
155
+ "(default: group by all columns not in -c or --poscol).",
156
+ )
157
+ g.add_argument(
158
+ "--sep",
159
+ default=",",
160
+ metavar="SEP",
161
+ help="Separator between joined values (default: ,).",
162
+ )
163
+ g.add_argument(
164
+ "--poscol",
165
+ default=None,
166
+ metavar="NAME",
167
+ help="Join the values in the order of column NAME (e.g. the "
168
+ "positions from explode --poscol) and drop it.",
169
+ )
170
+ g.add_argument(
171
+ "--unique",
172
+ action="store_true",
173
+ help="Drop repeated values (with several -c columns, repeated rows).",
174
+ )
175
+ g.add_argument(
176
+ "--sortvalues",
177
+ action="store_true",
178
+ help="Sort the values in each cell (with several -c columns, "
179
+ "sort whole rows by them).",
180
+ )
181
+
182
+ def execute(self, args: argparse.Namespace) -> None:
183
+ df = io.read(args)
184
+ check_cols(df, args.cols, "-c/--cols")
185
+ check_cols(df, args.indexcols, "-i/--indexcols")
186
+ if args.poscol is not None:
187
+ check_cols(df, [args.poscol], "--poscol")
188
+ result = _implode(df, args)
189
+ io.printdf(result, args)