stats3 0.1.0__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.
stats3/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ """R's statistical tests on Polars, for Polars, pandas and tidy3 data.
2
+
3
+ from stats3 import *
4
+
5
+ penguins >> t_test("body_mass_g ~ sex", by="species") # Polars or pandas
6
+ t_test(penguins, "body_mass_g ~ sex", by="species") # data first
7
+ penguins >> group_by(species) >> t_test(body_mass_g ~ sex) # tidy3, notebook
8
+
9
+ Each test returns one row per group, with R's defaults and R's results, in
10
+ the kind of frame it was given.
11
+ """
12
+
13
+ from stats3.__version__ import __version__
14
+ from stats3.checks import bartlett_test, ks_test, levene_test, shapiro_test, var_test
15
+ from stats3.core import aov, t_test, tukey_hsd
16
+ from stats3.counts import binom_test, chisq_test, fisher_test, prop_test
17
+ from stats3.effects import (
18
+ cohens_d,
19
+ eta_squared,
20
+ hedges_g,
21
+ p_adjust,
22
+ power_prop_test,
23
+ power_t_test,
24
+ )
25
+ from stats3.models import (
26
+ Gamma,
27
+ anova,
28
+ augment,
29
+ binomial,
30
+ gaussian,
31
+ glance,
32
+ glm,
33
+ inverse_gaussian,
34
+ lm,
35
+ poisson,
36
+ predict,
37
+ quasibinomial,
38
+ quasipoisson,
39
+ tidy,
40
+ )
41
+ from stats3.ranks import cor_test, kruskal_test, wilcox_test
42
+
43
+ # In IPython / Jupyter, turn on unquoted formulas and bare column names in
44
+ # stats3 calls (from the next cell on), as tidy3 does for its verbs.
45
+ try:
46
+ from stats3.jupyter import enable as _enable_notebook
47
+
48
+ _enable_notebook()
49
+ except Exception: # pragma: no cover - never let the shell block an import
50
+ pass
51
+
52
+ __all__ = [
53
+ "anova",
54
+ "aov",
55
+ "augment",
56
+ "bartlett_test",
57
+ "binom_test",
58
+ "binomial",
59
+ "chisq_test",
60
+ "cohens_d",
61
+ "cor_test",
62
+ "eta_squared",
63
+ "fisher_test",
64
+ "Gamma",
65
+ "gaussian",
66
+ "glance",
67
+ "glm",
68
+ "hedges_g",
69
+ "inverse_gaussian",
70
+ "kruskal_test",
71
+ "ks_test",
72
+ "levene_test",
73
+ "lm",
74
+ "p_adjust",
75
+ "poisson",
76
+ "power_prop_test",
77
+ "power_t_test",
78
+ "predict",
79
+ "prop_test",
80
+ "quasibinomial",
81
+ "quasipoisson",
82
+ "shapiro_test",
83
+ "t_test",
84
+ "tidy",
85
+ "tukey_hsd",
86
+ "var_test",
87
+ "wilcox_test",
88
+ ]
stats3/__version__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
stats3/_data.py ADDED
@@ -0,0 +1,269 @@
1
+ """Data in and out: pandas, Polars and tidy3 frames, without importing them.
2
+
3
+ Every test works on a ``Data``: a Polars LazyFrame, the grouping columns, and
4
+ the factor levels that order a grouping factor's levels. ``Data.read`` builds
5
+ one from whatever the caller passed, and ``Data.write`` turns the result back
6
+ into the same kind of frame:
7
+
8
+ ======================== ================== ===========================
9
+ input groups factor levels
10
+ ======================== ================== ===========================
11
+ Polars DataFrame / Lazy ``by=`` ``Enum`` columns
12
+ pandas DataFrame ``by=`` ``category`` columns
13
+ tidy3 TidyFrame its ``group_by`` its recorded levels
14
+ dict of columns ``by=`` none
15
+ ======================== ================== ===========================
16
+
17
+ pandas and tidy3 are recognised by their class and module, so stats3 imports
18
+ neither; a TidyFrame can only exist when tidy3 is already loaded.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import sys
24
+ from collections.abc import Mapping
25
+ from typing import Any, Callable
26
+
27
+ import polars as pl
28
+
29
+ OUTPUTS = ("polars", "pandas", "tidy3")
30
+
31
+
32
+ def _is_pandas_frame(value: Any) -> bool:
33
+ cls = type(value)
34
+ return cls.__name__ == "DataFrame" and cls.__module__.startswith("pandas")
35
+
36
+
37
+ def _is_pandas_series(value: Any) -> bool:
38
+ cls = type(value)
39
+ return cls.__name__ == "Series" and cls.__module__.startswith("pandas")
40
+
41
+
42
+ def _is_tidy(value: Any) -> bool:
43
+ cls = type(value)
44
+ return cls.__name__ == "TidyFrame" and cls.__module__.startswith("tidy3")
45
+
46
+
47
+ def is_frame(value: Any) -> bool:
48
+ """True for anything stats3 can read as a table."""
49
+ return (
50
+ isinstance(value, (pl.DataFrame, pl.LazyFrame))
51
+ or _is_pandas_frame(value)
52
+ or _is_tidy(value)
53
+ or (isinstance(value, Mapping) and not isinstance(value, str))
54
+ )
55
+
56
+
57
+ def _tidy_expr(value: Any) -> bool:
58
+ """A tidy3 expression (``col("x")``, ``sex == "male"``), duck-typed."""
59
+ node = getattr(value, "node", None)
60
+ return isinstance(node, tuple) and type(value).__module__.startswith("tidy3")
61
+
62
+
63
+ def column_name(spec: Any) -> str | None:
64
+ """The column a spec names, or None when it is a computed expression.
65
+
66
+ Accepts a string (tidy3's bare notebook names are strings too), a Polars
67
+ column expression, or a tidy3 ``col("x")``.
68
+ """
69
+ if isinstance(spec, str):
70
+ return str(spec)
71
+ if isinstance(spec, pl.Expr):
72
+ if spec.meta.is_column():
73
+ return spec.meta.output_name()
74
+ return None
75
+ if _tidy_expr(spec) and spec.node[0] in {"col", "name"}:
76
+ return str(spec.node[1])
77
+ return None
78
+
79
+
80
+ def expression(spec: Any) -> pl.Expr:
81
+ """A Polars expression for a column name, Polars expression, or tidy3
82
+ expression (compiled with tidy3, which is loaded if one exists)."""
83
+ if isinstance(spec, pl.Expr):
84
+ return spec
85
+ name = column_name(spec)
86
+ if name is not None:
87
+ return pl.col(name)
88
+ if _tidy_expr(spec):
89
+ return sys.modules["tidy3.expr"].to_polars(spec)
90
+ raise TypeError(f"expected a column name or expression, got {spec!r}")
91
+
92
+
93
+ class Data:
94
+ """One input table, read once: rows, groups, and factor levels."""
95
+
96
+ def __init__(
97
+ self,
98
+ lf: pl.LazyFrame,
99
+ groups: list[str],
100
+ levels: dict[str, list[Any]],
101
+ kind: str,
102
+ source: Any = None,
103
+ ):
104
+ self._lf = lf
105
+ self._groups = groups
106
+ self._levels = levels
107
+ self.kind = kind
108
+ self.source = source
109
+ self._names: list[str] | None = None
110
+
111
+ @property
112
+ def columns(self) -> list[str]:
113
+ if self._names is None:
114
+ self._names = list(self._lf.collect_schema().names())
115
+ return self._names
116
+
117
+ @classmethod
118
+ def read(cls, data: Any, by: Any, verb: str) -> "Data":
119
+ if isinstance(data, pl.LazyFrame):
120
+ out = cls(data, [], {}, "lazy")
121
+ elif isinstance(data, pl.DataFrame):
122
+ out = cls(data.lazy(), [], {}, "polars")
123
+ elif _is_pandas_frame(data):
124
+ levels = {
125
+ str(name): list(data[name].cat.categories)
126
+ for name, dtype in data.dtypes.items()
127
+ if str(dtype) == "category"
128
+ }
129
+ try:
130
+ frame = pl.from_pandas(data)
131
+ except ImportError as e:
132
+ raise ImportError(
133
+ f"{verb}(): reading this pandas frame needs pyarrow: "
134
+ "pip install 'stats3[pandas]'"
135
+ ) from e
136
+ out = cls(frame.lazy(), [], levels, "pandas")
137
+ elif _is_tidy(data):
138
+ out = cls(data.lazy(), _tidy_groups(data), _tidy_levels(data), "tidy3", data)
139
+ elif isinstance(data, Mapping):
140
+ out = cls(pl.DataFrame(dict(data)).lazy(), [], {}, "polars")
141
+ else:
142
+ raise TypeError(
143
+ f"{verb}(): expected a Polars or pandas DataFrame, a tidy3 frame, "
144
+ f"or a dict of columns; got {type(data).__name__}"
145
+ )
146
+ if by is not None:
147
+ if out._groups:
148
+ raise ValueError(f"{verb}(): by= cannot be used on a grouped frame")
149
+ specs = by if isinstance(by, (list, tuple)) else [by]
150
+ out._groups = [out.column(spec, "by", verb) for spec in specs]
151
+ return out
152
+
153
+ def column(self, spec: Any, role: str, verb: str) -> str:
154
+ name = column_name(spec)
155
+ if name is None:
156
+ raise ValueError(f"{verb}(): {role} must name one column, got {spec!r}")
157
+ if name not in self.columns:
158
+ shown = ", ".join(repr(c) for c in self.columns[:20])
159
+ raise KeyError(f"{verb}(): column {name!r} does not exist. Columns: {shown}")
160
+ return name
161
+
162
+ def levels_of(self, name: str) -> list[Any] | None:
163
+ """A column's factor levels in order, or None when it is not a factor."""
164
+ if name in self._levels:
165
+ return list(self._levels[name])
166
+ dtype = self._lf.collect_schema()[name]
167
+ return dtype.categories.to_list() if isinstance(dtype, pl.Enum) else None
168
+
169
+ def write(self, result: pl.DataFrame, as_: str | None = None) -> Any:
170
+ """The result as the kind of frame that came in (or ``as_``)."""
171
+ kind = as_ or self.kind
172
+ if kind == "lazy":
173
+ return result.lazy()
174
+ if kind == "polars":
175
+ return result
176
+ if kind == "pandas":
177
+ return _to_pandas(result, {g: self._levels[g] for g in self._groups if g in self._levels})
178
+ if kind == "tidy3":
179
+ return _to_tidy(result, self)
180
+ raise ValueError(f"as_ must be one of {OUTPUTS}, got {as_!r}")
181
+
182
+
183
+ def _tidy_groups(frame: Any) -> list[str]:
184
+ tidy3 = sys.modules["tidy3"]
185
+ groups = tidy3.group_vars(frame) if hasattr(tidy3, "group_vars") else frame._groups
186
+ return [str(g) for g in (groups or [])]
187
+
188
+
189
+ def _tidy_levels(frame: Any) -> dict[str, list[Any]]:
190
+ accessor = getattr(frame, "factor_levels", None)
191
+ levels = accessor() if callable(accessor) else getattr(frame, "_category_levels", {})
192
+ return {str(name): list(values) for name, values in (levels or {}).items()}
193
+
194
+
195
+ def _to_pandas(result: pl.DataFrame, levels: dict[str, list[Any]]) -> Any:
196
+ try:
197
+ frame = result.to_pandas()
198
+ except ImportError as e:
199
+ raise ImportError(
200
+ "returning a pandas frame needs pandas and pyarrow: pip install 'stats3[pandas]'"
201
+ ) from e
202
+ for name, values in levels.items():
203
+ frame[name] = frame[name].astype("category").cat.set_categories(values)
204
+ return frame
205
+
206
+
207
+ def _to_tidy(result: pl.DataFrame, data: Data) -> Any:
208
+ """A tidy3 frame that keeps the grouping columns' factor levels."""
209
+ if "tidy3" not in sys.modules:
210
+ raise ImportError("as_='tidy3' needs tidy3: pip install tidy3")
211
+ levels = {g: data._levels[g] for g in data._groups if g in data._levels}
212
+ if data.source is not None:
213
+ try:
214
+ return type(data.source)(result.lazy(), category_levels=levels)
215
+ except TypeError:
216
+ pass
217
+ return sys.modules["tidy3"].tidy(result)
218
+
219
+
220
+ class Step:
221
+ """A test waiting for data: ``data >> step``, or ``step(data)``.
222
+
223
+ pandas and Polars frames have no ``>>`` of their own, and tidy3 frames
224
+ hand ``>>`` to the right-hand side, so ``__rrshift__`` receives them all.
225
+ """
226
+
227
+ __slots__ = ("_fn", "name", "by", "as_")
228
+
229
+ def __init__(self, fn: Callable[[Data], pl.DataFrame], name: str, by: Any = None, as_: str | None = None):
230
+ self._fn = fn
231
+ self.name = name
232
+ self.by = by
233
+ self.as_ = as_
234
+
235
+ def __rrshift__(self, data: Any) -> Any:
236
+ frame = Data.read(data, self.by, self.name)
237
+ return frame.write(self._fn(frame), self.as_)
238
+
239
+ def __call__(self, data: Any) -> Any:
240
+ return self.__rrshift__(data)
241
+
242
+ def __repr__(self) -> str:
243
+ return f"<stats3 {self.name}: pipe data into it with >>>"
244
+
245
+
246
+ def step(fn: Callable[..., Step]) -> Callable[..., Any]:
247
+ """Make a test usable as a pipe step or with the data first.
248
+
249
+ ``t_test("y ~ g", by="g2")`` returns a ``Step``;
250
+ ``t_test(frame, "y ~ g", by="g2")`` runs it on ``frame`` at once.
251
+ Every test gets ``by=`` (dplyr's ``.by``) and ``as_=`` (the output frame
252
+ type, by default the input's).
253
+ """
254
+ import functools
255
+
256
+ @functools.wraps(fn)
257
+ def wrapper(*args: Any, by: Any = None, as_: str | None = None, **kwargs: Any) -> Any:
258
+ if as_ is not None and as_ not in OUTPUTS:
259
+ raise ValueError(f"as_ must be one of {OUTPUTS}, got {as_!r}")
260
+ if args and is_frame(args[0]) and not isinstance(args[0], str):
261
+ data, rest = args[0], args[1:]
262
+ made = fn(*rest, **kwargs)
263
+ made.by, made.as_ = by, as_
264
+ return made(data)
265
+ made = fn(*args, **kwargs)
266
+ made.by, made.as_ = by, as_
267
+ return made
268
+
269
+ return wrapper