dataframe-toolkit 0.15.1__tar.gz → 0.16.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 (57) hide show
  1. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/PKG-INFO +32 -2
  2. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/README.md +31 -1
  3. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/pyproject.toml +1 -1
  4. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/pyproject.toml.orig +1 -1
  5. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/__init__.py +2 -0
  6. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/corr_cmd.py +169 -23
  7. dataframe_toolkit-0.16.0/src/dftk/commands/heatmap_cmd.py +253 -0
  8. dataframe_toolkit-0.16.0/src/dftk/commands/pairs_cmd.py +366 -0
  9. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/skills/dftk/SKILL.md +8 -3
  10. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/__init__.py +0 -0
  11. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/cli.py +0 -0
  12. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/annotate_cmd.py +0 -0
  13. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/base.py +0 -0
  14. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/binx_cmd.py +0 -0
  15. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/chi_cmd.py +0 -0
  16. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/clean_cmd.py +0 -0
  17. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/cleannames_cmd.py +0 -0
  18. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/concat_cmd.py +0 -0
  19. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/dataset_cmd.py +0 -0
  20. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/describe_cmd.py +0 -0
  21. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/eval_cmd.py +0 -0
  22. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/explode_cmd.py +0 -0
  23. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/fit_cmd.py +0 -0
  24. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/func_cmd.py +0 -0
  25. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/help.py +0 -0
  26. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/hist_cmd.py +0 -0
  27. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/implode_cmd.py +0 -0
  28. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/info_cmd.py +0 -0
  29. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/interp_cmd.py +0 -0
  30. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/line_cmd.py +0 -0
  31. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/melt_cmd.py +0 -0
  32. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/merge_cmd.py +0 -0
  33. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/pivot_cmd.py +0 -0
  34. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/print_cmd.py +0 -0
  35. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/query_cmd.py +0 -0
  36. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/randvar_cmd.py +0 -0
  37. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/sample_cmd.py +0 -0
  38. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/scale_cmd.py +0 -0
  39. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/scat_cmd.py +0 -0
  40. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/segid_cmd.py +0 -0
  41. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/skill_cmd.py +0 -0
  42. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/split_cmd.py +0 -0
  43. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/stat_cmd.py +0 -0
  44. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/test_cmd.py +0 -0
  45. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/transpose_cmd.py +0 -0
  46. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/commands/wstat_cmd.py +0 -0
  47. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/common/__init__.py +0 -0
  48. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/common/copula.py +0 -0
  49. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/common/docformats.py +0 -0
  50. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/common/evalfuncs.py +0 -0
  51. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/common/io.py +0 -0
  52. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/common/plot.py +0 -0
  53. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/common/seed.py +0 -0
  54. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/common/seq.py +0 -0
  55. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/common/stats.py +0 -0
  56. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/io.py +0 -0
  57. {dataframe_toolkit-0.15.1 → dataframe_toolkit-0.16.0}/src/dftk/skills/dftk/references/snakemake.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: dataframe-toolkit
3
- Version: 0.15.1
3
+ Version: 0.16.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
@@ -139,7 +139,7 @@ dftk dataset tips -o \
139
139
  | `wstat` | Weighted descriptive statistics (wmean, wstd, weighted quantile CI) |
140
140
  | `fit` | OLS/robust/weighted regression via R-style formulas; tidy table, `--summary`, `--anova` |
141
141
  | `test` | P-values between column pairs: t-test, Mann-Whitney, Wilcoxon, KS, correlations, bootstrap; groups |
142
- | `corr` | Pairwise column correlations (Pearson/Spearman/Kendall) with optional BCa bootstrap CI |
142
+ | `corr` | Pairwise column correlations (Pearson/Spearman/Kendall) with optional BCa bootstrap CI; globs (`-c '*:*'`) or a full matrix (`--all`) |
143
143
  | `describe` | Quick column-level summary (dtype, n, n_unique, n_null, sample values) |
144
144
  | `info` | Per-column dtype, null counts, and memory usage; `--summary` for dataset-level totals |
145
145
  | `randvar` | Sample from a distribution and append as a new column (norm, alpha, beta, …) |
@@ -157,6 +157,8 @@ publication-quality output.
157
157
  | `line` | Line plot; optional error bars (`--yerr`) or CI bands (`--yci lo,hi`) and fit overlay |
158
158
  | `hist` | Histogram or KDE (`--kde`); normalisation, cumulative mode, mean±σ annotation (`--stats`) |
159
159
  | `chi` | Corner-resolved chi-plot: whether tail dependence sits in the lower or upper corner |
160
+ | `heatmap` | Coloured grid of values from a long (`-x -y -v`) or wide (`-y`) table; correlograms from `corr --all`, `--annot`, `--lower` |
161
+ | `pairs` | Scatter-plot matrix with KDEs (or histograms) on the diagonal; `-g` colours groups, `--upper corr` puts r in the upper triangle |
160
162
 
161
163
  ### Utilities
162
164
 
@@ -409,6 +411,34 @@ dftk eval segments.tsv -f 'gap = Begin - End.groupby(Chr).shift()'
409
411
  Without `groupby(Chr)` the first segment of a chromosome is compared with
410
412
  the last one of the previous chromosome.
411
413
 
414
+ ### Correlograms and heatmaps
415
+
416
+ `corr --all` correlates every pair of numeric columns both ways round, plus the diagonal, which is the square table `heatmap` draws. Values of both signs get diverging colours centred on 0; `--vmin -1 --vmax 1` keeps the full scale for r, so a weak correlation doesn't look strong. `--annot` writes the values in the cells and `--lower` keeps the lower triangle:
417
+
418
+ ```bash
419
+ dftk dataset iris -o | dftk corr --all -o \
420
+ | dftk heatmap -x col1 -y col2 -v correlation --annot --vmin -1 --vmax 1 -f iris_corr.png
421
+ dftk dataset diamonds -o | dftk corr --all --method spearman -o \
422
+ | dftk heatmap -x col1 -y col2 -v correlation --annot --lower --square \
423
+ --vmin -1 --vmax 1 -f diamonds_corr.png
424
+ ```
425
+
426
+ Missing values are dropped pair by pair, so `nobs` can differ between cells; `dftk print --dropna -o | dftk corr --all` uses the complete rows throughout. p-values are not adjusted for multiple testing.
427
+
428
+ For the shape of each relationship, not just r, `pairs` draws every pair of columns as a scatter plot with each column's KDE on the diagonal; `-g` colours groups (each group's KDE scaled by its share of the rows) and `--upper corr` writes r in the upper triangle, with `-g` also r within each group, which can differ even in sign from r over all rows:
429
+
430
+ ```bash
431
+ dftk dataset iris -o | dftk pairs -g species -f iris_pairs.png
432
+ dftk dataset iris -o | dftk pairs -g species --upper corr --diag hist -f iris_pairs_r.png
433
+ ```
434
+
435
+ `heatmap` draws any table with one row per cell (`-x -y -v`), or a wide one with `-y` naming the row labels and the other numeric columns as heatmap columns, as `pivot` makes it:
436
+
437
+ ```bash
438
+ dftk dataset penguins -o | dftk pivot -i species -g island -v body_mass_g -f count -o \
439
+ | dftk heatmap -y species --annot --cmap Blues -f penguin_counts.png
440
+ ```
441
+
412
442
  ### Bootstrap confidence intervals
