py-flexplot 0.8.2__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,287 @@
1
+ """
2
+ descriptives: Descriptive-statistics visualizations.
3
+
4
+ A port of R's ``fifer::meansplot()``: takes a numeric ``y ~ group`` formula
5
+ and shows the mean (with an error bar) per group, optionally connecting
6
+ the means with a line.
7
+
8
+ Usage::
9
+
10
+ from pyflexplot.descriptives import meansplot
11
+
12
+ p = meansplot("weight ~ diet", data=df)
13
+ p = meansplot("weight ~ diet", data=df, error="sd") # SD instead of SE
14
+ p = meansplot("weight ~ diet", data=df, error="ci") # 95% CI on the mean
15
+ p = meansplot("weight ~ diet", data=df, connect=True) # line connecting means
16
+
17
+ This module is intentionally separate from ``core.py`` so the descriptive-
18
+ stats surface doesn't bloat the formula-dispatch logic in ``flexplot()``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+
24
+ import numpy as np
25
+ import pandas as pd
26
+ from plotnine import (
27
+ aes,
28
+ geom_errorbar,
29
+ geom_line,
30
+ geom_point,
31
+ geom_tile,
32
+ ggplot,
33
+ labs,
34
+ scale_fill_gradient,
35
+ scale_color_gradient,
36
+ theme_bw,
37
+ )
38
+
39
+ from .core import parse_flexplot_formula, _validate_data_for_plot
40
+
41
+
42
+ _VALID_ERROR = {"se", "sd", "ci", "range", "iqr", "no"}
43
+
44
+
45
+ def meansplot(
46
+ formula: str,
47
+ data: pd.DataFrame,
48
+ error: str = "se",
49
+ level: float = 0.95,
50
+ connect: bool = True,
51
+ ):
52
+ """Plot the mean of y per level of x with an error bar.
53
+
54
+ Parameters
55
+ ----------
56
+ formula : str
57
+ Formula of the form ``y ~ group``. ``group`` may be a single
58
+ categorical variable (string/object) or a numeric variable with
59
+ few enough unique values to be treated as discrete.
60
+ data : pd.DataFrame
61
+ The dataset.
62
+ error : {"se", "sd", "ci", "range", "iqr", "no"}, default "se"
63
+ Kind of error bar to draw around each mean:
64
+ - ``"se"``: standard error of the mean (default).
65
+ - ``"sd"``: standard deviation.
66
+ - ``"ci"``: ``level`` confidence interval on the mean.
67
+ - ``"range"``: min-max range.
68
+ - ``"iqr"``: Q1-Q3 IQR.
69
+ - ``"no"``: no error bar.
70
+ level : float, default 0.95
71
+ Coverage probability for ``error="ci"``. Ignored otherwise.
72
+ connect : bool, default True
73
+ If ``True``, draw a line connecting the per-group means (useful
74
+ for ordinal predictors where the trend matters).
75
+ """
76
+ if error not in _VALID_ERROR:
77
+ raise ValueError(
78
+ f"error must be one of {sorted(_VALID_ERROR)}; got {error!r}."
79
+ )
80
+
81
+ variables = parse_flexplot_formula(formula)
82
+ _validate_data_for_plot(formula, data, variables)
83
+ y = variables["y"]
84
+ x = variables["x"]
85
+ if variables.get("color"):
86
+ raise ValueError(
87
+ f"meansplot does not support a `color` term; got formula "
88
+ f"{formula!r} with color={variables['color']!r}."
89
+ )
90
+ if variables.get("given"):
91
+ raise ValueError(
92
+ f"meansplot does not support `given` terms (faceting); got "
93
+ f"formula {formula!r} with given={variables['given']!r}."
94
+ )
95
+
96
+ if not pd.api.types.is_numeric_dtype(data[y]):
97
+ raise ValueError(
98
+ f"meansplot requires a numeric y; got {y!r} with dtype "
99
+ f"{data[y].dtype}."
100
+ )
101
+
102
+ # Group by x and compute summary statistics.
103
+ grouped = data.groupby(x, observed=True, sort=True)[y]
104
+ summary = grouped.agg(["count", "mean", "std"]).reset_index()
105
+
106
+ if error == "se":
107
+ summary["__lower"] = summary["mean"] - summary["std"] / np.sqrt(summary["count"])
108
+ summary["__upper"] = summary["mean"] + summary["std"] / np.sqrt(summary["count"])
109
+ elif error == "sd":
110
+ summary["__lower"] = summary["mean"] - summary["std"]
111
+ summary["__upper"] = summary["mean"] + summary["std"]
112
+ elif error == "ci":
113
+ from scipy import stats as _scipy_stats
114
+ # 95% CI on the mean using a t-distribution (n-1 df).
115
+ se = summary["std"] / np.sqrt(summary["count"])
116
+ df = summary["count"] - 1
117
+ t_crit = _scipy_stats.t.ppf(0.5 + level / 2, df)
118
+ summary["__lower"] = summary["mean"] - t_crit * se
119
+ summary["__upper"] = summary["mean"] + t_crit * se
120
+ elif error == "range":
121
+ summary["__lower"] = grouped.min().to_numpy()
122
+ summary["__upper"] = grouped.max().to_numpy()
123
+ elif error == "iqr":
124
+ summary["__lower"] = grouped.quantile(0.25).to_numpy()
125
+ summary["__upper"] = grouped.quantile(0.75).to_numpy()
126
+ elif error == "no":
127
+ # No error bars; we'll skip the geom_errorbar layer below.
128
+ summary["__lower"] = summary["mean"]
129
+ summary["__upper"] = summary["mean"]
130
+
131
+ # If x is numeric with few unique values, coerce to categorical so
132
+ # plotnine treats it as discrete levels (matches R's behavior).
133
+ plot_df = summary.copy()
134
+ if pd.api.types.is_numeric_dtype(plot_df[x]):
135
+ plot_df[x] = plot_df[x].astype(str)
136
+
137
+ p = (
138
+ ggplot(plot_df, aes(x=x, y="mean"))
139
+ + geom_point(size=3, color="black")
140
+ + labs(
141
+ x=x,
142
+ y=f"mean({y})",
143
+ title=f"Means plot: {y} by {x}",
144
+ )
145
+ + theme_bw()
146
+ )
147
+ if error != "no":
148
+ p += geom_errorbar(
149
+ aes(ymin="__lower", ymax="__upper"),
150
+ width=0.2,
151
+ color="black",
152
+ )
153
+ if connect:
154
+ p += geom_line(
155
+ mapping=aes(x=x, y="mean", group=1),
156
+ color="gray",
157
+ linetype="dashed",
158
+ )
159
+ return p
160
+
161
+ _VALID_SCATTER3D_TYPE = {"points", "tile"}
162
+
163
+
164
+ def scatter3D(
165
+ formula: str,
166
+ data: pd.DataFrame,
167
+ type: str = "points",
168
+ bins: int = 20,
169
+ ):
170
+ """Visualize the relationship between a numeric outcome and two continuous predictors.
171
+
172
+ A 2D rendering of R-flexplot's ``scatter3D()``: plots ``x`` on the
173
+ horizontal axis and ``z`` on the vertical axis, with the outcome
174
+ ``y`` mapped to either point color (``type='points'``, the default)
175
+ or tile fill (``type='tile'``).
176
+
177
+ This is a *projection* of the 3D relationship ``y ~ x + z`` — not a
178
+ true 3D scatter, since plotnine renders to 2D. R-flexplot's
179
+ ``scatter3D()`` uses the rgl package for true 3D; that backend is
180
+ out of scope for the Python port. This 2D projection still surfaces
181
+ the relationship structure and is the closest faithful rendering
182
+ without adding a 3D plotting dependency.
183
+
184
+ Parameters
185
+ ----------
186
+ formula : str
187
+ Formula of the form ``y ~ x + z``. ``y``, ``x``, and ``z`` must
188
+ all be numeric. The parser uses ``x`` as the horizontal axis,
189
+ ``z`` as the vertical axis, and ``y`` as the color / fill.
190
+ data : pd.DataFrame
191
+ The dataset.
192
+ type : {"points", "tile"}, default "points"
193
+ - ``"points"``: scatter of (x, z), color = y. Best for raw
194
+ inspection of the (x, z) -> y relationship.
195
+ - ``"tile"``: aggregate y into a (bins x bins) grid and draw
196
+ a heatmap. Best for dense data where point overlap obscures
197
+ structure.
198
+ bins : int, default 20
199
+ Number of bins per axis when ``type='tile'``. Ignored when
200
+ ``type='points'``.
201
+
202
+ Returns
203
+ -------
204
+ plotnine.ggplot
205
+ """
206
+ if type not in _VALID_SCATTER3D_TYPE:
207
+ raise ValueError(
208
+ f"type must be one of {sorted(_VALID_SCATTER3D_TYPE)}; got {type!r}."
209
+ )
210
+
211
+ variables = parse_flexplot_formula(formula)
212
+ _validate_data_for_plot(formula, data, variables)
213
+ y = variables["y"]
214
+ x = variables["x"]
215
+ # parse_flexplot_formula returns the first atom as x and the second
216
+ # (when present) as color. For y ~ x + z, z is exposed as the
217
+ # "color" term in the parser; we treat it as the vertical axis.
218
+ z = variables.get("color")
219
+ if z is None:
220
+ raise ValueError(
221
+ f"scatter3D requires a formula with two predictors: y ~ x + z. "
222
+ f"Got {formula!r}."
223
+ )
224
+ if variables.get("given"):
225
+ raise ValueError(
226
+ f"scatter3D does not support `given` terms (faceting); got "
227
+ f"formula {formula!r} with given={variables['given']!r}."
228
+ )
229
+
230
+ for col in (y, x, z):
231
+ if not pd.api.types.is_numeric_dtype(data[col]):
232
+ raise ValueError(
233
+ f"scatter3D requires numeric columns for y, x, and z; "
234
+ f"got column {col!r} with dtype {data[col].dtype}."
235
+ )
236
+
237
+ if type == "points":
238
+ p = (
239
+ ggplot(data, aes(x=x, y=z, color=y))
240
+ + geom_point(alpha=0.6)
241
+ + scale_color_gradient(low="#fee8c8", high="#7f0000", name=y)
242
+ + labs(
243
+ x=x,
244
+ y=z,
245
+ color=y,
246
+ title=f"scatter3D: {y} by {x} + {z}",
247
+ )
248
+ + theme_bw()
249
+ )
250
+ return p
251
+
252
+ # type == "tile": bin (x, z) into a grid; compute the mean of y per bin.
253
+ work = data[[x, z, y]].copy()
254
+ work["__x_bin"] = pd.cut(work[x], bins=bins, labels=False, include_lowest=True)
255
+ work["__z_bin"] = pd.cut(work[z], bins=bins, labels=False, include_lowest=True)
256
+ agg = (
257
+ work.groupby(["__x_bin", "__z_bin"], observed=True)[y]
258
+ .mean()
259
+ .reset_index()
260
+ )
261
+ # Recover bin centers for the aesthetic mapping.
262
+ x_cuts = pd.cut(data[x], bins=bins, include_lowest=True)
263
+ z_cuts = pd.cut(data[z], bins=bins, include_lowest=True)
264
+ x_bin_centers = {
265
+ i: float((interval.left + interval.right) / 2.0)
266
+ for i, interval in enumerate(x_cuts.cat.categories)
267
+ }
268
+ z_bin_centers = {
269
+ i: float((interval.left + interval.right) / 2.0)
270
+ for i, interval in enumerate(z_cuts.cat.categories)
271
+ }
272
+ agg["__x_center"] = agg["__x_bin"].map(x_bin_centers)
273
+ agg["__z_center"] = agg["__z_bin"].map(z_bin_centers)
274
+
275
+ p = (
276
+ ggplot(agg, aes(x="__x_center", y="__z_center", fill=y))
277
+ + geom_tile()
278
+ + scale_fill_gradient(low="#fee8c8", high="#7f0000", name=f"mean({y})")
279
+ + labs(
280
+ x=x,
281
+ y=z,
282
+ fill=f"mean({y})",
283
+ title=f"scatter3D tile: {y} by {x} + {z}",
284
+ )
285
+ + theme_bw()
286
+ )
287
+ return p
pyflexplot/ebbr.py ADDED
@@ -0,0 +1,148 @@
1
+ """Empirical Bayes binomial estimation (ported from ebbr)."""
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ from scipy.optimize import minimize
6
+ from scipy.special import betaln, gammaln
7
+ from scipy.stats import beta as beta_dist
8
+ from dataclasses import dataclass
9
+
10
+
11
+ @dataclass
12
+ class BetaPrior:
13
+ alpha: float
14
+ beta: float
15
+ n_obs: int
16
+ method: str = "mle"
17
+
18
+ @property
19
+ def mean(self) -> float:
20
+ return self.alpha / (self.alpha + self.beta)
21
+
22
+
23
+ def _beta_binomial_loglik(params, successes, totals):
24
+ alpha, beta = params
25
+ if alpha <= 0 or beta <= 0:
26
+ return np.inf
27
+ log_coeff = gammaln(totals + 1) - gammaln(successes + 1) - gammaln(
28
+ totals - successes + 1
29
+ )
30
+ ll = (
31
+ log_coeff
32
+ + betaln(successes + alpha, totals - successes + beta)
33
+ - betaln(alpha, beta)
34
+ )
35
+ return -np.sum(ll)
36
+
37
+
38
+ def fit_beta_prior(successes, totals, method="mle"):
39
+ """
40
+ Fit a Beta prior to observed binomial counts via MLE or method of moments.
41
+
42
+ Validates that ``0 <= successes <= totals`` and ``totals > 0``. Guards
43
+ against zero variance in the observed rates and verifies that the
44
+ optimizer converged to finite parameters.
45
+ """
46
+ successes = np.asarray(successes, dtype=float)
47
+ totals = np.asarray(totals, dtype=float)
48
+
49
+ if successes.ndim != 1 or totals.ndim != 1:
50
+ raise ValueError("successes and totals must be one-dimensional arrays/Series")
51
+ if len(successes) != len(totals):
52
+ raise ValueError(
53
+ f"successes and totals must have the same length: "
54
+ f"{len(successes)} vs {len(totals)}"
55
+ )
56
+ if len(successes) == 0:
57
+ raise ValueError("successes and totals must not be empty")
58
+
59
+ if np.any((successes < 0) | (successes > totals)):
60
+ bad = np.where((successes < 0) | (successes > totals))[0]
61
+ raise ValueError(
62
+ f"successes must satisfy 0 <= successes <= totals. Bad indices: {bad[:10].tolist()}"
63
+ )
64
+ if np.any(totals <= 0):
65
+ bad = np.where(totals <= 0)[0]
66
+ raise ValueError(f"totals must be positive. Bad indices: {bad[:10].tolist()}")
67
+ if np.any(np.isnan(successes)) or np.any(np.isnan(totals)):
68
+ raise ValueError("successes and totals must not contain NaN")
69
+
70
+ rates = successes / totals
71
+ m = rates.mean()
72
+ v = rates.var()
73
+
74
+ # Guard zero variance: all rates identical => no information to estimate a
75
+ # beta prior. Return a weakly informative prior rather than NaN/Inf seeds.
76
+ if v == 0:
77
+ if method == "moments":
78
+ return BetaPrior(1.0, 1.0, len(successes), "moments")
79
+ return BetaPrior(1.0, 1.0, len(successes), "mle")
80
+
81
+ common = m * (1 - m) / v - 1
82
+ alpha0 = m * common
83
+ beta0 = (1 - m) * common
84
+
85
+ # Clip method-of-moments seeds to avoid negative or extreme initial values.
86
+ alpha0 = max(1e-6, min(alpha0, 1e6))
87
+ beta0 = max(1e-6, min(beta0, 1e6))
88
+
89
+ if method == "moments":
90
+ return BetaPrior(alpha0, beta0, len(successes), "moments")
91
+
92
+ res = minimize(
93
+ _beta_binomial_loglik,
94
+ x0=[alpha0, beta0],
95
+ args=(successes, totals),
96
+ bounds=((1e-6, None), (1e-6, None)),
97
+ )
98
+
99
+ if not res.success:
100
+ raise RuntimeError(
101
+ f"Beta prior optimization did not converge: {res.message}"
102
+ )
103
+
104
+ alpha_hat, beta_hat = res.x
105
+ if not (np.isfinite(alpha_hat) and np.isfinite(beta_hat) and alpha_hat > 0 and beta_hat > 0):
106
+ raise RuntimeError(
107
+ f"Beta prior optimization returned invalid parameters: alpha={alpha_hat}, beta={beta_hat}"
108
+ )
109
+
110
+ return BetaPrior(alpha_hat, beta_hat, len(successes), "mle")
111
+
112
+
113
+ def add_ebb_estimate(df, success_col, total_col, prior=None):
114
+ """
115
+ Add empirical-Bayes beta-binomial shrinkage estimates to a DataFrame.
116
+ """
117
+ if not isinstance(df, pd.DataFrame):
118
+ raise TypeError(f"df must be a pandas DataFrame, got {type(df).__name__}")
119
+ if success_col not in df.columns:
120
+ raise ValueError(f"success_col {success_col!r} not found in DataFrame")
121
+ if total_col not in df.columns:
122
+ raise ValueError(f"total_col {total_col!r} not found in DataFrame")
123
+
124
+ successes = pd.to_numeric(df[success_col], errors="coerce")
125
+ totals = pd.to_numeric(df[total_col], errors="coerce")
126
+
127
+ if successes.isna().any() or totals.isna().any():
128
+ raise ValueError(
129
+ f"{success_col!r} and {total_col!r} must be numeric and non-missing"
130
+ )
131
+
132
+ if prior is None:
133
+ prior = fit_beta_prior(successes.values, totals.values)
134
+
135
+ # Use scalar arithmetic on the underlying arrays to avoid index-alignment
136
+ # surprises when df has a non-default index.
137
+ alpha1 = prior.alpha + successes.values
138
+ beta1 = prior.beta + totals.values - successes.values
139
+
140
+ fitted = alpha1 / (alpha1 + beta1)
141
+ low = beta_dist.ppf(0.025, alpha1, beta1)
142
+ high = beta_dist.ppf(0.975, alpha1, beta1)
143
+
144
+ out = df.copy()
145
+ out["ebb_fitted"] = fitted
146
+ out["ebb_low"] = low
147
+ out["ebb_high"] = high
148
+ return out