dataframe-toolkit 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
1
+ """
2
+ dftk.commands.randvar_cmd — dftk randvar subcommand.
3
+
4
+ Port of dfrvs.py: append a column of random variates drawn from any
5
+ scipy.stats distribution, or generate a stand-alone sample without an
6
+ input file.
7
+
8
+ Usage
9
+ -----
10
+ # Append a column to an existing file
11
+ dftk randvar data.tsv -d x --dist norm --parameters loc:0,scale:1
12
+
13
+ # Generate a stand-alone sample (no input file)
14
+ dftk randvar -n 1000 -d x --dist norm --parameters loc:0,scale:1
15
+ dftk randvar -n 500 -d x --dist beta --parameters a:2,b:5 --randomseed 42
16
+
17
+ # List all available distributions and their parameters
18
+ dftk randvar --list
19
+ """
20
+
21
+ import argparse
22
+ import logging
23
+ import sys
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+ import scipy.stats as ss
28
+
29
+ from dftk.commands.base import BaseCommand
30
+ from dftk.common.io import io
31
+ from dftk.common.seed import normalize_seed
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ _SCIPY_DOCS = "https://docs.scipy.org/doc/scipy/reference/stats.html"
36
+
37
+ _EPILOG = f"""\
38
+ PARAMETERS
39
+ ----------
40
+ Distribution parameters are passed as key:val pairs separated by commas:
41
+
42
+ --parameters loc:0,scale:1
43
+ --parameters a:2,b:5,loc:0,scale:1
44
+
45
+ The parameter names are distribution-specific. Use --list to see the
46
+ accepted parameters for every distribution, or consult the scipy.stats
47
+ documentation:
48
+
49
+ {_SCIPY_DOCS}
50
+
51
+ RANDOM SEED
52
+ -----------
53
+ --randomseed accepts an integer or any string. A non-numeric string is
54
+ hashed (MD5) to a reproducible uint32 seed, which is printed to stderr.
55
+
56
+ EXAMPLES
57
+ --------
58
+ dftk randvar data.tsv -d noise --dist norm
59
+ dftk randvar data.tsv -d noise --dist norm --parameters loc:5,scale:2
60
+ dftk randvar -n 1000 -d u --dist uniform --randomseed 99
61
+ dftk randvar -n 500 -d k --dist poisson --parameters mu:3
62
+ dftk randvar --list
63
+ dftk randvar --list | dftk query - --query "type == 'continuous'"
64
+ """
65
+
66
+
67
+ def _parse_parameters(paramstring: str | None) -> dict:
68
+ """Parse 'key:val,key:val' into {key: float} dict."""
69
+ if not paramstring:
70
+ return {}
71
+ pairs = [x.split(":") for x in paramstring.split(",")]
72
+ return {k: float(v) for k, v in pairs}
73
+
74
+
75
+ def _list_distributions() -> pd.DataFrame:
76
+ """Return a DataFrame of all scipy.stats distributions and their parameters."""
77
+ rows = []
78
+ for name in sorted(ss._continuous_distns._distn_names):
79
+ dist = getattr(ss, name)
80
+ shapes = dist.shapes or ""
81
+ params = (shapes + ", " if shapes else "") + "loc, scale"
82
+ rows.append({"name": name, "type": "continuous", "parameters": params})
83
+ for name in sorted(ss._discrete_distns._distn_names):
84
+ dist = getattr(ss, name)
85
+ shapes = dist.shapes or ""
86
+ params = (shapes + ", " if shapes else "") + "loc"
87
+ rows.append({"name": name, "type": "discrete", "parameters": params})
88
+ return pd.DataFrame(rows)
89
+
90
+
91
+ class RandvarCommand(BaseCommand):
92
+ @property
93
+ def name(self) -> str:
94
+ return "randvar"
95
+
96
+ @property
97
+ def help(self) -> str:
98
+ return "Append a column of random variates from a scipy.stats distribution."
99
+
100
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
101
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
102
+ parser.epilog = _EPILOG
103
+
104
+ self.add_io_arguments(parser)
105
+
106
+ g = parser.add_argument_group("distribution")
107
+ g.add_argument(
108
+ "-d",
109
+ "--destcol",
110
+ default=None,
111
+ metavar="COL",
112
+ help="Name of the new column to append.",
113
+ )
114
+ g.add_argument(
115
+ "--dist",
116
+ default=None,
117
+ metavar="DIST",
118
+ help="scipy.stats distribution name (e.g. norm, uniform, beta, poisson).",
119
+ )
120
+ g.add_argument(
121
+ "--parameters",
122
+ default=None,
123
+ metavar="KEY:VAL,...",
124
+ help="Distribution parameters as key:val pairs, e.g. loc:0,scale:1.",
125
+ )
126
+ g.add_argument(
127
+ "--randomseed",
128
+ default=None,
129
+ metavar="SEED",
130
+ help="Random seed (integer or string; strings are hashed to uint32).",
131
+ )
132
+ g.add_argument(
133
+ "-n",
134
+ "--nsamples",
135
+ type=int,
136
+ default=None,
137
+ metavar="N",
138
+ help=(
139
+ "Number of samples to generate. Required when no DATAFILE is "
140
+ "provided and stdin is not piped; ignored otherwise."
141
+ ),
142
+ )
143
+ g.add_argument(
144
+ "--list",
145
+ action="store_true",
146
+ help="List all available scipy.stats distributions and their parameters.",
147
+ )
148
+
149
+ def execute(self, args: argparse.Namespace) -> None:
150
+ # --list short-circuits everything
151
+ if args.list:
152
+ df = _list_distributions()
153
+ io.printdf(df, args)
154
+ return
155
+
156
+ # Validate required args (not enforced at parse time to allow --list)
157
+ if not args.destcol:
158
+ logger.error("-d/--destcol is required.")
159
+ sys.exit(1)
160
+ if not args.dist:
161
+ logger.error("--dist is required.")
162
+ sys.exit(1)
163
+
164
+ # Resolve distribution
165
+ try:
166
+ dist_cls = getattr(ss, args.dist)
167
+ except AttributeError:
168
+ logger.error(
169
+ "Distribution %r not found in scipy.stats. Use --list.",
170
+ args.dist,
171
+ )
172
+ sys.exit(1)
173
+
174
+ kwargs = _parse_parameters(args.parameters)
175
+ try:
176
+ rv = dist_cls(**kwargs)
177
+ except TypeError as exc:
178
+ logger.error("Invalid parameters for %r: %s", args.dist, exc)
179
+ sys.exit(1)
180
+
181
+ seed = normalize_seed(args.randomseed)
182
+ rng = np.random.default_rng(seed)
183
+
184
+ # -n given → standalone mode (no input file needed)
185
+ # -n absent → read DATAFILE or stdin, use len(df) as sample count
186
+ if args.nsamples is not None:
187
+ df = pd.DataFrame(
188
+ {args.destcol: rv.rvs(size=args.nsamples, random_state=rng)}
189
+ )
190
+ else:
191
+ if args.DATAFILE is None and sys.stdin.isatty():
192
+ logger.error(
193
+ "No input: provide a DATAFILE, pipe to stdin, or use -n/--nsamples."
194
+ )
195
+ sys.exit(1)
196
+ df = io.read(args)
197
+ df[args.destcol] = rv.rvs(size=len(df), random_state=rng)
198
+
199
+ io.printdf(df, args)
@@ -0,0 +1,126 @@
1
+ """
2
+ dftk.commands.sample_cmd — dftk sample subcommand.
3
+
4
+ Draw a random sample of rows from a DataFrame, optionally within groups.
5
+
6
+ Sampling size (one required)
7
+ -----------------------------
8
+ -n N draw exactly N rows
9
+ -f FRAC draw a fraction of rows (0.0 – 1.0); use 1.0 to shuffle
10
+
11
+ Options
12
+ -------
13
+ -g COL … sample independently within each group
14
+ --randomseed random seed — integer or string (strings are MD5-hashed)
15
+ --replace sample with replacement
16
+
17
+ Examples
18
+ --------
19
+ dftk sample data.tsv -n 100
20
+ dftk sample data.tsv -f 0.8
21
+ dftk sample data.tsv -n 50 -g treatment --randomseed 42
22
+ dftk sample data.tsv -f 1.0 --randomseed shuffle
23
+ """
24
+
25
+ import argparse
26
+ import sys
27
+
28
+ import numpy as np
29
+ import pandas as pd
30
+
31
+ from dftk.commands.base import BaseCommand
32
+ from dftk.common.io import io
33
+ from dftk.common.seed import normalize_seed
34
+
35
+
36
+ class SampleCommand(BaseCommand):
37
+ @property
38
+ def name(self) -> str:
39
+ return "sample"
40
+
41
+ @property
42
+ def help(self) -> str:
43
+ return "Draw a random sample of rows, optionally within groups"
44
+
45
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
46
+ import argparse as ap
47
+
48
+ parser.formatter_class = ap.RawDescriptionHelpFormatter
49
+ parser.epilog = """\
50
+ EXAMPLES
51
+ --------
52
+ dftk sample data.tsv -n 100
53
+ dftk sample data.tsv -f 0.8
54
+ dftk sample data.tsv -n 50 -g treatment --randomseed 42
55
+ dftk sample data.tsv -f 1.0 --randomseed shuffle # reproducible shuffle
56
+ dftk sample data.tsv -n 10 -g group --replace
57
+ """
58
+ io.parser_read(parser)
59
+
60
+ g = parser.add_argument_group("sampling")
61
+ size = g.add_mutually_exclusive_group(required=True)
62
+ size.add_argument(
63
+ "-n",
64
+ dest="samplesize",
65
+ type=int,
66
+ default=None,
67
+ metavar="N",
68
+ help="Number of rows to sample.",
69
+ )
70
+ size.add_argument(
71
+ "-f",
72
+ dest="samplefrac",
73
+ type=float,
74
+ default=None,
75
+ metavar="FRAC",
76
+ help="Fraction of rows to sample (0.0 – 1.0). Use 1.0 to shuffle.",
77
+ )
78
+ g.add_argument(
79
+ "-g",
80
+ "--groupcol",
81
+ nargs="+",
82
+ default=None,
83
+ metavar="COL",
84
+ help="Sample independently within each group defined by these columns.",
85
+ )
86
+ g.add_argument(
87
+ "--randomseed",
88
+ default=None,
89
+ metavar="SEED",
90
+ help=(
91
+ "Random seed for reproducibility. "
92
+ "Accepts an integer or any string (hashed via MD5 to a uint32)."
93
+ ),
94
+ )
95
+ g.add_argument(
96
+ "--replace",
97
+ action="store_true",
98
+ help="Sample with replacement.",
99
+ )
100
+
101
+ io.parser_output(parser)
102
+
103
+ def execute(self, args: argparse.Namespace) -> None:
104
+ df = io.read(args)
105
+
106
+ seed = normalize_seed(args.randomseed)
107
+ if seed is not None:
108
+ print(f"randomseed = {seed}", file=sys.stderr)
109
+
110
+ rng = np.random.default_rng(seed)
111
+
112
+ sample_kwargs: dict = {"replace": args.replace, "random_state": rng}
113
+ if args.samplesize is not None:
114
+ sample_kwargs["n"] = args.samplesize
115
+ else:
116
+ sample_kwargs["frac"] = args.samplefrac
117
+
118
+ if args.groupcol is not None:
119
+ pieces = [
120
+ grp.sample(**sample_kwargs) for _, grp in df.groupby(args.groupcol)
121
+ ]
122
+ result = pd.concat(pieces).reset_index(drop=True)
123
+ else:
124
+ result = df.sample(**sample_kwargs).reset_index(drop=True)
125
+
126
+ io.printdf(result, args)
@@ -0,0 +1,299 @@
1
+ """
2
+ dftk.commands.scale_cmd — dftk scale subcommand.
3
+
4
+ Port of dfscale.py: column-wise normalization / rank transformation /
5
+ regression detrending.
6
+
7
+ Three modes (mutually exclusive flags)
8
+ ---------------------------------------
9
+ Default (scale) Linear shift + scale. New options --shift / --scale
10
+ replace the old --noshift / --minshift / --inishift /
11
+ --noscale / --sumscale / --maxscale / --meanscale flags.
12
+ --rank Rank normalization.
13
+ --resid Regression detrending (residuals); requires -f/--formula.
14
+
15
+ Output
16
+ ------
17
+ Each processed column gets a companion column named <col>_scaled appended
18
+ to the output dataframe. All other columns are preserved.
19
+
20
+ Grouping (-g)
21
+ -------------
22
+ Any mode can be applied per group. Results are concatenated into a single
23
+ dataframe and written once (cleaner than the old loop-with-removeheader
24
+ approach).
25
+ """
26
+
27
+ import argparse
28
+ import logging
29
+ import sys
30
+
31
+ import numpy as np
32
+ import pandas as pd
33
+ from scipy.stats import norm
34
+
35
+ from dftk.commands.base import BaseCommand
36
+ from dftk.common.io import check_cols, io
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Mode implementations
43
+ # ---------------------------------------------------------------------------
44
+
45
+
46
+ def scalecols(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
47
+ """Linear shift + scale normalization."""
48
+ for col in args.cols:
49
+ newcol = f"{col}_scaled"
50
+ x = df[col].astype(float)
51
+
52
+ shift_val = {
53
+ "mean": x.mean(),
54
+ "min": x.min(),
55
+ "first": float(x.iloc[0]),
56
+ "none": 0.0,
57
+ }[args.shift]
58
+
59
+ shifted = x - shift_val
60
+
61
+ if args.scale_by == "none":
62
+ df[newcol] = shifted
63
+ continue
64
+
65
+ scale_val = {
66
+ "std": shifted.std(),
67
+ "sum": shifted.sum(),
68
+ "max": shifted.abs().max(),
69
+ "mean": shifted.mean(),
70
+ }[args.scale_by]
71
+
72
+ if args.verbose:
73
+ print(
74
+ f"{newcol}: shift={shift_val:.6g} scale={scale_val:.6g}",
75
+ file=sys.stderr,
76
+ )
77
+
78
+ df[newcol] = shifted / scale_val if scale_val != 0 else shifted
79
+
80
+ return df
81
+
82
+
83
+ def rankcols(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
84
+ """Rank normalization to uniform [0,1] or normal scores."""
85
+ n = len(df)
86
+ for col in args.cols:
87
+ newcol = f"{col}_scaled"
88
+ x = df[col].astype(float)
89
+ rnk = x.rank(method="average").values
90
+
91
+ if args.rankdist == "uniform":
92
+ df[newcol] = (rnk - 0.5) / n
93
+ else: # normal (Blom transform)
94
+ p = (rnk - 3 / 8.0) / (n + 1 / 4.0)
95
+ z = norm.ppf(p)
96
+ z = z - z.mean()
97
+ s = z.std(ddof=0)
98
+ df[newcol] = z / s if s > 0 else np.zeros_like(z)
99
+
100
+ return df
101
+
102
+
103
+ def residcols(df: pd.DataFrame, args: argparse.Namespace) -> pd.DataFrame:
104
+ """Regression detrending: replace column with (intercept +) residuals."""
105
+ # Import here to avoid circular imports at module level
106
+ from dftk.commands.fit_cmd import regress_it
107
+
108
+ # regress_it needs these attrs; set safe defaults if absent
109
+ if not hasattr(args, "robust"):
110
+ args.robust = False
111
+ if not hasattr(args, "weights"):
112
+ args.weights = None
113
+
114
+ res = regress_it(df, args)
115
+ col = res.model.endog_names
116
+ newcol = f"{col}_scaled"
117
+
118
+ intercept = 0.0
119
+ if not args.nointercept and "Intercept" in res.params:
120
+ intercept = res.params["Intercept"]
121
+
122
+ if args.verbose:
123
+ print(res.summary(), file=sys.stderr)
124
+
125
+ df[newcol] = intercept + res.resid
126
+ return df
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Command
131
+ # ---------------------------------------------------------------------------
132
+
133
+ _EPILOG = """\
134
+ MODES
135
+ -----
136
+ Default (scale):
137
+
138
+ dftk scale data.tsv -c y z-score (mean shift, std scale)
139
+ dftk scale data.tsv -c y --shift min shift so min=0, scale by std
140
+ dftk scale data.tsv -c y --shift min --scale max shift so min=0, scale so max=1
141
+ dftk scale data.tsv -c y --shift none --scale sum no shift, scale so sum=1
142
+ dftk scale data.tsv -c y --scale none shift to zero mean, no scaling
143
+
144
+ Rank normalization (--rank):
145
+
146
+ dftk scale data.tsv -c y --rank normal scores (default)
147
+ dftk scale data.tsv -c y --rank --rankdist uniform uniform [0, 1]
148
+
149
+ Regression detrending (--resid):
150
+
151
+ dftk scale data.tsv -c y --resid -f "y ~ x"
152
+ dftk scale data.tsv -c y --resid -f "y ~ x" --nointercept
153
+
154
+ Grouping:
155
+
156
+ dftk scale data.tsv -c y -g group z-score within each group
157
+ dftk scale data.tsv -c y --rank -g group rank within each group
158
+
159
+ SHIFT OPTIONS (--shift)
160
+ -----------------------
161
+ mean Subtract the column mean (default). Results in zero mean.
162
+ min Subtract the column minimum. Lowest value becomes 0.
163
+ first Subtract the first observation. First value becomes 0.
164
+ none No shift applied.
165
+
166
+ SCALE OPTIONS (--scale)
167
+ -----------------------
168
+ std Divide by standard deviation (default). Results in unit std.
169
+ sum Divide by sum. Values sum to 1.
170
+ max Divide by absolute maximum. Largest absolute value becomes 1.
171
+ mean Divide by mean. Mean becomes 1.
172
+ none No scaling applied.
173
+ """
174
+
175
+
176
+ class ScaleCommand(BaseCommand):
177
+ name = "scale"
178
+ help = "Column-wise normalization, rank transformation, or regression detrending."
179
+
180
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
181
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
182
+ parser.epilog = _EPILOG
183
+
184
+ self.add_io_arguments(parser)
185
+
186
+ g = parser.add_argument_group("column selection")
187
+ g.add_argument(
188
+ "-c",
189
+ "--cols",
190
+ nargs="+",
191
+ metavar="COL",
192
+ default=None,
193
+ help="Column(s) to process. Required for scale and rank modes.",
194
+ )
195
+ g.add_argument(
196
+ "-g",
197
+ "--groupcol",
198
+ nargs="+",
199
+ default=None,
200
+ metavar="COL",
201
+ help="Apply transformation independently within each group.",
202
+ )
203
+
204
+ mode = parser.add_argument_group("mode (mutually exclusive)")
205
+ mx = mode.add_mutually_exclusive_group()
206
+ mx.add_argument(
207
+ "--rank",
208
+ action="store_true",
209
+ help="Rank normalization (normal scores by default; see --rankdist).",
210
+ )
211
+ mx.add_argument(
212
+ "--resid",
213
+ action="store_true",
214
+ help="Regression detrending: replace column with (intercept +) residuals. "
215
+ "Requires -f/--formula.",
216
+ )
217
+
218
+ sp = parser.add_argument_group("scale mode options")
219
+ sp.add_argument(
220
+ "--shift",
221
+ choices=["mean", "min", "first", "none"],
222
+ default="mean",
223
+ help="What to subtract before scaling (default: mean).",
224
+ )
225
+ sp.add_argument(
226
+ "--scale",
227
+ dest="scale_by",
228
+ choices=["std", "sum", "max", "mean", "none"],
229
+ default="std",
230
+ help="What to divide by after shifting (default: std).",
231
+ )
232
+
233
+ rp = parser.add_argument_group("rank mode options")
234
+ rp.add_argument(
235
+ "--rankdist",
236
+ choices=["normal", "uniform"],
237
+ default="normal",
238
+ help="Distribution for rank normalization (default: normal = Blom scores).",
239
+ )
240
+
241
+ dp = parser.add_argument_group("resid mode options")
242
+ dp.add_argument(
243
+ "-f",
244
+ "--formula",
245
+ default=None,
246
+ metavar="FORMULA",
247
+ help="R-style formula for regression detrending, e.g. 'y ~ x'.",
248
+ )
249
+ dp.add_argument(
250
+ "--nointercept",
251
+ action="store_true",
252
+ help="Return raw residuals without adding back the intercept.",
253
+ )
254
+
255
+ parser.add_argument(
256
+ "-v",
257
+ "--verbose",
258
+ action="store_true",
259
+ help="Print shift/scale values (scale mode) or full summary (resid mode) "
260
+ "to stderr.",
261
+ )
262
+
263
+ def execute(self, args: argparse.Namespace) -> None:
264
+ # Validate mode / required args
265
+ if args.resid:
266
+ if not args.formula:
267
+ raise ValueError("--resid requires -f/--formula.")
268
+ fn = residcols
269
+ elif args.rank:
270
+ if not args.cols:
271
+ raise ValueError("--rank requires -c/--cols.")
272
+ fn = rankcols
273
+ else:
274
+ if not args.cols:
275
+ raise ValueError("-c/--cols is required.")
276
+ fn = scalecols
277
+
278
+ df = io.read(args)
279
+ check_cols(df, args.cols, "-c/--cols")
280
+ check_cols(df, args.groupcol, "-g/--groupcol")
281
+
282
+ if args.groupcol is None:
283
+ result = fn(df, args)
284
+ io.printdf(result, args)
285
+ else:
286
+ parts = []
287
+ for groupname, groupdf in df.groupby(args.groupcol):
288
+ if args.verbose:
289
+ groupstr = (
290
+ ", ".join(
291
+ f"{k}={v}"
292
+ for k, v in zip(args.groupcol, groupname, strict=False)
293
+ if isinstance(groupname, tuple)
294
+ )
295
+ or f"{args.groupcol[0]}={groupname}"
296
+ )
297
+ print(f"Group: {groupstr}", file=sys.stderr)
298
+ parts.append(fn(groupdf.copy(), args))
299
+ io.printdf(pd.concat(parts, ignore_index=True), args)