413
443
 
414
444
  Bootstrap mode repeats the full stat computation N times on resampled data
@@ -109,7 +109,7 @@ dftk dataset tips -o \
109
109
  | `wstat` | Weighted descriptive statistics (wmean, wstd, weighted quantile CI) |
110
110
  | `fit` | OLS/robust/weighted regression via R-style formulas; tidy table, `--summary`, `--anova` |
111
111
  | `test` | P-values between column pairs: t-test, Mann-Whitney, Wilcoxon, KS, correlations, bootstrap; groups |
112
- | `corr` | Pairwise column correlations (Pearson/Spearman/Kendall) with optional BCa bootstrap CI |
112
+ | `corr` | Pairwise column correlations (Pearson/Spearman/Kendall) with optional BCa bootstrap CI; globs (`-c '*:*'`) or a full matrix (`--all`) |
113
113
  | `describe` | Quick column-level summary (dtype, n, n_unique, n_null, sample values) |
114
114
  | `info` | Per-column dtype, null counts, and memory usage; `--summary` for dataset-level totals |
115
115
  | `randvar` | Sample from a distribution and append as a new column (norm, alpha, beta, …) |
@@ -127,6 +127,8 @@ publication-quality output.
127
127
  | `line` | Line plot; optional error bars (`--yerr`) or CI bands (`--yci lo,hi`) and fit overlay |
128
128
  | `hist` | Histogram or KDE (`--kde`); normalisation, cumulative mode, mean±σ annotation (`--stats`) |
129
129
  | `chi` | Corner-resolved chi-plot: whether tail dependence sits in the lower or upper corner |
130
+ | `heatmap` | Coloured grid of values from a long (`-x -y -v`) or wide (`-y`) table; correlograms from `corr --all`, `--annot`, `--lower` |
131
+ | `pairs` | Scatter-plot matrix with KDEs (or histograms) on the diagonal; `-g` colours groups, `--upper corr` puts r in the upper triangle |
130
132
 
131
133
  ### Utilities
132
134
 
@@ -379,6 +381,34 @@ dftk eval segments.tsv -f 'gap = Begin - End.groupby(Chr).shift()'
379
381
  Without `groupby(Chr)` the first segment of a chromosome is compared with
380
382
  the last one of the previous chromosome.
381
383
 
384
+ ### Correlograms and heatmaps
385
+
386
+ `corr --all` correlates every pair of numeric columns both ways round, plus the diagonal, which is the square table `heatmap` draws. Values of both signs get diverging colours centred on 0; `--vmin -1 --vmax 1` keeps the full scale for r, so a weak correlation doesn't look strong. `--annot` writes the values in the cells and `--lower` keeps the lower triangle:
387
+
388
+ ```bash
389
+ dftk dataset iris -o | dftk corr --all -o \
390
+ | dftk heatmap -x col1 -y col2 -v correlation --annot --vmin -1 --vmax 1 -f iris_corr.png
391
+ dftk dataset diamonds -o | dftk corr --all --method spearman -o \
392
+ | dftk heatmap -x col1 -y col2 -v correlation --annot --lower --square \
393
+ --vmin -1 --vmax 1 -f diamonds_corr.png
394
+ ```
395
+
396
+ Missing values are dropped pair by pair, so `nobs` can differ between cells; `dftk print --dropna -o | dftk corr --all` uses the complete rows throughout. p-values are not adjusted for multiple testing.
397
+
398
+ For the shape of each relationship, not just r, `pairs` draws every pair of columns as a scatter plot with each column's KDE on the diagonal; `-g` colours groups (each group's KDE scaled by its share of the rows) and `--upper corr` writes r in the upper triangle, with `-g` also r within each group, which can differ even in sign from r over all rows:
399
+
400
+ ```bash
401
+ dftk dataset iris -o | dftk pairs -g species -f iris_pairs.png
402
+ dftk dataset iris -o | dftk pairs -g species --upper corr --diag hist -f iris_pairs_r.png
403
+ ```
404
+
405
+ `heatmap` draws any table with one row per cell (`-x -y -v`), or a wide one with `-y` naming the row labels and the other numeric columns as heatmap columns, as `pivot` makes it:
406
+
407
+ ```bash
408
+ dftk dataset penguins -o | dftk pivot -i species -g island -v body_mass_g -f count -o \
409
+ | dftk heatmap -y species --annot --cmap Blues -f penguin_counts.png
410
+ ```
411
+
382
412
  ### Bootstrap confidence intervals
383
413
 
384
414
  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.15.1"
3
+ version = "0.16.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.15.1"
3
+ version = "0.16.0"
4
4
  description = "A command-line toolkit for DataFrame analysis and manipulation"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -62,6 +62,8 @@ COMMAND_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [
62
62
  ("line", "line_cmd:LineCommand"),
63
63
  ("hist", "hist_cmd:HistCommand"),
64
64
  ("chi", "chi_cmd:ChiCommand"),
65
+ ("heatmap", "heatmap_cmd:HeatmapCommand"),
66
+ ("pairs", "pairs_cmd:PairsCommand"),
65
67
  ],
66
68
  ),
