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.
Files changed (49) hide show
  1. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/PKG-INFO +33 -6
  2. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/README.md +32 -5
  3. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/pyproject.toml +1 -1
  4. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/pyproject.toml.orig +1 -1
  5. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/merge_cmd.py +48 -0
  6. dataframe_toolkit-0.9.1/src/dftk/commands/scat_cmd.py +454 -0
  7. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/common/io.py +27 -13
  8. dataframe_toolkit-0.8.0/src/dftk/commands/scat_cmd.py +0 -281
  9. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/__init__.py +0 -0
  10. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/cli.py +0 -0
  11. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/__init__.py +0 -0
  12. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/annotate_cmd.py +0 -0
  13. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/base.py +0 -0
  14. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/binx_cmd.py +0 -0
  15. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/chi_cmd.py +0 -0
  16. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/clean_cmd.py +0 -0
  17. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/cleannames_cmd.py +0 -0
  18. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/concat_cmd.py +0 -0
  19. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/corr_cmd.py +0 -0
  20. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/dataset_cmd.py +0 -0
  21. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/describe_cmd.py +0 -0
  22. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/eval_cmd.py +0 -0
  23. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/fit_cmd.py +0 -0
  24. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/func_cmd.py +0 -0
  25. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/help.py +0 -0
  26. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/hist_cmd.py +0 -0
  27. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/info_cmd.py +0 -0
  28. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/interp_cmd.py +0 -0
  29. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/line_cmd.py +0 -0
  30. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/melt_cmd.py +0 -0
  31. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/pivot_cmd.py +0 -0
  32. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/print_cmd.py +0 -0
  33. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/query_cmd.py +0 -0
  34. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/randvar_cmd.py +0 -0
  35. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/sample_cmd.py +0 -0
  36. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/scale_cmd.py +0 -0
  37. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/segid_cmd.py +0 -0
  38. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/split_cmd.py +0 -0
  39. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/stat_cmd.py +0 -0
  40. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/test_cmd.py +0 -0
  41. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/transpose_cmd.py +0 -0
  42. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/commands/wstat_cmd.py +0 -0
  43. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/common/__init__.py +0 -0
  44. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/common/copula.py +0 -0
  45. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/common/plot.py +0 -0
  46. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/common/seed.py +0 -0
  47. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/common/seq.py +0 -0
  48. {dataframe_toolkit-0.8.0 → dataframe_toolkit-0.9.1}/src/dftk/common/stats.py +0 -0
  49. {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.8.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; optional OLS/robust fit overlay, 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`) |
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; optional OLS/robust fit overlay, 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`) |
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:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataframe-toolkit"
3
- version = "0.8.0"
3
+ version = "0.9.1"
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.8.0"
3
+ version = "0.9.1"
4
4
  description = "A command-line toolkit for DataFrame analysis and manipulation"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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(path, sep=sep, header=not noheader).df()
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
- df = _csv_with_duckdb(filename)
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
- # so spool stdin to a temp file and read it like a named file
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)