dataframe-toolkit 0.8.0__tar.gz → 0.9.1__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.
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/PKG-INFO +33 -6
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/README.md +32 -5
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/pyproject.toml +1 -1
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/pyproject.toml.orig +1 -1
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/merge_cmd.py +48 -0
- dataframe_toolkit-0.9.1/src/dftk/commands/scat_cmd.py +454 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/common/io.py +27 -13
- dataframe_toolkit-0.8.0/src/dftk/commands/scat_cmd.py +0 -281
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/__init__.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/cli.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/__init__.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/annotate_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/base.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/binx_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/chi_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/clean_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/cleannames_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/concat_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/corr_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/dataset_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/describe_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/eval_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/fit_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/func_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/help.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/hist_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/info_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/interp_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/line_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/melt_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/pivot_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/print_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/query_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/randvar_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/sample_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/scale_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/segid_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/split_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/stat_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/test_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/transpose_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/wstat_cmd.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/common/__init__.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/common/copula.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/common/plot.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/common/seed.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/common/seq.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/common/stats.py +0 -0
- {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/io.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: dataframe-toolkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.1
|
|
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
|
|
@@ -145,11 +145,12 @@ interactively otherwise. They support `--groupcol` for colour grouping,
|
|
|
145
145
|
`--subgraphcol` for subplot grids, and figure/font presets for
|
|
146
146
|
publication-quality output.
|
|
147
147
|
|
|
148
|
-
| Command | Description
|
|
149
|
-
|
|
150
|
-
| `scat` | Scatter plot;
|
|
151
|
-
| `line` | Line plot; optional error bars (`--yerr`) or CI bands (`--yci lo,hi`) and fit overlay
|
|
152
|
-
| `hist` | Histogram or KDE (`--kde`); normalisation, cumulative mode, mean±σ annotation (`--stats`)
|
|
148
|
+
| Command | Description |
|
|
149
|
+
|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
150
|
+
| `scat` | Scatter plot; OLS/robust or LOWESS fit (`--fit lowess`, one per group with `-g`), hexagonal binning for large tables (`--hexbin`), bubble size (`--sizecol`), colour (`--colorcol`) |
|
|
151
|
+
| `line` | Line plot; optional error bars (`--yerr`) or CI bands (`--yci lo,hi`) and fit overlay |
|
|
152
|
+
| `hist` | Histogram or KDE (`--kde`); normalisation, cumulative mode, mean±σ annotation (`--stats`) |
|
|
153
|
+
| `chi` | Corner-resolved chi-plot: whether tail dependence sits in the lower or upper corner |
|
|
153
154
|
|
|
154
155
|
### Utilities
|
|
155
156
|
|
|
@@ -186,6 +187,17 @@ dftk dataset fertility -o \
|
|
|
186
187
|
|
|
187
188
|
For your own export, `dftk cleannames data.tsv --backend duckdb -o data.parquet` also keeps integer columns with empty cells as integers.
|
|
188
189
|
|
|
190
|
+
### Large tables: hexbin instead of points
|
|
191
|
+
|
|
192
|
+
With tens of thousands of points a scatter plot is a solid blob. `--hexbin` counts points per hexagon instead (`--hexlog` for a log colour scale), and `--fit lowess` adds a smooth curve:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
dftk dataset diamonds -o \
|
|
196
|
+
| dftk scat ... -x carat -y price --hexbin --hexlog --logx --logy -f diamonds.png
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`--colorcol COL` colours each hexagon by the mean of `COL` instead of the count; with `--subgraphcol` all panels share one colour scale.
|
|
200
|
+
|
|
189
201
|
### Group summary then plot
|
|
190
202
|
|
|
191
203
|
```bash
|
|
@@ -239,6 +251,21 @@ All commands accept:
|
|
|
239
251
|
> parquet and prints *its path* to stdout — `-` would try to parse that path
|
|
240
252
|
> as TSV and fail.
|
|
241
253
|
|
|
254
|
+
### Comment lines and preambles
|
|
255
|
+
|
|
256
|
+
dftk has no comment or skip-rows option: `#` is ordinary data, so headers
|
|
257
|
+
like `Count #` and values like `A #1` are read as they are. Drop comment
|
|
258
|
+
lines or a preamble in the shell instead:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
grep -v '^#' data.tsv | dftk print # drop every line starting with #
|
|
262
|
+
tail -n +3 data.tsv | dftk print # skip a 2-line preamble
|
|
263
|
+
dftk print <(grep -v '^#' data.tsv) # the same, as a filename
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
With `--backend duckdb`, a whole-line `#` comment below the header is an
|
|
267
|
+
error, and `#` lines above the header are skipped.
|
|
268
|
+
|
|
242
269
|
Standard output options (available on all tabular commands):
|
|
243
270
|
|
|
244
271
|
- `-o` / `--output` — controls where output goes; takes an *optional* value:
|
|
@@ -116,11 +116,12 @@ interactively otherwise. They support `--groupcol` for colour grouping,
|
|
|
116
116
|
`--subgraphcol` for subplot grids, and figure/font presets for
|
|
117
117
|
publication-quality output.
|
|
118
118
|
|
|
119
|
-
| Command | Description
|
|
120
|
-
|
|
121
|
-
| `scat` | Scatter plot;
|
|
122
|
-
| `line` | Line plot; optional error bars (`--yerr`) or CI bands (`--yci lo,hi`) and fit overlay
|
|
123
|
-
| `hist` | Histogram or KDE (`--kde`); normalisation, cumulative mode, mean±σ annotation (`--stats`)
|
|
119
|
+
| Command | Description |
|
|
120
|
+
|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
121
|
+
| `scat` | Scatter plot; OLS/robust or LOWESS fit (`--fit lowess`, one per group with `-g`), hexagonal binning for large tables (`--hexbin`), bubble size (`--sizecol`), colour (`--colorcol`) |
|
|
122
|
+
| `line` | Line plot; optional error bars (`--yerr`) or CI bands (`--yci lo,hi`) and fit overlay |
|
|
123
|
+
| `hist` | Histogram or KDE (`--kde`); normalisation, cumulative mode, mean±σ annotation (`--stats`) |
|
|
124
|
+
| `chi` | Corner-resolved chi-plot: whether tail dependence sits in the lower or upper corner |
|
|
124
125
|
|
|
125
126
|
### Utilities
|
|
126
127
|
|
|
@@ -157,6 +158,17 @@ dftk dataset fertility -o \
|
|
|
157
158
|
|
|
158
159
|
For your own export, `dftk cleannames data.tsv --backend duckdb -o data.parquet` also keeps integer columns with empty cells as integers.
|
|
159
160
|
|
|
161
|
+
### Large tables: hexbin instead of points
|
|
162
|
+
|
|
163
|
+
With tens of thousands of points a scatter plot is a solid blob. `--hexbin` counts points per hexagon instead (`--hexlog` for a log colour scale), and `--fit lowess` adds a smooth curve:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
dftk dataset diamonds -o \
|
|
167
|
+
| dftk scat ... -x carat -y price --hexbin --hexlog --logx --logy -f diamonds.png
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`--colorcol COL` colours each hexagon by the mean of `COL` instead of the count; with `--subgraphcol` all panels share one colour scale.
|
|
171
|
+
|
|
160
172
|
### Group summary then plot
|
|
161
173
|
|
|
162
174
|
```bash
|
|
@@ -210,6 +222,21 @@ All commands accept:
|
|
|
210
222
|
> parquet and prints *its path* to stdout — `-` would try to parse that path
|
|
211
223
|
> as TSV and fail.
|
|
212
224
|
|
|
225
|
+
### Comment lines and preambles
|
|
226
|
+
|
|
227
|
+
dftk has no comment or skip-rows option: `#` is ordinary data, so headers
|
|
228
|
+
like `Count #` and values like `A #1` are read as they are. Drop comment
|
|
229
|
+
lines or a preamble in the shell instead:
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
grep -v '^#' data.tsv | dftk print # drop every line starting with #
|
|
233
|
+
tail -n +3 data.tsv | dftk print # skip a 2-line preamble
|
|
234
|
+
dftk print <(grep -v '^#' data.tsv) # the same, as a filename
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
With `--backend duckdb`, a whole-line `#` comment below the header is an
|
|
238
|
+
error, and `#` lines above the header are skipped.
|
|
239
|
+
|
|
213
240
|
Standard output options (available on all tabular commands):
|
|
214
241
|
|
|
215
242
|
- `-o` / `--output` — controls where output goes; takes an *optional* value:
|
|
@@ -17,6 +17,8 @@ Key features
|
|
|
17
17
|
* --select [:right:] expands to all right-file columns, suppressing ``_l``.
|
|
18
18
|
* Both input files can be TSV or Parquet (.parquet extension auto-detected).
|
|
19
19
|
The left file defaults to stdin, making it parquet-pipe friendly.
|
|
20
|
+
* Integer columns that a left, right or outer join leaves gaps in become
|
|
21
|
+
nullable (Int64) instead of float64.
|
|
20
22
|
"""
|
|
21
23
|
|
|
22
24
|
import argparse
|
|
@@ -107,6 +109,35 @@ def _add_cross_col(
|
|
|
107
109
|
args.drop = drop
|
|
108
110
|
|
|
109
111
|
|
|
112
|
+
def _gap_sides(args: argparse.Namespace) -> tuple[bool, bool]:
|
|
113
|
+
"""Return (left, right): which side's columns can get missing values.
|
|
114
|
+
|
|
115
|
+
--only runs an outer join before filtering, so both sides count.
|
|
116
|
+
"""
|
|
117
|
+
return args.type in ("right", "outer"), args.type in ("left", "outer")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _nullable_ints(df: pd.DataFrame, keep: list[str]) -> pd.DataFrame:
|
|
121
|
+
"""Return *df* with numpy integer columns (except *keep*) as Int64 etc.
|
|
122
|
+
|
|
123
|
+
A plain int64 column cannot hold a missing value, so pandas turns it into
|
|
124
|
+
float64 when a join leaves rows unmatched; the nullable type keeps the
|
|
125
|
+
integers.
|
|
126
|
+
"""
|
|
127
|
+
cols = [
|
|
128
|
+
c
|
|
129
|
+
for c in df.columns
|
|
130
|
+
if c not in keep
|
|
131
|
+
and pd.api.types.is_integer_dtype(df[c])
|
|
132
|
+
and not isinstance(df[c].dtype, pd.api.extensions.ExtensionDtype)
|
|
133
|
+
]
|
|
134
|
+
if not cols:
|
|
135
|
+
return df
|
|
136
|
+
# int32 -> Int32, uint8 -> UInt8, ...
|
|
137
|
+
dtypes = {c: df[c].dtype.name.capitalize().replace("Uint", "UInt") for c in cols}
|
|
138
|
+
return df.astype(dtypes)
|
|
139
|
+
|
|
140
|
+
|
|
110
141
|
def _filter_only(
|
|
111
142
|
df: pd.DataFrame,
|
|
112
143
|
df_left: pd.DataFrame,
|
|
@@ -152,6 +183,15 @@ def _do_merge(
|
|
|
152
183
|
|
|
153
184
|
suffixes = _expand_select(list(df_left.columns), list(df_right.columns), args)
|
|
154
185
|
|
|
186
|
+
# Keep integer columns integers when the join leaves gaps in them. The
|
|
187
|
+
# -k keys are filled from both sides, so they never get gaps.
|
|
188
|
+
keys = args.keys or []
|
|
189
|
+
left_gaps, right_gaps = _gap_sides(args)
|
|
190
|
+
if left_gaps:
|
|
191
|
+
df_left = _nullable_ints(df_left, keys)
|
|
192
|
+
if right_gaps:
|
|
193
|
+
df_right = _nullable_ints(df_right, keys)
|
|
194
|
+
|
|
155
195
|
if args.keys is not None:
|
|
156
196
|
df_merged = pd.merge(
|
|
157
197
|
df_left,
|
|
@@ -278,6 +318,14 @@ would be read as if it were the data.
|
|
|
278
318
|
Named-file self-merge (no stdin required):
|
|
279
319
|
|
|
280
320
|
dftk merge penguins.tsv -r penguins_adelie.tsv -k species --only left
|
|
321
|
+
|
|
322
|
+
INTEGER COLUMNS
|
|
323
|
+
---------------
|
|
324
|
+
A left, right or outer join leaves the other side's columns empty in
|
|
325
|
+
unmatched rows. Integer columns there are made nullable (Int64), so they
|
|
326
|
+
stay integers (100, not 100.0); the -k key columns are not changed. Read
|
|
327
|
+
TSV input with --backend duckdb to keep integer columns that already have
|
|
328
|
+
empty cells as integers too.
|
|
281
329
|
"""
|
|
282
330
|
|
|
283
331
|
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dftk.commands.scat_cmd — dftk scat subcommand.
|
|
3
|
+
|
|
4
|
+
Scatter plot with optional OLS / robust regression line overlay.
|
|
5
|
+
|
|
6
|
+
Port of dfscat.py with improvements:
|
|
7
|
+
- --groupcol colour-codes points by a column
|
|
8
|
+
- --subgraphcol splits into a subplot grid
|
|
9
|
+
- --size / --fontsize publication-quality figure presets
|
|
10
|
+
- --fit overlays an OLS (or robust) regression line via fit_cmd.regress_it,
|
|
11
|
+
or a LOWESS curve (--fit lowess); with --groupcol, one per group
|
|
12
|
+
- --hexbin hexagonal binning for large tables (port of dfhexb.py)
|
|
13
|
+
- --sizecol point size from a data column (was --area)
|
|
14
|
+
- --colorcol continuous colour mapping from a data column (was --color)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
import matplotlib.lines as mlines
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from dftk.commands.base import BaseCommand
|
|
24
|
+
from dftk.common.io import check_cols, io
|
|
25
|
+
from dftk.common.plot import (
|
|
26
|
+
add_figure_arguments,
|
|
27
|
+
add_group_arguments,
|
|
28
|
+
add_legend_arguments,
|
|
29
|
+
add_xy_arguments,
|
|
30
|
+
apply_labels,
|
|
31
|
+
apply_limits,
|
|
32
|
+
apply_style,
|
|
33
|
+
apply_ticks,
|
|
34
|
+
make_figure,
|
|
35
|
+
make_grouplabel,
|
|
36
|
+
save_or_show,
|
|
37
|
+
subgraph_groups,
|
|
38
|
+
subgraph_layout,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Fit overlay
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ols_curve(x, y, args, label: str):
|
|
47
|
+
"""Fit OLS (or RLM) of y on x; return (x_plot, y_plot, legend text)."""
|
|
48
|
+
import pandas as pd
|
|
49
|
+
|
|
50
|
+
from dftk.commands.fit_cmd import regress_it
|
|
51
|
+
|
|
52
|
+
fit_args = argparse.Namespace(
|
|
53
|
+
formula="_y ~ _x" if not args.nointercept else "_y ~ _x - 1",
|
|
54
|
+
weights=None,
|
|
55
|
+
robust=getattr(args, "robust", False),
|
|
56
|
+
)
|
|
57
|
+
res = regress_it(pd.DataFrame({"_x": x, "_y": y}), fit_args)
|
|
58
|
+
if label:
|
|
59
|
+
print(f"== {label}", file=sys.stderr)
|
|
60
|
+
print(res.summary(), file=sys.stderr)
|
|
61
|
+
|
|
62
|
+
slope = res.params["_x"]
|
|
63
|
+
intercept = 0.0 if args.nointercept else res.params["Intercept"]
|
|
64
|
+
if args.nointercept:
|
|
65
|
+
text = f"{slope:+.3g} × {args.xcol}"
|
|
66
|
+
else:
|
|
67
|
+
text = f"{intercept:.3g}{slope:+.3g} × {args.xcol}"
|
|
68
|
+
if getattr(args, "pvalue", False):
|
|
69
|
+
pval = res.pvalues.get("_x", res.pvalues.iloc[-1])
|
|
70
|
+
text += f" -log10(p)={-np.log10(pval):.2g}"
|
|
71
|
+
return slope, intercept, text
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _lowess_curve(x, y, args):
|
|
75
|
+
"""Return the LOWESS curve of y on x as (x_sorted, y_smoothed)."""
|
|
76
|
+
from statsmodels.nonparametric.smoothers_lowess import lowess
|
|
77
|
+
|
|
78
|
+
# delta: fit only every 1% of the x range and interpolate in between.
|
|
79
|
+
# Without it LOWESS is quadratic in n (10,000 points take seconds,
|
|
80
|
+
# 200,000 do not finish); with it 1,000,000 points take a few seconds.
|
|
81
|
+
delta = 0.01 * (x.max() - x.min())
|
|
82
|
+
curve = lowess(y, x, frac=args.frac, delta=delta)
|
|
83
|
+
return curve[:, 0], curve[:, 1]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _fit_overlay(ax, x_series, y_series, args, color=None, label: str = ""):
|
|
87
|
+
"""Overlay the --fit line or curve on ax.
|
|
88
|
+
|
|
89
|
+
Without *color* (no --groupcol) the fit gets its own red legend entry,
|
|
90
|
+
as before. With *color* it is drawn in the group's colour, over that
|
|
91
|
+
group's x range, and the group legend already identifies it.
|
|
92
|
+
"""
|
|
93
|
+
import pandas as pd
|
|
94
|
+
|
|
95
|
+
data = pd.DataFrame({"x": x_series.values, "y": y_series.values}).dropna()
|
|
96
|
+
data = data.astype(float)
|
|
97
|
+
if len(data) < 3:
|
|
98
|
+
print(
|
|
99
|
+
f"--fit: skipped {label or 'data'} (fewer than 3 points)", file=sys.stderr
|
|
100
|
+
)
|
|
101
|
+
return
|
|
102
|
+
x, y = data["x"].to_numpy(), data["y"].to_numpy()
|
|
103
|
+
|
|
104
|
+
if args.fit == "lowess":
|
|
105
|
+
x_plot, y_plot = _lowess_curve(x, y, args)
|
|
106
|
+
text = f"LOWESS (frac={args.frac:.2g})"
|
|
107
|
+
else:
|
|
108
|
+
slope, intercept, text = _ols_curve(x, y, args, label)
|
|
109
|
+
xmin, xmax = (x.min(), x.max()) if color is not None else ax.get_xlim()
|
|
110
|
+
x_plot = np.linspace(xmin, xmax, 200)
|
|
111
|
+
y_plot = intercept + slope * x_plot
|
|
112
|
+
|
|
113
|
+
if color is None:
|
|
114
|
+
ax.plot(x_plot, y_plot, "r--", linewidth=1)
|
|
115
|
+
ax.legend(
|
|
116
|
+
handles=[mlines.Line2D([], [], color="red", linestyle="--", label=text)],
|
|
117
|
+
loc=0,
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
ax.plot(x_plot, y_plot, "--", color=color, linewidth=1.2)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _hexbin_ax(ax, df, args):
|
|
124
|
+
"""Draw a hexagonal-bin density plot (or mean of --colorcol per hexagon)."""
|
|
125
|
+
import matplotlib.pyplot as plt
|
|
126
|
+
|
|
127
|
+
cols = [args.xcol, args.ycol] + ([args.colorcol] if args.colorcol else [])
|
|
128
|
+
data = df[cols].dropna()
|
|
129
|
+
for flag, col in (("logx", args.xcol), ("logy", args.ycol)):
|
|
130
|
+
if getattr(args, flag, False):
|
|
131
|
+
positive = data[col] > 0
|
|
132
|
+
if not positive.all():
|
|
133
|
+
print(
|
|
134
|
+
f"--hexbin --{flag}: dropped {(~positive).sum()} rows with "
|
|
135
|
+
f"{col} <= 0",
|
|
136
|
+
file=sys.stderr,
|
|
137
|
+
)
|
|
138
|
+
data = data[positive]
|
|
139
|
+
|
|
140
|
+
kw = dict(
|
|
141
|
+
gridsize=args.gridsize,
|
|
142
|
+
mincnt=1,
|
|
143
|
+
cmap="viridis",
|
|
144
|
+
# bin on the log scale, so hexagons are regular on log axes
|
|
145
|
+
xscale="log" if getattr(args, "logx", False) else "linear",
|
|
146
|
+
yscale="log" if getattr(args, "logy", False) else "linear",
|
|
147
|
+
)
|
|
148
|
+
if args.hexlog:
|
|
149
|
+
kw["bins"] = "log"
|
|
150
|
+
if args.colorcol:
|
|
151
|
+
kw["C"] = data[args.colorcol]
|
|
152
|
+
kw["reduce_C_function"] = np.mean
|
|
153
|
+
label = f"mean {args.colorcol}"
|
|
154
|
+
else:
|
|
155
|
+
label = "count"
|
|
156
|
+
hb = ax.hexbin(data[args.xcol], data[args.ycol], **kw)
|
|
157
|
+
plt.colorbar(hb, ax=ax, label=label)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _share_hexbin_colours(axes) -> None:
|
|
161
|
+
"""Give every --subgraphcol panel the same colour scale, so they compare."""
|
|
162
|
+
from matplotlib.collections import PolyCollection
|
|
163
|
+
|
|
164
|
+
hexbins = [
|
|
165
|
+
c
|
|
166
|
+
for row in axes
|
|
167
|
+
for ax in row
|
|
168
|
+
for c in ax.collections
|
|
169
|
+
if isinstance(c, PolyCollection) and c.get_array() is not None
|
|
170
|
+
]
|
|
171
|
+
values = [hb.get_array() for hb in hexbins if hb.get_array().size]
|
|
172
|
+
if not values:
|
|
173
|
+
return
|
|
174
|
+
vmin = min(float(v.min()) for v in values)
|
|
175
|
+
vmax = max(float(v.max()) for v in values)
|
|
176
|
+
for hb in hexbins:
|
|
177
|
+
hb.set_clim(vmin, vmax)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# Single-axes scatter (reused per subplot)
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _scatter_ax(ax, df, args, title_suffix: str = ""):
|
|
186
|
+
"""Draw scatter on a single Axes, with optional --groupcol colouring."""
|
|
187
|
+
import matplotlib.pyplot as plt
|
|
188
|
+
|
|
189
|
+
xcol = args.xcol
|
|
190
|
+
ycol = args.ycol
|
|
191
|
+
legendloc = tuple(args.legendloc) if args.legendloc else "best"
|
|
192
|
+
|
|
193
|
+
scatter_kw = dict(marker=getattr(args, "marker", "o"), alpha=0.7)
|
|
194
|
+
|
|
195
|
+
if getattr(args, "hexbin", False):
|
|
196
|
+
_hexbin_ax(ax, df, args)
|
|
197
|
+
if getattr(args, "fit", None):
|
|
198
|
+
_fit_overlay(ax, df[xcol], df[ycol], args)
|
|
199
|
+
elif args.groupcol is not None:
|
|
200
|
+
for groupname, gdf in subgraph_groups(
|
|
201
|
+
df, args.groupcol, getattr(args, "groupcolorder", None)
|
|
202
|
+
):
|
|
203
|
+
label = make_grouplabel(
|
|
204
|
+
groupname, args.groupcol, getattr(args, "groupcolformat", None)
|
|
205
|
+
)
|
|
206
|
+
kw = dict(scatter_kw)
|
|
207
|
+
if args.sizecol:
|
|
208
|
+
kw["s"] = gdf[args.sizecol]
|
|
209
|
+
points = ax.scatter(gdf[xcol], gdf[ycol], label=label, **kw)
|
|
210
|
+
if getattr(args, "fit", None):
|
|
211
|
+
color = points.get_facecolor()[0]
|
|
212
|
+
_fit_overlay(ax, gdf[xcol], gdf[ycol], args, color=color, label=label)
|
|
213
|
+
ax.legend(loc=legendloc, title=args.legendtitle)
|
|
214
|
+
else:
|
|
215
|
+
kw = dict(scatter_kw)
|
|
216
|
+
if args.sizecol:
|
|
217
|
+
kw["s"] = df[args.sizecol]
|
|
218
|
+
if args.colorcol:
|
|
219
|
+
sc = ax.scatter(
|
|
220
|
+
df[xcol], df[ycol], c=df[args.colorcol], cmap="viridis", **kw
|
|
221
|
+
)
|
|
222
|
+
plt.colorbar(sc, ax=ax, label=args.colorcol)
|
|
223
|
+
else:
|
|
224
|
+
label = args.legend or ""
|
|
225
|
+
ax.scatter(df[xcol], df[ycol], label=label if label else None, **kw)
|
|
226
|
+
if label:
|
|
227
|
+
ax.legend(loc=legendloc, title=args.legendtitle)
|
|
228
|
+
|
|
229
|
+
if getattr(args, "fit", None):
|
|
230
|
+
_fit_overlay(ax, df[xcol], df[ycol], args)
|
|
231
|
+
|
|
232
|
+
apply_limits(ax, args)
|
|
233
|
+
apply_ticks(ax, args)
|
|
234
|
+
apply_labels(ax, args, default_xlabel=xcol or "", default_ylabel=ycol or "")
|
|
235
|
+
if title_suffix:
|
|
236
|
+
existing = ax.get_title()
|
|
237
|
+
ax.set_title(f"{existing} [{title_suffix}]" if existing else title_suffix)
|
|
238
|
+
|
|
239
|
+
ax.grid(True, linewidth=0.4, alpha=0.5)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# Command
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
_EPILOG = """\
|
|
247
|
+
EXAMPLES
|
|
248
|
+
--------
|
|
249
|
+
Basic scatter:
|
|
250
|
+
|
|
251
|
+
dftk scat data.tsv -x weight -y height
|
|
252
|
+
dftk dataset tips -o | dftk scat ... -x total_bill -y tip
|
|
253
|
+
|
|
254
|
+
Colour by group:
|
|
255
|
+
|
|
256
|
+
dftk dataset tips -o | dftk scat ... -x total_bill -y tip -g day
|
|
257
|
+
|
|
258
|
+
Subplot grid by time-of-day, colour by smoker:
|
|
259
|
+
|
|
260
|
+
dftk dataset tips -o | dftk scat ... -x total_bill -y tip \\
|
|
261
|
+
--subgraphcol time -g smoker
|
|
262
|
+
|
|
263
|
+
Fit OLS line, or one per group in the group's colour:
|
|
264
|
+
|
|
265
|
+
dftk dataset tips -o | dftk scat ... -x total_bill -y tip --fit
|
|
266
|
+
dftk dataset tips -o | dftk scat ... -x total_bill -y tip -g smoker --fit
|
|
267
|
+
|
|
268
|
+
LOWESS curve (local regression; smaller --frac follows the data more
|
|
269
|
+
closely). At the ends of the x range, where few points are left, the curve
|
|
270
|
+
can overshoot the data; a larger --frac or --xlim helps:
|
|
271
|
+
|
|
272
|
+
dftk dataset tips -o | dftk scat ... -x total_bill -y tip --fit lowess
|
|
273
|
+
|
|
274
|
+
Hexagonal binning for large tables (54,000 diamonds), log colour scale and
|
|
275
|
+
log axes; hexagons are binned on the log scale:
|
|
276
|
+
|
|
277
|
+
dftk dataset diamonds -o | dftk scat ... -x carat -y price \
|
|
278
|
+
--hexbin --hexlog --logx --logy
|
|
279
|
+
|
|
280
|
+
Colour each hexagon by the mean of another column instead of the count:
|
|
281
|
+
|
|
282
|
+
dftk dataset diamonds -o | dftk scat ... -x carat -y price \
|
|
283
|
+
--hexbin --colorcol depth --gridsize 50
|
|
284
|
+
|
|
285
|
+
One hexbin panel per group, all on the same colour scale:
|
|
286
|
+
|
|
287
|
+
dftk dataset diamonds -o | dftk scat ... -x carat -y price \
|
|
288
|
+
--hexbin --hexlog --subgraphcol cut --gridsize 40
|
|
289
|
+
|
|
290
|
+
Publication figure (Nature single column, PDF):
|
|
291
|
+
|
|
292
|
+
dftk dataset tips -o | dftk scat ... -x total_bill -y tip \\
|
|
293
|
+
--size single --fontsize publication -f fig.pdf
|
|
294
|
+
|
|
295
|
+
Bubble chart (point size from column):
|
|
296
|
+
|
|
297
|
+
dftk dataset tips -o | dftk scat ... -x total_bill -y tip --sizecol size
|
|
298
|
+
|
|
299
|
+
Continuous colour mapping:
|
|
300
|
+
|
|
301
|
+
dftk dataset tips -o | dftk scat ... -x total_bill -y tip --colorcol tip
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class ScatCommand(BaseCommand):
|
|
306
|
+
name = "scat"
|
|
307
|
+
help = "Scatter plot with optional regression line overlay."
|
|
308
|
+
|
|
309
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
310
|
+
parser.formatter_class = argparse.RawDescriptionHelpFormatter
|
|
311
|
+
parser.epilog = _EPILOG
|
|
312
|
+
|
|
313
|
+
self.add_read_arguments(parser)
|
|
314
|
+
add_xy_arguments(parser)
|
|
315
|
+
add_figure_arguments(parser)
|
|
316
|
+
add_group_arguments(parser)
|
|
317
|
+
add_legend_arguments(parser)
|
|
318
|
+
|
|
319
|
+
s = parser.add_argument_group("scatter options")
|
|
320
|
+
s.add_argument(
|
|
321
|
+
"-m",
|
|
322
|
+
"--marker",
|
|
323
|
+
default="o",
|
|
324
|
+
metavar="MARKER",
|
|
325
|
+
help="Matplotlib marker style (default: o).",
|
|
326
|
+
)
|
|
327
|
+
s.add_argument(
|
|
328
|
+
"--sizecol",
|
|
329
|
+
default=None,
|
|
330
|
+
metavar="COL",
|
|
331
|
+
help="Column to use for point sizes (bubble chart).",
|
|
332
|
+
)
|
|
333
|
+
s.add_argument(
|
|
334
|
+
"--colorcol",
|
|
335
|
+
default=None,
|
|
336
|
+
metavar="COL",
|
|
337
|
+
help="Column for continuous colour mapping.",
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
h = parser.add_argument_group("hexbin options")
|
|
341
|
+
h.add_argument(
|
|
342
|
+
"--hexbin",
|
|
343
|
+
action="store_true",
|
|
344
|
+
help="Hexagonal binning instead of points, for large tables: "
|
|
345
|
+
"colour shows the number of points per hexagon, or with "
|
|
346
|
+
"--colorcol the mean of that column. Not with -g or --sizecol.",
|
|
347
|
+
)
|
|
348
|
+
h.add_argument(
|
|
349
|
+
"--gridsize",
|
|
350
|
+
type=int,
|
|
351
|
+
default=100,
|
|
352
|
+
metavar="N",
|
|
353
|
+
help="Number of hexagons across the x axis (default: 100).",
|
|
354
|
+
)
|
|
355
|
+
h.add_argument(
|
|
356
|
+
"--hexlog",
|
|
357
|
+
action="store_true",
|
|
358
|
+
help="Log colour scale, so sparse areas stay visible.",
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
f = parser.add_argument_group("fit options")
|
|
362
|
+
f.add_argument(
|
|
363
|
+
"--fit",
|
|
364
|
+
nargs="?",
|
|
365
|
+
const="ols",
|
|
366
|
+
default=None,
|
|
367
|
+
choices=["ols", "lowess"],
|
|
368
|
+
help="Overlay a fit: ols (the default for a bare --fit; a "
|
|
369
|
+
"regression line, summary to stderr) or lowess (a smooth curve). "
|
|
370
|
+
"With -g, one per group in the group's colour.",
|
|
371
|
+
)
|
|
372
|
+
f.add_argument(
|
|
373
|
+
"--frac",
|
|
374
|
+
type=float,
|
|
375
|
+
default=2 / 3,
|
|
376
|
+
metavar="F",
|
|
377
|
+
help="Share of the data used for each local fit in --fit lowess "
|
|
378
|
+
"(default: 2/3). Smaller values follow the data more closely.",
|
|
379
|
+
)
|
|
380
|
+
f.add_argument(
|
|
381
|
+
"-r",
|
|
382
|
+
"--robust",
|
|
383
|
+
action="store_true",
|
|
384
|
+
help="Use robust regression (RLM) for --fit ols.",
|
|
385
|
+
)
|
|
386
|
+
f.add_argument(
|
|
387
|
+
"--nointercept", action="store_true", help="Suppress intercept in --fit."
|
|
388
|
+
)
|
|
389
|
+
f.add_argument(
|
|
390
|
+
"--pvalue",
|
|
391
|
+
action="store_true",
|
|
392
|
+
help="Add −log₁₀(p) to the fit legend label.",
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
def execute(self, args: argparse.Namespace) -> None:
|
|
396
|
+
if not args.xcol or not args.ycol:
|
|
397
|
+
raise ValueError("--xcol (-x) and --ycol (-y) are required.")
|
|
398
|
+
|
|
399
|
+
df = io.read(args)
|
|
400
|
+
check_cols(df, [args.xcol, args.ycol], "-x/-y")
|
|
401
|
+
check_cols(df, args.groupcol, "-g/--groupcol")
|
|
402
|
+
check_cols(df, args.subgraphcol, "--subgraphcol")
|
|
403
|
+
if args.sizecol:
|
|
404
|
+
check_cols(df, [args.sizecol], "--sizecol")
|
|
405
|
+
if args.colorcol:
|
|
406
|
+
check_cols(df, [args.colorcol], "--colorcol")
|
|
407
|
+
if args.hexbin:
|
|
408
|
+
if args.groupcol:
|
|
409
|
+
raise ValueError(
|
|
410
|
+
"--hexbin cannot be combined with -g/--groupcol: overlaid "
|
|
411
|
+
"densities cannot be told apart. Use --subgraphcol instead."
|
|
412
|
+
)
|
|
413
|
+
if args.sizecol:
|
|
414
|
+
raise ValueError("--hexbin cannot be combined with --sizecol.")
|
|
415
|
+
if args.fit == "lowess" and not 0 < args.frac <= 1:
|
|
416
|
+
raise ValueError(f"--frac must be in (0, 1], got {args.frac}")
|
|
417
|
+
|
|
418
|
+
# Use non-interactive backend when saving to file
|
|
419
|
+
if args.file:
|
|
420
|
+
import matplotlib
|
|
421
|
+
|
|
422
|
+
matplotlib.use("Agg")
|
|
423
|
+
|
|
424
|
+
import matplotlib.pyplot as plt
|
|
425
|
+
|
|
426
|
+
if args.usetex:
|
|
427
|
+
plt.rc("text", usetex=True)
|
|
428
|
+
|
|
429
|
+
apply_style(args)
|
|
430
|
+
|
|
431
|
+
if args.subgraphcol is not None:
|
|
432
|
+
groups = subgraph_groups(
|
|
433
|
+
df, args.subgraphcol, getattr(args, "subgraphorder", None)
|
|
434
|
+
)
|
|
435
|
+
nrows, ncols = subgraph_layout(len(groups), args.ncols)
|
|
436
|
+
fig, axes = make_figure(args, nrows=nrows, ncols=ncols)
|
|
437
|
+
for idx, (groupname, gdf) in enumerate(groups):
|
|
438
|
+
ax = axes[idx // ncols][idx % ncols]
|
|
439
|
+
suffix = make_grouplabel(
|
|
440
|
+
groupname, args.subgraphcol, getattr(args, "subgraphformat", None)
|
|
441
|
+
)
|
|
442
|
+
_scatter_ax(ax, gdf, args, title_suffix=suffix)
|
|
443
|
+
# Hide unused axes
|
|
444
|
+
for idx in range(len(groups), nrows * ncols):
|
|
445
|
+
axes[idx // ncols][idx % ncols].set_visible(False)
|
|
446
|
+
if args.hexbin:
|
|
447
|
+
_share_hexbin_colours(axes)
|
|
448
|
+
fig.tight_layout()
|
|
449
|
+
else:
|
|
450
|
+
fig, axes = make_figure(args)
|
|
451
|
+
_scatter_ax(axes[0][0], df, args)
|
|
452
|
+
fig.tight_layout()
|
|
453
|
+
|
|
454
|
+
save_or_show(fig, args)
|
|
@@ -27,6 +27,7 @@ import fnmatch
|
|
|
27
27
|
import io as _io
|
|
28
28
|
import logging
|
|
29
29
|
import os
|
|
30
|
+
import shutil
|
|
30
31
|
import sys
|
|
31
32
|
import tempfile
|
|
32
33
|
|
|
@@ -365,14 +366,30 @@ class io:
|
|
|
365
366
|
return _name_columns(df)
|
|
366
367
|
|
|
367
368
|
def _csv_with_duckdb(path: str) -> pd.DataFrame:
|
|
368
|
-
# DuckDB keeps integer columns with blanks as Int64 (pandas: float64)
|
|
369
|
+
# DuckDB keeps integer columns with blanks as Int64 (pandas: float64).
|
|
370
|
+
# comment="" stops it guessing "#" as a comment character: after one
|
|
371
|
+
# whole-line "#" comment it cuts every line at "#", headers included
|
|
369
372
|
try:
|
|
370
|
-
df = duckdb.read_csv(
|
|
373
|
+
df = duckdb.read_csv(
|
|
374
|
+
path, sep=sep, header=not noheader, comment=""
|
|
375
|
+
).df()
|
|
371
376
|
except Exception as exc:
|
|
372
377
|
logging.error(f"DuckDB failed to read {path!r}: {exc}")
|
|
373
378
|
raise
|
|
374
379
|
return _name_columns(df)
|
|
375
380
|
|
|
381
|
+
def _spooled_csv_with_duckdb(stream) -> pd.DataFrame:
|
|
382
|
+
# DuckDB reads its input twice (type sniffing, then data), so a
|
|
383
|
+
# stream must be copied to a temp file first
|
|
384
|
+
os.makedirs(DFTK_TMPDIR, exist_ok=True)
|
|
385
|
+
fd, path = tempfile.mkstemp(suffix=".tsv", dir=DFTK_TMPDIR)
|
|
386
|
+
try:
|
|
387
|
+
with os.fdopen(fd, "wb") as fh:
|
|
388
|
+
shutil.copyfileobj(stream, fh)
|
|
389
|
+
return _csv_with_duckdb(path)
|
|
390
|
+
finally:
|
|
391
|
+
os.unlink(path)
|
|
392
|
+
|
|
376
393
|
def _csv_from_file(path: str) -> pd.DataFrame:
|
|
377
394
|
df = _pandas_csv(path)
|
|
378
395
|
df = _with_blank_lines(
|
|
@@ -403,21 +420,18 @@ class io:
|
|
|
403
420
|
df = pd.read_parquet(filename)
|
|
404
421
|
df.attrs["_parquet_meta"] = _read_parquet_meta(filename)
|
|
405
422
|
elif backend == "duckdb" and duckdb is not None:
|
|
406
|
-
|
|
423
|
+
if os.path.exists(filename) and not os.path.isfile(filename):
|
|
424
|
+
# A pipe, e.g. <(grep -v '^#' file): one read would use it up
|
|
425
|
+
with open(filename, "rb") as fh:
|
|
426
|
+
df = _spooled_csv_with_duckdb(fh)
|
|
427
|
+
else:
|
|
428
|
+
df = _csv_with_duckdb(filename)
|
|
407
429
|
else:
|
|
408
430
|
df = _csv_from_file(filename)
|
|
409
431
|
|
|
410
432
|
elif backend == "duckdb" and duckdb is not None:
|
|
411
|
-
# stdin with DuckDB: it reads files, not buffers (that needs fsspec)
|
|
412
|
-
|
|
413
|
-
os.makedirs(DFTK_TMPDIR, exist_ok=True)
|
|
414
|
-
fd, path = tempfile.mkstemp(suffix=".tsv", dir=DFTK_TMPDIR)
|
|
415
|
-
try:
|
|
416
|
-
with os.fdopen(fd, "wb") as fh:
|
|
417
|
-
fh.write(sys.stdin.buffer.read())
|
|
418
|
-
df = _csv_with_duckdb(path)
|
|
419
|
-
finally:
|
|
420
|
-
os.unlink(path)
|
|
433
|
+
# stdin with DuckDB: it reads files, not buffers (that needs fsspec)
|
|
434
|
+
df = _spooled_csv_with_duckdb(sys.stdin.buffer)
|
|
421
435
|
|
|
422
436
|
else:
|
|
423
437
|
# stdin — read TSV directly
|
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
dftk.commands.scat_cmd — dftk scat subcommand.
|
|
3
|
-
|
|
4
|
-
Scatter plot with optional OLS / robust regression line overlay.
|
|
5
|
-
|
|
6
|
-
Port of dfscat.py with improvements:
|
|
7
|
-
- --groupcol colour-codes points by a column
|
|
8
|
-
- --subgraphcol splits into a subplot grid
|
|
9
|
-
- --size / --fontsize publication-quality figure presets
|
|
10
|
-
- --fit overlays an OLS (or robust) regression line via fit_cmd.regress_it
|
|
11
|
-
- --sizecol point size from a data column (was --area)
|
|
12
|
-
- --colorcol continuous colour mapping from a data column (was --color)
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
import argparse
|
|
16
|
-
import sys
|
|
17
|
-
|
|
18
|
-
import matplotlib.lines as mlines
|
|
19
|
-
import numpy as np
|
|
20
|
-
|
|
21
|
-
from dftk.commands.base import BaseCommand
|
|
22
|
-
from dftk.common.io import check_cols, io
|
|
23
|
-
from dftk.common.plot import (
|
|
24
|
-
add_figure_arguments,
|
|
25
|
-
add_group_arguments,
|
|
26
|
-
add_legend_arguments,
|
|
27
|
-
add_xy_arguments,
|
|
28
|
-
apply_labels,
|
|
29
|
-
apply_limits,
|
|
30
|
-
apply_style,
|
|
31
|
-
apply_ticks,
|
|
32
|
-
make_figure,
|
|
33
|
-
make_grouplabel,
|
|
34
|
-
save_or_show,
|
|
35
|
-
subgraph_groups,
|
|
36
|
-
subgraph_layout,
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
# ---------------------------------------------------------------------------
|
|
40
|
-
# Fit overlay
|
|
41
|
-
# ---------------------------------------------------------------------------
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def _fit_overlay(ax, x_series, y_series, args):
|
|
45
|
-
"""Fit a regression line and overlay it on ax. Prints summary to stdout."""
|
|
46
|
-
import pandas as pd
|
|
47
|
-
|
|
48
|
-
from dftk.commands.fit_cmd import regress_it
|
|
49
|
-
|
|
50
|
-
df_fit = pd.DataFrame({"_x": x_series.values, "_y": y_series.values}).dropna()
|
|
51
|
-
fit_args = argparse.Namespace(
|
|
52
|
-
formula="_y ~ _x" if not args.nointercept else "_y ~ _x - 1",
|
|
53
|
-
weights=None,
|
|
54
|
-
robust=getattr(args, "robust", False),
|
|
55
|
-
)
|
|
56
|
-
res = regress_it(df_fit, fit_args)
|
|
57
|
-
print(res.summary(), file=sys.stderr)
|
|
58
|
-
|
|
59
|
-
xmin, xmax = ax.get_xlim()
|
|
60
|
-
X_plot = np.linspace(xmin, xmax, 200)
|
|
61
|
-
|
|
62
|
-
if args.nointercept:
|
|
63
|
-
slope = res.params["_x"]
|
|
64
|
-
Y_plot = slope * X_plot
|
|
65
|
-
label_text = f"{slope:+.3g} × {args.xcol}"
|
|
66
|
-
else:
|
|
67
|
-
intercept = res.params["Intercept"]
|
|
68
|
-
slope = res.params["_x"]
|
|
69
|
-
Y_plot = intercept + slope * X_plot
|
|
70
|
-
label_text = f"{intercept:.3g}{slope:+.3g} × {args.xcol}"
|
|
71
|
-
|
|
72
|
-
if getattr(args, "pvalue", False):
|
|
73
|
-
pval = res.pvalues.get("_x", res.pvalues.iloc[-1])
|
|
74
|
-
label_text += f" -log10(p)={-np.log10(pval):.2g}"
|
|
75
|
-
|
|
76
|
-
ax.plot(X_plot, Y_plot, "r--", linewidth=1)
|
|
77
|
-
ax.legend(
|
|
78
|
-
handles=[mlines.Line2D([], [], color="red", linestyle="--", label=label_text)],
|
|
79
|
-
loc=0,
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
# ---------------------------------------------------------------------------
|
|
84
|
-
# Single-axes scatter (reused per subplot)
|
|
85
|
-
# ---------------------------------------------------------------------------
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def _scatter_ax(ax, df, args, title_suffix: str = ""):
|
|
89
|
-
"""Draw scatter on a single Axes, with optional --groupcol colouring."""
|
|
90
|
-
import matplotlib.pyplot as plt
|
|
91
|
-
|
|
92
|
-
xcol = args.xcol
|
|
93
|
-
ycol = args.ycol
|
|
94
|
-
legendloc = tuple(args.legendloc) if args.legendloc else "best"
|
|
95
|
-
|
|
96
|
-
scatter_kw = dict(marker=getattr(args, "marker", "o"), alpha=0.7)
|
|
97
|
-
|
|
98
|
-
if args.groupcol is not None:
|
|
99
|
-
for groupname, gdf in subgraph_groups(
|
|
100
|
-
df, args.groupcol, getattr(args, "groupcolorder", None)
|
|
101
|
-
):
|
|
102
|
-
label = make_grouplabel(
|
|
103
|
-
groupname, args.groupcol, getattr(args, "groupcolformat", None)
|
|
104
|
-
)
|
|
105
|
-
kw = dict(scatter_kw)
|
|
106
|
-
if args.sizecol:
|
|
107
|
-
kw["s"] = gdf[args.sizecol]
|
|
108
|
-
ax.scatter(gdf[xcol], gdf[ycol], label=label, **kw)
|
|
109
|
-
ax.legend(loc=legendloc, title=args.legendtitle)
|
|
110
|
-
else:
|
|
111
|
-
kw = dict(scatter_kw)
|
|
112
|
-
if args.sizecol:
|
|
113
|
-
kw["s"] = df[args.sizecol]
|
|
114
|
-
if args.colorcol:
|
|
115
|
-
sc = ax.scatter(
|
|
116
|
-
df[xcol], df[ycol], c=df[args.colorcol], cmap="viridis", **kw
|
|
117
|
-
)
|
|
118
|
-
plt.colorbar(sc, ax=ax, label=args.colorcol)
|
|
119
|
-
else:
|
|
120
|
-
label = args.legend or ""
|
|
121
|
-
ax.scatter(df[xcol], df[ycol], label=label if label else None, **kw)
|
|
122
|
-
if label:
|
|
123
|
-
ax.legend(loc=legendloc, title=args.legendtitle)
|
|
124
|
-
|
|
125
|
-
if getattr(args, "fit", False) and args.groupcol is None:
|
|
126
|
-
_fit_overlay(ax, df[xcol], df[ycol], args)
|
|
127
|
-
|
|
128
|
-
apply_limits(ax, args)
|
|
129
|
-
apply_ticks(ax, args)
|
|
130
|
-
apply_labels(ax, args, default_xlabel=xcol or "", default_ylabel=ycol or "")
|
|
131
|
-
if title_suffix:
|
|
132
|
-
existing = ax.get_title()
|
|
133
|
-
ax.set_title(f"{existing} [{title_suffix}]" if existing else title_suffix)
|
|
134
|
-
|
|
135
|
-
ax.grid(True, linewidth=0.4, alpha=0.5)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
# ---------------------------------------------------------------------------
|
|
139
|
-
# Command
|
|
140
|
-
# ---------------------------------------------------------------------------
|
|
141
|
-
|
|
142
|
-
_EPILOG = """\
|
|
143
|
-
EXAMPLES
|
|
144
|
-
--------
|
|
145
|
-
Basic scatter:
|
|
146
|
-
|
|
147
|
-
dftk scat data.tsv -x weight -y height
|
|
148
|
-
dftk dataset tips -o | dftk scat ... -x total_bill -y tip
|
|
149
|
-
|
|
150
|
-
Colour by group:
|
|
151
|
-
|
|
152
|
-
dftk dataset tips -o | dftk scat ... -x total_bill -y tip -g day
|
|
153
|
-
|
|
154
|
-
Subplot grid by time-of-day, colour by smoker:
|
|
155
|
-
|
|
156
|
-
dftk dataset tips -o | dftk scat ... -x total_bill -y tip \\
|
|
157
|
-
--subgraphcol time -g smoker
|
|
158
|
-
|
|
159
|
-
Fit OLS line:
|
|
160
|
-
|
|
161
|
-
dftk dataset tips -o | dftk scat ... -x total_bill -y tip --fit
|
|
162
|
-
|
|
163
|
-
Publication figure (Nature single column, PDF):
|
|
164
|
-
|
|
165
|
-
dftk dataset tips -o | dftk scat ... -x total_bill -y tip \\
|
|
166
|
-
--size single --fontsize publication -f fig.pdf
|
|
167
|
-
|
|
168
|
-
Bubble chart (point size from column):
|
|
169
|
-
|
|
170
|
-
dftk dataset tips -o | dftk scat ... -x total_bill -y tip --sizecol size
|
|
171
|
-
|
|
172
|
-
Continuous colour mapping:
|
|
173
|
-
|
|
174
|
-
dftk dataset tips -o | dftk scat ... -x total_bill -y tip --colorcol tip
|
|
175
|
-
"""
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
class ScatCommand(BaseCommand):
|
|
179
|
-
name = "scat"
|
|
180
|
-
help = "Scatter plot with optional regression line overlay."
|
|
181
|
-
|
|
182
|
-
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
183
|
-
parser.formatter_class = argparse.RawDescriptionHelpFormatter
|
|
184
|
-
parser.epilog = _EPILOG
|
|
185
|
-
|
|
186
|
-
self.add_read_arguments(parser)
|
|
187
|
-
add_xy_arguments(parser)
|
|
188
|
-
add_figure_arguments(parser)
|
|
189
|
-
add_group_arguments(parser)
|
|
190
|
-
add_legend_arguments(parser)
|
|
191
|
-
|
|
192
|
-
s = parser.add_argument_group("scatter options")
|
|
193
|
-
s.add_argument(
|
|
194
|
-
"-m",
|
|
195
|
-
"--marker",
|
|
196
|
-
default="o",
|
|
197
|
-
metavar="MARKER",
|
|
198
|
-
help="Matplotlib marker style (default: o).",
|
|
199
|
-
)
|
|
200
|
-
s.add_argument(
|
|
201
|
-
"--sizecol",
|
|
202
|
-
default=None,
|
|
203
|
-
metavar="COL",
|
|
204
|
-
help="Column to use for point sizes (bubble chart).",
|
|
205
|
-
)
|
|
206
|
-
s.add_argument(
|
|
207
|
-
"--colorcol",
|
|
208
|
-
default=None,
|
|
209
|
-
metavar="COL",
|
|
210
|
-
help="Column for continuous colour mapping.",
|
|
211
|
-
)
|
|
212
|
-
|
|
213
|
-
f = parser.add_argument_group("fit options")
|
|
214
|
-
f.add_argument(
|
|
215
|
-
"--fit",
|
|
216
|
-
action="store_true",
|
|
217
|
-
help="Overlay an OLS regression line. Prints summary to stderr.",
|
|
218
|
-
)
|
|
219
|
-
f.add_argument(
|
|
220
|
-
"-r",
|
|
221
|
-
"--robust",
|
|
222
|
-
action="store_true",
|
|
223
|
-
help="Use robust regression (RLM) for --fit.",
|
|
224
|
-
)
|
|
225
|
-
f.add_argument(
|
|
226
|
-
"--nointercept", action="store_true", help="Suppress intercept in --fit."
|
|
227
|
-
)
|
|
228
|
-
f.add_argument(
|
|
229
|
-
"--pvalue",
|
|
230
|
-
action="store_true",
|
|
231
|
-
help="Add −log₁₀(p) to the fit legend label.",
|
|
232
|
-
)
|
|
233
|
-
|
|
234
|
-
def execute(self, args: argparse.Namespace) -> None:
|
|
235
|
-
if not args.xcol or not args.ycol:
|
|
236
|
-
raise ValueError("--xcol (-x) and --ycol (-y) are required.")
|
|
237
|
-
|
|
238
|
-
df = io.read(args)
|
|
239
|
-
check_cols(df, [args.xcol, args.ycol], "-x/-y")
|
|
240
|
-
check_cols(df, args.groupcol, "-g/--groupcol")
|
|
241
|
-
check_cols(df, args.subgraphcol, "--subgraphcol")
|
|
242
|
-
if args.sizecol:
|
|
243
|
-
check_cols(df, [args.sizecol], "--sizecol")
|
|
244
|
-
if args.colorcol:
|
|
245
|
-
check_cols(df, [args.colorcol], "--colorcol")
|
|
246
|
-
|
|
247
|
-
# Use non-interactive backend when saving to file
|
|
248
|
-
if args.file:
|
|
249
|
-
import matplotlib
|
|
250
|
-
|
|
251
|
-
matplotlib.use("Agg")
|
|
252
|
-
|
|
253
|
-
import matplotlib.pyplot as plt
|
|
254
|
-
|
|
255
|
-
if args.usetex:
|
|
256
|
-
plt.rc("text", usetex=True)
|
|
257
|
-
|
|
258
|
-
apply_style(args)
|
|
259
|
-
|
|
260
|
-
if args.subgraphcol is not None:
|
|
261
|
-
groups = subgraph_groups(
|
|
262
|
-
df, args.subgraphcol, getattr(args, "subgraphorder", None)
|
|
263
|
-
)
|
|
264
|
-
nrows, ncols = subgraph_layout(len(groups), args.ncols)
|
|
265
|
-
fig, axes = make_figure(args, nrows=nrows, ncols=ncols)
|
|
266
|
-
for idx, (groupname, gdf) in enumerate(groups):
|
|
267
|
-
ax = axes[idx // ncols][idx % ncols]
|
|
268
|
-
suffix = make_grouplabel(
|
|
269
|
-
groupname, args.subgraphcol, getattr(args, "subgraphformat", None)
|
|
270
|
-
)
|
|
271
|
-
_scatter_ax(ax, gdf, args, title_suffix=suffix)
|
|
272
|
-
# Hide unused axes
|
|
273
|
-
for idx in range(len(groups), nrows * ncols):
|
|
274
|
-
axes[idx // ncols][idx % ncols].set_visible(False)
|
|
275
|
-
fig.tight_layout()
|
|
276
|
-
else:
|
|
277
|
-
fig, axes = make_figure(args)
|
|
278
|
-
_scatter_ax(axes[0][0], df, args)
|
|
279
|
-
fig.tight_layout()
|
|
280
|
-
|
|
281
|
-
save_or_show(fig, args)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|