67
69
  (
@@ -11,6 +11,8 @@ Bootstrap mode (--bootstrap N) produces a single summary row per pair with:
11
11
  """
12
12
 
13
13
  import argparse
14
+ import fnmatch
15
+ import itertools
14
16
  import logging
15
17
 
16
18
  import numpy as np
@@ -140,14 +142,82 @@ def _tail_stats(a: np.ndarray, b: np.ndarray, q_grid: np.ndarray) -> tuple:
140
142
  return tuple(out)
141
143
 
142
144
 
145
+ _GLOB_CHARS = frozenset("*?[")
146
+
147
+
148
+ def _numeric_columns(df: pd.DataFrame, groups: list[str]) -> list[str]:
149
+ """Numeric, non-boolean columns of *df* other than the group columns."""
150
+ return [
151
+ c
152
+ for c in df.columns
153
+ if c not in groups
154
+ and pd.api.types.is_numeric_dtype(df[c])
155
+ and not pd.api.types.is_bool_dtype(df[c])
156
+ ]
157
+
158
+
159
+ def _is_glob(pattern: str, numeric: list[str]) -> bool:
160
+ """A glob pattern, unless it is the name of a column (e.g. x[1])."""
161
+ return bool(_GLOB_CHARS & set(pattern)) and pattern not in numeric
162
+
163
+
164
+ def _match(pattern: str, numeric: list[str], option: str) -> list[str]:
165
+ """A column name as is, or the numeric columns a glob pattern matches."""
166
+ if not _is_glob(pattern, numeric):
167
+ return [pattern]
168
+ matched = fnmatch.filter(numeric, pattern)
169
+ if not matched:
170
+ raise ValueError(
171
+ f"{option}: {pattern!r} matches no numeric column (numeric columns: "
172
+ f"{numeric})"
173
+ )
174
+ return matched
175
+
176
+
177
+ def _expand_pairs(cols: list[str], numeric: list[str]) -> list[tuple[str, str]]:
178
+ """-c COL1:COL2 items as pairs; a glob on either side expands.
179
+
180
+ A pair from a glob is left out when both sides are the same column or
181
+ when the pair already came up the other way round, so -c '*:*' gives
182
+ every pair once. Pairs named in full are kept as given.
183
+ """
184
+ bad = [c for c in cols if len(c.split(":", maxsplit=1)) != 2]
185
+ if bad:
186
+ raise ValueError(f"-c pairs must be col1:col2, got: {bad}")
187
+ pairs: list[tuple[str, str]] = []
188
+ for item in cols:
189
+ left, right = item.split(":", maxsplit=1)
190
+ if not (_is_glob(left, numeric) or _is_glob(right, numeric)):
191
+ pairs.append((left, right))
192
+ continue
193
+ for a, b in itertools.product(
194
+ _match(left, numeric, "-c"), _match(right, numeric, "-c")
195
+ ):
196
+ if a != b and (a, b) not in pairs and (b, a) not in pairs:
197
+ pairs.append((a, b))
198
+ return pairs
199
+
200
+
201
+ def _all_pairs(cols: list[str], numeric: list[str]) -> list[tuple[str, str]]:
202
+ """--all [COL ...]: every ordered pair, diagonal included (a square matrix)."""
203
+ names: list[str] = []
204
+ for pattern in cols or ["*"]:
205
+ names += [c for c in _match(pattern, numeric, "--all") if c not in names]
206
+ if len(names) < 2:
207
+ raise ValueError(f"--all needs at least two numeric columns, got {names}")
208
+ return list(itertools.product(names, names))
209
+
210
+
143
211
  def _corr(
144
- df: pd.DataFrame, args: argparse.Namespace, rng: np.random.Generator
212
+ df: pd.DataFrame,
213
+ args: argparse.Namespace,
214
+ rng: np.random.Generator,
215
+ pairs: list[tuple[str, str]] | None = None,
145
216
  ) -> pd.DataFrame:
217
+ """Correlations for *pairs* (default: args.cols, globs expanded)."""
218
+ if pairs is None:
219
+ pairs = _expand_pairs(args.cols, _numeric_columns(df, args.groups))
146
220
  method_fn = _METHOD_FUNC[args.method]
147
- pairs = [c.split(":", maxsplit=1) for c in args.cols]
148
- bad = [c for c, p in zip(args.cols, pairs, strict=False) if len(p) != 2]
149
- if bad:
150
- raise ValueError(f"-c pairs must be col1:col2, got: {bad}")
151
221
 
152
222
  ci = args.ci and args.method == "pearson"
153
223
  tail = getattr(args, "tail", False)
@@ -163,20 +233,49 @@ def _corr(
163
233
  header += [f"lambda_lower_q{q:g}", f"lambda_upper_q{q:g}"]
164
234
 
165
235
  rows = []
236
+ n_stats = len(header) - 2 # nobs onwards
166
237
 
167
238
  def _add_pairs(sub: pd.DataFrame, prefix: tuple = ()):
239
+ # Every statistic is symmetric in the two columns, so --all's mirrored
240
+ # pair (b, a) reuses the result for (a, b)
241
+ done: dict[frozenset, tuple] = {}
168
242
  for col1, col2 in pairs:
169
- pair_df = sub[[col1, col2]].dropna()
170
- a, b = pair_df[col1].values, pair_df[col2].values
171
- cr = _compute_correlation(a, b, method_fn, ci)
172
- extra = ()
173
- if args.bootstrap is not None:
174
- extra += _bootstrap_stats(
175
- a, b, method_fn, args.bootstrap, rng, args.confidence / 100.0
176
- )
177
- if tail:
178
- extra += _tail_stats(a, b, q_grid)
179
- rows.append((*prefix, col1, col2, *cr, *extra))
243
+ key = frozenset((col1, col2))
244
+ if key not in done:
245
+ if col1 == col2: # the diagonal of --all; sub[[c, c]] is 2-D
246
+ a = b = sub[col1].dropna().values
247
+ else:
248
+ pair_df = sub[[col1, col2]].dropna()
249
+ a, b = pair_df[col1].values, pair_df[col2].values
250
+ if len(a) < 2:
251
+ where = f" (group {', '.join(map(str, prefix))})" if prefix else ""
252
+ logger.warning(
253
+ f"corr: {col1}:{col2}{where} has {len(a)} complete "
254
+ "row(s); statistics left empty"
255
+ )
256
+ stats = (len(a),) + (np.nan,) * (n_stats - 1)
257
+ elif col1 == col2:
258
+ # r is 1 by definition; a p-value or interval of a column
259
+ # with itself means nothing and would count as a finding,
260
+ # and ties push the tail statistics above 1
261
+ r = _compute_correlation(a, b, method_fn, False)[1]
262
+ r = 1.0 if np.isfinite(r) else r # NaN: a constant column
263
+ stats = (len(a), r) + (np.nan,) * (n_stats - 2)
264
+ else:
265
+ stats = _compute_correlation(a, b, method_fn, ci)
266
+ if args.bootstrap is not None:
267
+ stats += _bootstrap_stats(
268
+ a,
269
+ b,
270
+ method_fn,
271
+ args.bootstrap,
272
+ rng,
273
+ args.confidence / 100.0,
274
+ )
275
+ if tail:
276
+ stats += _tail_stats(a, b, q_grid)
277
+ done[key] = stats
278
+ rows.append((*prefix, col1, col2, *done[key]))
180
279
 
181
280
  if args.groups:
182
281
  header = [*args.groups, *header]
@@ -197,6 +296,36 @@ COLUMN PAIRS (-c)
197
296
  dftk corr data.tsv -c x:y
198
297
  dftk dataset tips -o | dftk corr -c total_bill:tip tip:size
199
298
 
299
+ A glob on either side expands to the numeric columns it matches (quote
300
+ it). '*:*' gives every pair once; 'sepal*:petal*' every sepal column
301
+ with every petal column:
302
+
303
+ dftk dataset iris -o | dftk corr -c '*:*'
304
+ dftk dataset iris -o | dftk corr -c 'sepal*:petal*' --method spearman
305
+
306
+ CORRELATION MATRIX (--all)
307
+ --------------------------
308
+ --all gives every pair both ways round plus the diagonal, in column order:
309
+ the square table a correlogram needs. Name columns (globs allowed) to
310
+ limit it; -g gives one matrix per group. Each pair is computed once. On
311
+ the diagonal r is 1 and the other statistics are empty.
312
+
313
+ dftk dataset iris -o | dftk corr --all
314
+ dftk dataset iris -o | dftk corr --all -o \\
315
+ | dftk heatmap -x col1 -y col2 -v correlation --annot --vmin -1 --vmax 1
316
+ dftk dataset diamonds -o | dftk corr --all carat depth price -g cut
317
+
318
+ Missing values are dropped pair by pair, so nobs can differ from cell to
319
+ cell, and the matrix need not be a valid correlation matrix. For one set
320
+ of rows throughout, drop incomplete rows first:
321
+
322
+ dftk dataset penguins -o | dftk print --dropna -o | dftk corr --all
323
+
324
+ p-values are not adjusted for multiple testing, and --all lists each
325
+ p-value twice. Adjust over one triangle:
326
+
327
+ dftk dataset iris -o | dftk corr --all -o | dftk query -q 'col1 < col2'
328
+
200
329
  METHODS (--method)
201
330
  ------------------
202
331
  pearson Pearson r (default)
@@ -287,13 +416,25 @@ class CorrCommand(BaseCommand):
287
416
  self.add_io_arguments(parser)
288
417
 
289
418
  g = parser.add_argument_group("corr options")
290
- g.add_argument(
419
+ which = g.add_mutually_exclusive_group(required=True)
420
+ which.add_argument(
291
421
  "-c",
292
422
  "--cols",
293
423
  nargs="+",
294
- required=True,
295
424
  metavar="COL1:COL2",
296
- help="Column pair(s) to correlate, e.g. x:y.",
425
+ help="Column pair(s) to correlate, e.g. x:y. A glob on either "
426
+ "side expands to numeric columns: '*:*' is every pair once, "
427
+ "'sepal*:petal*' every sepal column with every petal column.",
428
+ )
429
+ which.add_argument(
430
+ "--all",
431
+ nargs="*",
432
+ default=None,
433
+ metavar="COL",
434
+ help="Correlation matrix: every pair of the named columns (globs "
435
+ "allowed; default: all numeric columns) both ways round, plus the "
436
+ "diagonal, in column order. Pipe it to dftk heatmap for a "
437
+ "correlogram.",
297
438
  )
298
439
  g.add_argument(
299
440
  "-g",
@@ -355,10 +496,15 @@ class CorrCommand(BaseCommand):
355
496
  seed = normalize_seed(args.randomseed) if args.randomseed is not None else None
356
497
  rng = np.random.default_rng(seed)
357
498
 
358
- all_cols = [c for pair in args.cols for c in pair.split(":", maxsplit=1)]
359
499
  df = io.read(args)
360
- check_cols(df, all_cols, "-c/--cols")
361
500
  check_cols(df, args.groups, "-g/--groups")
362
-
363
- result = _corr(df, args, rng)
501
+ numeric = _numeric_columns(df, args.groups)
502
+ if args.all is not None:
503
+ pairs = _all_pairs(args.all, numeric)
504
+ check_cols(df, sorted({c for p in pairs for c in p}), "--all")
505
+ else:
506
+ pairs = _expand_pairs(args.cols, numeric)
507
+ check_cols(df, [c for p in pairs for c in p], "-c/--cols")
508
+
509
+ result = _corr(df, args, rng, pairs)
364
510
  io.printdf(result, args)
@@ -0,0 +1,253 @@
1
+ """
2
+ dftk.commands.heatmap_cmd — dftk heatmap subcommand.
3
+
4
+ A coloured grid of values, e.g. a correlogram from ``dftk corr --all``.
5
+
6
+ Two input shapes:
7
+
8
+ long (-x COL -y COL -v COL) one row per cell, as corr and stat give it;
9
+ rows and columns keep their first-seen order
10
+ wide (-y COL) one row per heatmap row; every other numeric
11
+ column (or --select) is a heatmap column,
12
+ as pivot gives it or a matrix file holds it
13
+ """
14
+
15
+ import argparse
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ from dftk.commands.base import BaseCommand
21
+ from dftk.common.io import check_cols, io
22
+ from dftk.common.plot import (
23
+ add_figure_arguments,
24
+ apply_style,
25
+ make_figure,
26
+ save_or_show,
27
+ )
28
+
29
+
30
+ def _matrix(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
31
+ """The heatmap as a DataFrame: index = y labels, columns = x labels."""
32
+ if args.xcol is None and args.value is None:
33
+ if args.ycol is None:
34
+ raise ValueError(
35
+ "give -x, -y and -v (one row per cell), or -y alone (one row "
36
+ "per heatmap row, other numeric columns as heatmap columns)"
37
+ )
38
+ check_cols(df, [args.ycol], "-y/--ycol")
39
+ values = [
40
+ c
41
+ for c in df.columns
42
+ if c != args.ycol
43
+ and pd.api.types.is_numeric_dtype(df[c])
44
+ and not pd.api.types.is_bool_dtype(df[c])
45
+ ]
46
+ if not values:
47
+ raise ValueError(f"no numeric columns besides -y {args.ycol}")
48
+ if df[args.ycol].duplicated().any():
49
+ raise ValueError(f"-y {args.ycol} has repeated values: one row each")
50
+ return df.set_index(args.ycol)[values].astype(float)
51
+
52
+ if None in (args.xcol, args.ycol, args.value):
53
+ raise ValueError("long input needs all of -x, -y and -v")
54
+ check_cols(df, [args.xcol, args.ycol, args.value], "-x/-y/-v")
55
+ keys = [args.ycol, args.xcol]
56
+ if df.duplicated(keys).any():
57
+ raise ValueError(
58
+ f"several rows for the same {args.xcol} and {args.ycol}: summarise "
59
+ "first (dftk pivot), or filter (--prequery)"
60
+ )
61
+ xs = pd.unique(df[args.xcol])
62
+ ys = pd.unique(df[args.ycol])
63
+ matrix = df.pivot(index=args.ycol, columns=args.xcol, values=args.value)
64
+ return matrix.reindex(index=ys, columns=xs).astype(float)
65
+
66
+
67
+ def _fmt(values: np.ndarray, fmt: str | None) -> str:
68
+ """--fmt, or g for whole numbers (counts) and .2f otherwise."""
69
+ if fmt:
70
+ return fmt
71
+ finite = values[np.isfinite(values)]
72
+ return "g" if finite.size and np.all(finite == np.round(finite)) else ".2f"
73
+
74
+
75
+ def _heatmap(ax, fig, matrix: pd.DataFrame, args: argparse.Namespace) -> None:
76
+ values = matrix.to_numpy()
77
+ shown = np.ma.masked_invalid(values)
78
+ if args.lower:
79
+ if matrix.shape[0] != matrix.shape[1]:
80
+ raise ValueError("--lower needs a square heatmap (as corr --all gives)")
81
+ shown = np.ma.masked_where(np.triu(np.ones_like(values), k=1) > 0, shown)
82
+
83
+ vmin, vmax, center = args.vmin, args.vmax, args.center
84
+ finite = values[np.isfinite(values)]
85
+ if center is None and finite.size and finite.min() < 0 < finite.max():
86
+ center = 0.0 # values of both signs: diverging colours around 0
87
+ cmap = args.cmap or ("RdBu_r" if center is not None else "viridis")
88
+ if center is not None and finite.size:
89
+ # Symmetric limits so that center gets the middle colour
90
+ half = max(abs(finite.max() - center), abs(finite.min() - center))
91
+ vmin = center - half if vmin is None else vmin
92
+ vmax = center + half if vmax is None else vmax
93
+
94
+ mesh = ax.pcolormesh(shown, cmap=cmap, vmin=vmin, vmax=vmax)
95
+ fig.colorbar(mesh, ax=ax, label=args.value or "")
96
+
97
+ ax.set_xticks(np.arange(matrix.shape[1]) + 0.5)
98
+ ax.set_xticklabels([str(c) for c in matrix.columns], rotation=45, ha="right")
99
+ ax.set_yticks(np.arange(matrix.shape[0]) + 0.5)
100
+ ax.set_yticklabels([str(i) for i in matrix.index])
101
+ ax.invert_yaxis() # first row at the top, as in the table
102
+ ax.set_xlabel(args.xlabel if args.xlabel is not None else (args.xcol or ""))
103
+ ax.set_ylabel(args.ylabel if args.ylabel is not None else (args.ycol or ""))
104
+ ax.set_title(args.title or "")
105
+ ax.set_aspect("equal" if args.square else "auto")
106
+
107
+ if args.annot:
108
+ fmt = _fmt(values, args.fmt)
109
+ for (i, j), v in np.ndenumerate(shown.filled(np.nan)):
110
+ if np.isfinite(v):
111
+ # Dark text on light cells, light text on dark cells
112
+ r, g, b, _ = mesh.cmap(mesh.norm(v))
113
+ light = 0.299 * r + 0.587 * g + 0.114 * b > 0.5
114
+ ax.text(
115
+ j + 0.5,
116
+ i + 0.5,
117
+ format(v, fmt),
118
+ ha="center",
119
+ va="center",
120
+ color="black" if light else "white",
121
+ )
122
+
123
+
124
+ _EPILOG = """\
125
+ INPUT
126
+ -----
127
+ Long, one row per cell (-x, -y, -v): rows and columns of the heatmap keep
128
+ the order in which their labels first appear.
129
+
130
+ dftk heatmap data.tsv -x sample -y gene -v expression
131
+ dftk dataset tips -o | dftk pivot -i day time -v tip -f mean -o \\
132
+ | dftk heatmap -x time -y day -v mean_tip --annot
133
+
134
+ Wide, one row per heatmap row (-y alone): every other numeric column is a
135
+ heatmap column (--select picks and orders them):
136
+
137
+ dftk dataset tips -o | dftk pivot -i day -g time -v tip -f mean -o \\
138
+ | dftk heatmap -y day --annot
139
+
140
+ CORRELOGRAM
141
+ -----------
142
+ corr --all gives every pair of columns both ways round, which is a square
143
+ heatmap. Values of both signs get diverging colours centred on 0. Give
144
+ --vmin -1 --vmax 1 for correlations: otherwise the scale stretches to the
145
+ largest |r|, and a weak correlation looks as strong as a strong one would:
146
+
147
+ dftk dataset iris -o | dftk corr --all -o \\
148
+ | dftk heatmap -x col1 -y col2 -v correlation --annot --vmin -1 --vmax 1
149
+
150
+ --lower keeps the lower triangle and the diagonal; --square makes the
151
+ cells square:
152
+
153
+ dftk dataset diamonds -o | dftk corr --all --method spearman -o \\
154
+ | dftk heatmap -x col1 -y col2 -v correlation --annot --lower --square \\
155
+ --vmin -1 --vmax 1
156
+
157
+ COLOURS AND NUMBERS
158
+ -------------------
159
+ --cmap takes any matplotlib colour map (viridis, magma, RdBu_r, coolwarm,
160
+ ...). --annot writes each value in its cell, with --fmt as a Python format
161
+ (.2f, .1%, g; default g for whole numbers, else .2f). Empty cells stay
162
+ blank.
163
+
164
+ dftk dataset penguins -o | dftk pivot -i species -g island -v body_mass_g \\
165
+ -f count -o \\
166
+ | dftk heatmap -y species --annot --cmap Blues -f counts.png
167
+ """
168
+
169
+
170
+ class HeatmapCommand(BaseCommand):
171
+ name = "heatmap"
172
+ help = "Coloured grid of values, e.g. a correlogram from corr --all."
173
+
174
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
175
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
176
+ parser.epilog = _EPILOG
177
+
178
+ self.add_read_arguments(parser)
179
+
180
+ g = parser.add_argument_group("heatmap")
181
+ g.add_argument("-x", "--xcol", metavar="COL", help="Heatmap column labels.")
182
+ g.add_argument(
183
+ "-y",
184
+ "--ycol",
185
+ metavar="COL",
186
+ help="Heatmap row labels. Alone, without -x and -v: the other "
187
+ "numeric columns are the heatmap columns.",
188
+ )
189
+ g.add_argument("-v", "--value", metavar="COL", help="Cell values.")
190
+ g.add_argument(
191
+ "--select",
192
+ nargs="+",
193
+ default=None,
194
+ metavar="COL",
195
+ help="Wide input: the value columns to show, in this order.",
196
+ )
197
+ g.add_argument("--xlabel", default=None, help="X-axis label (default: -x).")
198
+ g.add_argument("--ylabel", default=None, help="Y-axis label (default: -y).")
199
+ g.add_argument("--annot", action="store_true", help="Write values in cells.")
200
+ g.add_argument(
201
+ "--fmt",
202
+ default=None,
203
+ metavar="FMT",
204
+ help="Format of --annot values, e.g. .2f or .1%% (default: g for "
205
+ "whole numbers, else .2f).",
206
+ )
207
+ g.add_argument(
208
+ "--cmap",
209
+ default=None,
210
+ help="Matplotlib colour map (default: RdBu_r around --center, "
211
+ "else viridis).",
212
+ )
213
+ g.add_argument(
214
+ "--center",
215
+ type=float,
216
+ default=None,
217
+ help="Value that gets the middle colour (default: 0 when values "
218
+ "have both signs).",
219
+ )
220
+ g.add_argument("--vmin", type=float, default=None, help="Colour scale minimum.")
221
+ g.add_argument("--vmax", type=float, default=None, help="Colour scale maximum.")
222
+ g.add_argument(
223
+ "--lower",
224
+ action="store_true",
225
+ help="Show only the lower triangle and diagonal (square heatmaps).",
226
+ )
227
+ g.add_argument("--square", action="store_true", help="Square cells.")
228
+
229
+ add_figure_arguments(parser)
230
+
231
+ def execute(self, args: argparse.Namespace) -> None:
232
+ df = io.read(args)
233
+ if args.select:
234
+ check_cols(df, args.select, "--select")
235
+ keep = [args.ycol] if args.ycol else []
236
+ df = df[keep + [c for c in args.select if c not in keep]]
237
+ matrix = _matrix(df, args)
238
+
239
+ if args.file:
240
+ import matplotlib
241
+
242
+ matplotlib.use("Agg")
243
+
244
+ import matplotlib.pyplot as plt
245
+
246
+ if getattr(args, "usetex", False):
247
+ plt.rc("text", usetex=True)
248
+
249
+ apply_style(args)
250
+ fig, axes = make_figure(args)
251
+ _heatmap(axes[0][0], fig, matrix, args)
252
+ fig.tight_layout()
253
+ save_or_show(fig, args)
@@ -0,0 +1,366 @@
1
+ """
2
+ dftk.commands.pairs_cmd — dftk pairs subcommand.
3
+
4
+ Scatter-plot matrix: every pair of columns as a small scatter plot, with each
5
+ column's distribution (KDE or histogram) on the diagonal. The upper triangle
6
+ repeats the scatter plots mirrored, or shows the correlation (--upper corr).
7
+ """
8
+
9
+ import argparse
10
+ import fnmatch
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+
15
+ from dftk.commands.base import BaseCommand
16
+ from dftk.common.io import check_cols, io
17
+ from dftk.common.plot import (
18
+ _DEFAULT_SIZE,
19
+ WONG_PALETTE,
20
+ add_figure_arguments,
21
+ apply_style,
22
+ parse_figsize,
23
+ save_or_show,
24
+ )
25
+
26
+ _PANEL_INCHES = 2.2 # default size of one panel
27
+ _MAX_INCHES = 12.0 # default figure size cap
28
+
29
+
30
+ def _columns(df: pd.DataFrame, patterns: list[str] | None, exclude) -> list[str]:
31
+ """-c names and globs (default: every numeric column), in that order."""
32
+ numeric = [
33
+ c
34
+ for c in df.columns
35
+ if c not in exclude
36
+ and pd.api.types.is_numeric_dtype(df[c])
37
+ and not pd.api.types.is_bool_dtype(df[c])
38
+ ]
39
+ if not patterns:
40
+ cols = numeric
41
+ else:
42
+ cols = []
43
+ for pattern in patterns:
44
+ if set("*?[") & set(pattern) and pattern not in numeric:
45
+ matched = fnmatch.filter(numeric, pattern)
46
+ if not matched:
47
+ raise ValueError(f"-c: {pattern!r} matches no numeric column")
48
+ else:
49
+ check_cols(df, [pattern], "-c/--cols")
50
+ matched = [pattern]
51
+ cols += [c for c in matched if c not in cols]
52
+ if len(cols) < 2:
53
+ raise ValueError(f"pairs needs at least two numeric columns, got {cols}")
54
+ return cols
55
+
56
+
57
+ def _groups(df: pd.DataFrame, groupcol: str | None, order: list[str] | None):
58
+ """[(label, sub-DataFrame)], in --groupcolorder, category or first-seen order."""
59
+ if groupcol is None:
60
+ return [(None, df)]
61
+ col = df[groupcol]
62
+ if order:
63
+ present = col.dropna().unique()
64
+ values = [v for v in present if str(v) in order]
65
+ values.sort(key=lambda v: order.index(str(v)))
66
+ values += [v for v in present if str(v) not in order]
67
+ elif isinstance(col.dtype, pd.CategoricalDtype):
68
+ values = [v for v in col.cat.categories if (col == v).any()]
69
+ else:
70
+ values = list(col.dropna().unique())
71
+ return [(str(v), df[col == v]) for v in values]
72
+
73
+
74
+ def _limits(df: pd.DataFrame, col: str) -> tuple[float, float] | None:
75
+ """Axis range of *col*: its data range plus 5% (None if all missing)."""
76
+ values = df[col].dropna().to_numpy(float)
77
+ if values.size == 0:
78
+ return None
79
+ lo, hi = values.min(), values.max()
80
+ pad = 0.05 * (hi - lo or abs(lo) or 1.0) # a constant column too
81
+ return lo - pad, hi + pad
82
+
83
+
84
+ def _diag(ax, groups, col: str, kind: str, colors) -> None:
85
+ """One column's distribution: a KDE (or histogram) per group.
86
+
87
+ With groups, each KDE is scaled by the group's share of the rows, so the
88
+ areas compare as the histogram's counts do.
89
+ """
90
+ from scipy.stats import gaussian_kde
91
+
92
+ ax.set_yticks([])
93
+ data = [g[col].dropna().to_numpy(float) for _, g in groups]
94
+ values = np.concatenate(data)
95
+ if values.size == 0:
96
+ return
97
+ if kind == "hist":
98
+ bins = np.histogram_bin_edges(values, bins="auto")
99
+ for (label, _), v, color in zip(groups, data, colors, strict=False):
100
+ ax.hist(v, bins=bins, color=color, alpha=0.5, label=label)
101
+ else:
102
+ kdes = [
103
+ (label, v, gaussian_kde(v), color)
104
+ for (label, _), v, color in zip(groups, data, colors, strict=False)
105
+ if v.size > 1 and np.ptp(v) > 0
106
+ ]
107
+ if kdes:
108
+ # Run the curves 3 bandwidths past the data, where they reach ~0
109
+ bw = max(float(np.sqrt(kde.covariance[0, 0])) for _, _, kde, _ in kdes)
110
+ grid = np.linspace(values.min() - 3 * bw, values.max() + 3 * bw, 200)
111
+ for label, v, kde, color in kdes:
112
+ density = kde(grid) * v.size / values.size
113
+ ax.fill_between(grid, density, color=color, alpha=0.25)
114
+ ax.plot(grid, density, color=color, label=label)
115
+
116
+
117
+ def _scatter(ax, groups, xcol: str, ycol: str, args, colors) -> None:
118
+ for (label, g), color in zip(groups, colors, strict=False):
119
+ ax.scatter(
120
+ g[xcol],
121
+ g[ycol],
122
+ s=args.markersize,
123
+ alpha=args.alpha,
124
+ color=color,
125
+ label=label,
126
+ linewidths=0,
127
+ rasterized=True, # keeps a PDF small with many points
128
+ )
129
+
130
+
131
+ def _r(df: pd.DataFrame, xcol: str, ycol: str, method: str) -> float:
132
+ pair = df[[xcol, ycol]].dropna()
133
+ return pair[xcol].corr(pair[ycol], method=method) if len(pair) > 1 else np.nan
134
+
135
+
136
+ def _corr_text(ax, df, groups, xcol: str, ycol: str, method: str, cmap, colors):
137
+ """The correlation of the pair; with groups, also r within each group.
138
+
139
+ Without groups the cell is coloured by r. With groups, r over all rows
140
+ can differ even in sign from r within every group (Simpson's paradox),
141
+ so each group's r is written below it, in the group's colour.
142
+ """
143
+ r = _r(df, xcol, ycol, method)
144
+ if len(groups) == 1:
145
+ if np.isfinite(r):
146
+ ax.set_facecolor(cmap((r + 1) / 2))
147
+ ax.text(
148
+ 0.5,
149
+ 0.5,
150
+ f"{r:.2f}",
151
+ transform=ax.transAxes,
152
+ ha="center",
153
+ va="center",
154
+ fontsize="large",
155
+ color="white" if abs(r) > 0.6 else "black",
156
+ )
157
+ else:
158
+ lines = [("all", r, "black")] + [
159
+ (label, _r(g, xcol, ycol, method), color)
160
+ for (label, g), color in zip(groups, colors, strict=False)
161
+ ]
162
+ step = 1 / (len(lines) + 1)
163
+ for n, (label, value, color) in enumerate(lines, start=1):
164
+ text = f"{value:.2f}" if np.isfinite(value) else "-"
165
+ ax.text(
166
+ 0.5,
167
+ 1 - n * step,
168
+ f"{label}: {text}",
169
+ transform=ax.transAxes,
170
+ ha="center",
171
+ va="center",
172
+ fontsize="small" if len(lines) <= 4 else "x-small",
173
+ color=color,
174
+ fontweight="bold" if n == 1 else "normal",
175
+ )
176
+ ax.set_xticks([])
177
+ ax.set_yticks([])
178
+
179
+
180
+ def _figsize(args: argparse.Namespace, k: int) -> tuple[float, float]:
181
+ if args.size != _DEFAULT_SIZE:
182
+ return parse_figsize(args.size)
183
+ side = min(_PANEL_INCHES * k, _MAX_INCHES)
184
+ return side, side
185
+
186
+
187
+ _EPILOG = """\
188
+ LAYOUT
189
+ ------
190
+ Row i, column j shows column j (x) against column i (y); the diagonal shows
191
+ each column's distribution, a KDE by default. All numeric columns are used
192
+ unless -c names some (globs allowed, in the order given):
193
+
194
+ dftk pairs data.tsv -c x y z
195
+ dftk dataset iris -o | dftk pairs -f iris_pairs.png
196
+ dftk dataset penguins -o | dftk pairs -c 'bill*' 'flipper*' body_mass_g \\
197
+ -f penguin_pairs.png
198
+
199
+ GROUPS
200
+ ------
201
+ -g colours points by group, with one KDE per group on the diagonal. Each
202
+ KDE is scaled by its group's share of the rows, so a small group has a
203
+ small curve, as its histogram (--diag hist) would:
204
+
205
+ dftk dataset iris -o | dftk pairs -g species -f iris_species.png
206
+
207
+ UPPER TRIANGLE AND DIAGONAL
208
+ ---------------------------
209
+ --upper corr writes the correlation (--method pearson, spearman or
210
+ kendall) in the upper cells, coloured blue (negative) to red (positive),
211
+ instead of repeating the scatter plots. With -g it writes r over all rows
212
+ and r within each group: they can differ, even in sign (in iris, sepal
213
+ length and width give -0.12 overall and +0.5 to +0.75 within each species).
214
+ --diag hist draws histograms:
215
+
216
+ dftk dataset iris -o | dftk pairs -g species --upper corr --diag hist \\
217
+ -f iris_corr.png
218
+
219
+ LARGE TABLES
220
+ ------------
221
+ Points are rasterized, so a PDF stays small. With many points, lower
222
+ --alpha and --markersize, or plot a sample:
223
+
224
+ dftk dataset diamonds -o | dftk sample -n 5000 --randomseed 1 -o \\
225
+ | dftk pairs -c carat depth price --alpha 0.2 --upper corr \\
226
+ --method spearman -f diamonds_pairs.png
227
+ """
228
+
229
+
230
+ class PairsCommand(BaseCommand):
231
+ name = "pairs"
232
+ help = "Scatter-plot matrix of column pairs, with KDEs on the diagonal."
233
+
234
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
235
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
236
+ parser.epilog = _EPILOG
237
+
238
+ self.add_read_arguments(parser)
239
+
240
+ g = parser.add_argument_group("pairs")
241
+ g.add_argument(
242
+ "-c",
243
+ "--cols",
244
+ nargs="+",
245
+ default=None,
246
+ metavar="COL",
247
+ help="Columns to plot, globs allowed (default: all numeric columns).",
248
+ )
249
+ g.add_argument(
250
+ "-g",
251
+ "--groupcol",
252
+ default=None,
253
+ metavar="COL",
254
+ help="Colour by this column; one KDE per group on the diagonal.",
255
+ )
256
+ g.add_argument(
257
+ "--groupcolorder",
258
+ nargs="+",
259
+ default=None,
260
+ metavar="VAL",
261
+ help="Group order for -g; values not listed come last.",
262
+ )
263
+ g.add_argument(
264
+ "--diag",
265
+ choices=["kde", "hist"],
266
+ default="kde",
267
+ help="Distribution on the diagonal (default: kde).",
268
+ )
269
+ g.add_argument(
270
+ "--upper",
271
+ choices=["scatter", "corr"],
272
+ default="scatter",
273
+ help="Upper triangle: mirrored scatter plots, or the correlation "
274
+ "(default: scatter).",
275
+ )
276
+ g.add_argument(
277
+ "--method",
278
+ choices=["pearson", "spearman", "kendall"],
279
+ default="pearson",
280
+ help="Correlation for --upper corr (default: pearson).",
281
+ )
282
+ g.add_argument(
283
+ "--alpha",
284
+ type=float,
285
+ default=0.6,
286
+ help="Point opacity, 0-1 (default: 0.6).",
287
+ )
288
+ g.add_argument(
289
+ "--markersize",
290
+ type=float,
291
+ default=8.0,
292
+ help="Point size in points^2 (default: 8).",
293
+ )
294
+
295
+ add_figure_arguments(parser)
296
+
297
+ def execute(self, args: argparse.Namespace) -> None:
298
+ df = io.read(args)
299
+ if args.groupcol is not None:
300
+ check_cols(df, [args.groupcol], "-g/--groupcol")
301
+ cols = _columns(df, args.cols, exclude={args.groupcol})
302
+ groups = _groups(df, args.groupcol, args.groupcolorder)
303
+ colors = [WONG_PALETTE[i % len(WONG_PALETTE)] for i in range(len(groups))]
304
+ if args.groupcol is None:
305
+ colors = [WONG_PALETTE[4]] # blue
306
+
307
+ if args.file:
308
+ import matplotlib
309
+
310
+ matplotlib.use("Agg")
311
+
312
+ import matplotlib.pyplot as plt
313
+
314
+ if getattr(args, "usetex", False):
315
+ plt.rc("text", usetex=True)
316
+
317
+ apply_style(args)
318
+ k = len(cols)
319
+ fig, axes = plt.subplots(
320
+ k, k, figsize=_figsize(args, k), squeeze=False, layout="constrained"
321
+ )
322
+ cmap = plt.get_cmap("RdBu_r")
323
+
324
+ for i, ycol in enumerate(cols):
325
+ for j, xcol in enumerate(cols):
326
+ ax = axes[i][j]
327
+ if i == j:
328
+ _diag(ax, groups, xcol, args.diag, colors)
329
+ elif i < j and args.upper == "corr":
330
+ _corr_text(ax, df, groups, xcol, ycol, args.method, cmap, colors)
331
+ else:
332
+ _scatter(ax, groups, xcol, ycol, args, colors)
333
+ # Labels on the outer edge only; columns share x, rows share y
334
+ if i == k - 1:
335
+ ax.set_xlabel(xcol)
336
+ else:
337
+ ax.tick_params(labelbottom=False)
338
+ if j == 0:
339
+ ax.set_ylabel(ycol)
340
+ if j != 0 and i != j:
341
+ ax.tick_params(labelleft=False)
342
+
343
+ # The column's range down each column of panels, and along each row
344
+ limits = [_limits(df, col) for col in cols]
345
+ for i in range(k):
346
+ for j in range(k):
347
+ if i < j and args.upper == "corr":
348
+ continue
349
+ if limits[j] is not None:
350
+ axes[i][j].set_xlim(limits[j])
351
+ if i != j and limits[i] is not None:
352
+ axes[i][j].set_ylim(limits[i])
353
+
354
+ if args.groupcol is not None:
355
+ # From a scatter panel: every group is there, with or without a KDE
356
+ handles, labels = axes[1][0].get_legend_handles_labels()
357
+ fig.legend(
358
+ handles,
359
+ labels,
360
+ title=args.groupcol,
361
+ loc="outside right center",
362
+ )
363
+ if args.title:
364
+ fig.suptitle(args.title)
365
+ fig.align_ylabels(axes[:, 0])
366
+ save_or_show(fig, args)
@@ -135,13 +135,18 @@ Statistics (all accept `-g` for per-group results):
135
135
  | Weighted versions | `wstat` |
136
136
  | Linear, robust and weighted regression (R-style formula) | `fit -f "y ~ x + z"` |
137
137
  | p-values for column pairs (t, Mann-Whitney, KS, …) | `test -c A:B -d p --test NAME` |
138
- | Correlations with CIs, bootstrap, tail dependence | `corr -c A:B` |
138
+ | Correlations with CIs, bootstrap, tail dependence | `corr -c A:B` (globs: `-c '*:*'` every pair once) |
139
+ | Correlation matrix, every pair both ways plus the diagonal | `corr --all [COL…]` |
139
140
 
140
141
  Plots (always with `-f FILE`): `scat` (scatter; `--fit` for an OLS line,
141
142
  `--fit lowess` for a smooth curve, per group with `-g`; `--hexbin` for
142
143
  tables too large for points), `line`, `hist`
143
- (also KDE), `chi` (tail-dependence chi-plot). `-g COL` overlays groups,
144
- `--subgraphcol COL` makes panels.
144
+ (also KDE), `chi` (tail-dependence chi-plot), `heatmap` (coloured grid from a
145
+ long table `-x COL -y COL -v VALUE`, or a wide one with `-y ROWS`; a
146
+ correlogram is `corr --all -o | heatmap -x col1 -y col2 -v correlation
147
+ --annot --vmin -1 --vmax 1`), `pairs` (scatter-plot matrix with KDEs on the diagonal; `-c COL…`,
148
+ `-g COL`, `--upper corr`). `-g COL` overlays groups, `--subgraphcol COL`
149
+ makes panels.
145
150
 
146
151
  Other: `dataset NAME` loads a bundled example table, `annotate FILE.parquet`
147
152
  reads and writes provenance metadata, `clean` removes leftover temp pipe