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.
- dataframe_toolkit-0.4.1.dist-info/METADATA +230 -0
- dataframe_toolkit-0.4.1.dist-info/RECORD +42 -0
- dataframe_toolkit-0.4.1.dist-info/WHEEL +4 -0
- dataframe_toolkit-0.4.1.dist-info/entry_points.txt +3 -0
- dftk/__init__.py +4 -0
- dftk/cli.py +72 -0
- dftk/commands/__init__.py +100 -0
- dftk/commands/annotate_cmd.py +165 -0
- dftk/commands/base.py +85 -0
- dftk/commands/binx_cmd.py +130 -0
- dftk/commands/clean_cmd.py +51 -0
- dftk/commands/concat_cmd.py +165 -0
- dftk/commands/corr_cmd.py +281 -0
- dftk/commands/dataset_cmd.py +181 -0
- dftk/commands/describe_cmd.py +360 -0
- dftk/commands/eval_cmd.py +657 -0
- dftk/commands/fit_cmd.py +265 -0
- dftk/commands/func_cmd.py +186 -0
- dftk/commands/help.py +63 -0
- dftk/commands/hist_cmd.py +321 -0
- dftk/commands/interp_cmd.py +271 -0
- dftk/commands/line_cmd.py +320 -0
- dftk/commands/melt_cmd.py +78 -0
- dftk/commands/merge_cmd.py +373 -0
- dftk/commands/pivot_cmd.py +438 -0
- dftk/commands/print_cmd.py +36 -0
- dftk/commands/query_cmd.py +230 -0
- dftk/commands/randvar_cmd.py +199 -0
- dftk/commands/sample_cmd.py +126 -0
- dftk/commands/scale_cmd.py +299 -0
- dftk/commands/scat_cmd.py +278 -0
- dftk/commands/split_cmd.py +130 -0
- dftk/commands/stat_cmd.py +289 -0
- dftk/commands/test_cmd.py +235 -0
- dftk/commands/wstat_cmd.py +163 -0
- dftk/common/__init__.py +8 -0
- dftk/common/io.py +690 -0
- dftk/common/plot.py +566 -0
- dftk/common/seed.py +25 -0
- dftk/common/seq.py +150 -0
- dftk/common/stats.py +214 -0
- dftk/io.py +10 -0
|
@@ -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)
|