otter 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.
otter/__init__.py ADDED
@@ -0,0 +1,81 @@
1
+ """A readable pandas-based framework for research workflows: dataset
2
+ handling with tracked model specifications, reusable column transforms,
3
+ and a Monte Carlo simulation module."""
4
+
5
+ from .experiment import (
6
+ ExperimentSize,
7
+ assign_groups,
8
+ compare_groups,
9
+ cuped_adjust,
10
+ detectable_lift_for_mean,
11
+ detectable_lift_for_proportion,
12
+ pretest_balance,
13
+ sample_size_for_mean,
14
+ sample_size_for_proportion,
15
+ )
16
+ from .pond import ModelSpec, Pond, PondLoadFailedError
17
+ from .simulation import (
18
+ ConvergenceDiagnostics,
19
+ DistributionSpec,
20
+ InputManager,
21
+ ModelFunction,
22
+ MonteCarloEngine,
23
+ Scenario,
24
+ SensitivityAnalyzer,
25
+ Simulation,
26
+ SimulationResult,
27
+ )
28
+ from .transforms import (
29
+ demean_by_group,
30
+ interaction,
31
+ log1p_transform,
32
+ log_transform,
33
+ mean_center,
34
+ min_max_scale,
35
+ rank_transform,
36
+ row_mean,
37
+ row_sum,
38
+ safe_ratio,
39
+ square,
40
+ winsorize,
41
+ z_score,
42
+ )
43
+ from .plotter import SimulationPlotter
44
+
45
+ __all__ = [
46
+ "ConvergenceDiagnostics",
47
+ "DistributionSpec",
48
+ "ExperimentSize",
49
+ "InputManager",
50
+ "ModelFunction",
51
+ "ModelSpec",
52
+ "MonteCarloEngine",
53
+ "Pond",
54
+ "PondLoadFailedError",
55
+ "Scenario",
56
+ "SensitivityAnalyzer",
57
+ "Simulation",
58
+ "SimulationPlotter",
59
+ "SimulationResult",
60
+ "assign_groups",
61
+ "compare_groups",
62
+ "cuped_adjust",
63
+ "demean_by_group",
64
+ "detectable_lift_for_mean",
65
+ "detectable_lift_for_proportion",
66
+ "interaction",
67
+ "log1p_transform",
68
+ "log_transform",
69
+ "mean_center",
70
+ "min_max_scale",
71
+ "pretest_balance",
72
+ "rank_transform",
73
+ "row_mean",
74
+ "row_sum",
75
+ "safe_ratio",
76
+ "sample_size_for_mean",
77
+ "sample_size_for_proportion",
78
+ "square",
79
+ "winsorize",
80
+ "z_score",
81
+ ]
otter/experiment.py ADDED
@@ -0,0 +1,508 @@
1
+ """Designing and measuring controlled experiments.
2
+
3
+ The rest of otter describes a study you already have data for. This module
4
+ covers the two questions that come before and after that: how big does the
5
+ experiment need to be, and what did it actually show.
6
+
7
+ design sample_size_for_mean, sample_size_for_proportion
8
+ detectable_lift_for_mean, detectable_lift_for_proportion
9
+ assign assign_groups
10
+ measure compare_groups, cuped_adjust, pretest_balance
11
+
12
+ Everything takes and returns plain pandas objects, so it composes with
13
+ ``Pond`` without needing to know about it.
14
+
15
+ A worked example, from planning to result::
16
+
17
+ from otter.experiment import sample_size_for_mean, assign_groups, compare_groups
18
+
19
+ # How many units do we need to see a 2% lift on a metric averaging 120
20
+ # with a standard deviation of 300, splitting the sample evenly?
21
+ size = sample_size_for_mean(
22
+ baseline_mean=120.0, baseline_sd=300.0, min_lift_pct=2.0,
23
+ )
24
+ size.treatment_n, size.control_n
25
+
26
+ # Randomise, then measure once the data is in.
27
+ assignment = assign_groups(user_ids, {"control": 0.5, "treatment": 0.5}, seed=1)
28
+ compare_groups(
29
+ data, group_col="group", metric_cols=["revenue"],
30
+ control="control", treatment="treatment",
31
+ )
32
+
33
+ ON THE STATISTICS. Continuous comparisons use Welch's t-test, which does not
34
+ assume equal variances between arms, with Satterthwaite degrees of freedom.
35
+ Proportion sample sizes use the normal approximation, and the sensitivity
36
+ direction uses the arcsine variance-stabilising transform. Variance reduction
37
+ is CUPED (Deng, Xu, Kohavi and Walker, 2013), which regresses out a
38
+ pre-period covariate. When several metrics are compared at once the p-values
39
+ are corrected for multiplicity, Benjamini-Hochberg by default.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ from dataclasses import dataclass
45
+ from typing import Iterable, Literal, Mapping, Sequence
46
+
47
+ import numpy as np
48
+ import pandas as pd
49
+ from scipy import stats
50
+ from statsmodels.stats.multitest import multipletests
51
+ from statsmodels.stats.power import TTestIndPower
52
+
53
+ Alternative = Literal["two-sided", "larger", "smaller"]
54
+
55
+ __all__ = [
56
+ "ExperimentSize",
57
+ "assign_groups",
58
+ "compare_groups",
59
+ "cuped_adjust",
60
+ "detectable_lift_for_mean",
61
+ "detectable_lift_for_proportion",
62
+ "pretest_balance",
63
+ "sample_size_for_mean",
64
+ "sample_size_for_proportion",
65
+ ]
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class ExperimentSize:
70
+ """How many units each arm needs, and the effect that buys.
71
+
72
+ ``effect_size`` is Cohen's d for a mean and the arcsine-transformed
73
+ effect for a proportion, so it is comparable within a metric type and not
74
+ across the two.
75
+ """
76
+
77
+ treatment_n: int
78
+ control_n: int
79
+ effect_size: float
80
+ detectable_lift_pct: float
81
+
82
+ @property
83
+ def total_n(self) -> int:
84
+ """Units needed across both arms."""
85
+ return self.treatment_n + self.control_n
86
+
87
+
88
+ def _check_share(treatment_share: float) -> float:
89
+ """The control-to-treatment ratio, or a named error.
90
+
91
+ ``treatment_share`` is the FRACTION of units in treatment, so 0.5 is an
92
+ even split and 0.1 puts a tenth in treatment. The source this replaced
93
+ called it a holdout percent and then solved for the arm it named
94
+ treatment, which read backwards; naming the fraction explicitly removes
95
+ the ambiguity rather than documenting it.
96
+ """
97
+ if not 0.0 < treatment_share < 1.0:
98
+ raise ValueError(
99
+ f"treatment_share must be strictly between 0 and 1, got "
100
+ f"{treatment_share!r}. It is a fraction, so an even split is 0.5, "
101
+ f"not 50."
102
+ )
103
+ return (1.0 - treatment_share) / treatment_share
104
+
105
+
106
+ def _z(alpha: float, power: float, alternative: Alternative) -> tuple[float, float]:
107
+ """The critical values for a normal-approximation sample size."""
108
+ if alternative == "two-sided":
109
+ z_alpha = stats.norm.ppf(1.0 - alpha / 2.0)
110
+ elif alternative in ("larger", "smaller"):
111
+ z_alpha = stats.norm.ppf(1.0 - alpha)
112
+ else:
113
+ raise ValueError(
114
+ f"alternative must be 'two-sided', 'larger' or 'smaller', got "
115
+ f"{alternative!r}"
116
+ )
117
+ return float(z_alpha), float(stats.norm.ppf(power))
118
+
119
+
120
+ def sample_size_for_mean(
121
+ baseline_mean: float,
122
+ baseline_sd: float,
123
+ min_lift_pct: float,
124
+ treatment_share: float = 0.5,
125
+ power: float = 0.80,
126
+ alpha: float = 0.05,
127
+ alternative: Alternative = "two-sided",
128
+ ) -> ExperimentSize:
129
+ """Units per arm needed to detect ``min_lift_pct`` on a continuous metric.
130
+
131
+ ``min_lift_pct`` is a percentage of the baseline mean, so 2.0 means a 2%
132
+ relative lift, which is how experiment targets are usually stated.
133
+
134
+ >>> size = sample_size_for_mean(100.0, 50.0, 5.0)
135
+ >>> size.treatment_n == size.control_n # an even split by default
136
+ True
137
+ """
138
+ if baseline_sd <= 0:
139
+ raise ValueError(f"baseline_sd must be positive, got {baseline_sd!r}")
140
+ if baseline_mean == 0:
141
+ raise ValueError(
142
+ "baseline_mean is zero, so a percentage lift has no meaning. "
143
+ "Use an absolute effect size instead."
144
+ )
145
+ ratio = _check_share(treatment_share)
146
+ cohen_d = baseline_mean * (min_lift_pct / 100.0) / baseline_sd
147
+ treatment_n = TTestIndPower().solve_power(
148
+ effect_size=cohen_d,
149
+ power=power,
150
+ nobs1=None,
151
+ ratio=ratio,
152
+ alpha=alpha,
153
+ alternative=alternative,
154
+ )
155
+ return ExperimentSize(
156
+ treatment_n=int(np.ceil(treatment_n)),
157
+ control_n=int(np.ceil(treatment_n * ratio)),
158
+ effect_size=float(cohen_d),
159
+ detectable_lift_pct=float(min_lift_pct),
160
+ )
161
+
162
+
163
+ def sample_size_for_proportion(
164
+ baseline_rate: float,
165
+ min_lift_pct: float,
166
+ treatment_share: float = 0.5,
167
+ power: float = 0.80,
168
+ alpha: float = 0.05,
169
+ alternative: Alternative = "two-sided",
170
+ ) -> ExperimentSize:
171
+ """Units per arm needed to detect ``min_lift_pct`` on a rate.
172
+
173
+ ``baseline_rate`` is a proportion in (0, 1), and ``min_lift_pct`` is
174
+ relative to it: a baseline of 0.10 with a 10% lift targets 0.11, not 0.20.
175
+ """
176
+ if not 0.0 < baseline_rate < 1.0:
177
+ raise ValueError(
178
+ f"baseline_rate is a proportion and must be strictly between 0 "
179
+ f"and 1, got {baseline_rate!r}"
180
+ )
181
+ ratio = _check_share(treatment_share)
182
+ z_alpha, z_beta = _z(alpha, power, alternative)
183
+ treated_rate = baseline_rate * (1.0 + min_lift_pct / 100.0)
184
+ if not 0.0 < treated_rate < 1.0:
185
+ raise ValueError(
186
+ f"a {min_lift_pct}% lift on a baseline of {baseline_rate} implies "
187
+ f"a rate of {treated_rate:.4f}, which is not a proportion"
188
+ )
189
+ var_sum = (
190
+ baseline_rate * (1.0 - baseline_rate)
191
+ + treated_rate * (1.0 - treated_rate)
192
+ )
193
+ n = var_sum / (baseline_rate - treated_rate) ** 2 * (z_alpha + z_beta) ** 2 * 2.0
194
+ n_prime = n * (1.0 + ratio) ** 2 / (4.0 * ratio)
195
+ treatment_n = n_prime / (1.0 + ratio)
196
+ pooled = (baseline_rate + treated_rate) / 2.0
197
+ effect = abs(treated_rate - baseline_rate) / np.sqrt(pooled * (1.0 - pooled))
198
+ return ExperimentSize(
199
+ treatment_n=int(np.ceil(treatment_n)),
200
+ control_n=int(np.ceil(n_prime - treatment_n)),
201
+ effect_size=float(effect),
202
+ detectable_lift_pct=float(min_lift_pct),
203
+ )
204
+
205
+
206
+ def detectable_lift_for_mean(
207
+ baseline_mean: float,
208
+ baseline_sd: float,
209
+ total_n: int,
210
+ treatment_share: float = 0.5,
211
+ power: float = 0.80,
212
+ alpha: float = 0.05,
213
+ alternative: Alternative = "two-sided",
214
+ ) -> float:
215
+ """The smallest relative lift a fixed sample can detect, as a percentage.
216
+
217
+ The other direction of ``sample_size_for_mean``: the sample is what it is,
218
+ so the question is whether the experiment can see anything worth seeing.
219
+ """
220
+ if baseline_sd <= 0:
221
+ raise ValueError(f"baseline_sd must be positive, got {baseline_sd!r}")
222
+ if total_n <= 2:
223
+ raise ValueError(f"total_n must be greater than 2, got {total_n!r}")
224
+ ratio = _check_share(treatment_share)
225
+ treatment_n = total_n * treatment_share
226
+ effect = TTestIndPower().solve_power(
227
+ effect_size=None,
228
+ power=power,
229
+ nobs1=treatment_n,
230
+ ratio=ratio,
231
+ alpha=alpha,
232
+ alternative=alternative,
233
+ )
234
+ return float(effect * baseline_sd / baseline_mean * 100.0)
235
+
236
+
237
+ def detectable_lift_for_proportion(
238
+ baseline_rate: float,
239
+ total_n: int,
240
+ treatment_share: float = 0.5,
241
+ power: float = 0.80,
242
+ alpha: float = 0.05,
243
+ alternative: Alternative = "two-sided",
244
+ ) -> float:
245
+ """The smallest relative lift on a rate a fixed sample can detect.
246
+
247
+ Uses the arcsine variance-stabilising transform, which behaves near 0 and
248
+ 1 where the plain normal approximation does not.
249
+ """
250
+ if not 0.0 < baseline_rate < 1.0:
251
+ raise ValueError(
252
+ f"baseline_rate is a proportion and must be strictly between 0 "
253
+ f"and 1, got {baseline_rate!r}"
254
+ )
255
+ if total_n <= 2:
256
+ raise ValueError(f"total_n must be greater than 2, got {total_n!r}")
257
+ _check_share(treatment_share)
258
+ z_alpha, z_beta = _z(alpha, power, alternative)
259
+ treatment_n = total_n * treatment_share
260
+ control_n = total_n - treatment_n
261
+ harmonic = 2.0 * treatment_n * control_n / (treatment_n + control_n)
262
+ effect = (z_alpha + z_beta) / np.sqrt(harmonic / 2.0)
263
+ treated = np.sin(2.0 * np.arcsin(np.sqrt(baseline_rate)) / 2.0 + effect / 2.0) ** 2
264
+ return float((treated - baseline_rate) / baseline_rate * 100.0)
265
+
266
+
267
+ def assign_groups(
268
+ units: Iterable[object],
269
+ ratios: Mapping[str, float],
270
+ seed: int | None = None,
271
+ ) -> pd.DataFrame:
272
+ """Randomly assign units to named groups in the given proportions.
273
+
274
+ Returns a frame of ``unit`` and ``group``. Pass ``seed`` to make an
275
+ assignment reproducible, which is what makes a published experiment
276
+ checkable by somebody else.
277
+
278
+ >>> frame = assign_groups(range(100), {"control": 0.5, "treatment": 0.5}, seed=0)
279
+ >>> sorted(frame["group"].unique())
280
+ ['control', 'treatment']
281
+ """
282
+ unit_list = list(units)
283
+ if not unit_list:
284
+ raise ValueError("no units to assign")
285
+ duplicates = len(unit_list) - len(set(unit_list))
286
+ if duplicates:
287
+ raise ValueError(
288
+ f"{duplicates} duplicate unit(s) in the input. Assigning the same "
289
+ f"unit twice puts it in two arms, which silently breaks the test."
290
+ )
291
+ names = list(ratios)
292
+ weights = np.asarray([ratios[name] for name in names], dtype=float)
293
+ if np.any(weights <= 0):
294
+ raise ValueError(f"every ratio must be positive, got {dict(ratios)!r}")
295
+ total = weights.sum()
296
+ if not np.isclose(total, 1.0):
297
+ raise ValueError(
298
+ f"ratios must sum to 1, got {total!r}. They are proportions of the "
299
+ f"sample, not counts."
300
+ )
301
+ rng = np.random.default_rng(seed)
302
+ chosen = rng.choice(len(names), size=len(unit_list), p=weights)
303
+ return pd.DataFrame(
304
+ {"unit": unit_list, "group": [names[i] for i in chosen]}
305
+ )
306
+
307
+
308
+ def cuped_adjust(
309
+ data: pd.DataFrame,
310
+ metric_col: str,
311
+ pre_col: str,
312
+ ) -> pd.Series:
313
+ """Variance-reduced metric using a pre-period covariate (CUPED).
314
+
315
+ Returns an adjusted copy of ``metric_col`` with the same mean and lower
316
+ variance, so the same experiment detects a smaller effect. The covariate
317
+ must be measured BEFORE assignment: a post-period covariate can be
318
+ affected by the treatment, and adjusting on it biases the result.
319
+
320
+ A covariate with no variance carries no information, so the metric is
321
+ returned unchanged rather than raising, and a constant column cannot
322
+ silently produce a divide by zero.
323
+ """
324
+ for column in (metric_col, pre_col):
325
+ if column not in data.columns:
326
+ raise KeyError(f"{column!r} is not a column of the data")
327
+ pre = data[pre_col].astype(float)
328
+ metric = data[metric_col].astype(float)
329
+ pre_var = float(pre.var(ddof=1))
330
+ if not np.isfinite(pre_var) or pre_var < 1e-12:
331
+ return metric.copy()
332
+ theta = float(pre.cov(metric)) / pre_var
333
+ return metric - theta * (pre - pre.mean())
334
+
335
+
336
+ def _welch(
337
+ treated: "pd.Series[float]", control: "pd.Series[float]", alpha: float,
338
+ alternative: Alternative,
339
+ ) -> dict[str, object]:
340
+ """One Welch comparison, as a row."""
341
+ n1, n2 = int(treated.count()), int(control.count())
342
+ if n1 < 2 or n2 < 2:
343
+ raise ValueError(
344
+ f"each arm needs at least 2 observations to have a variance, got "
345
+ f"treatment={n1} and control={n2}"
346
+ )
347
+ m1, m2 = float(treated.mean()), float(control.mean())
348
+ s1, s2 = float(treated.std(ddof=1)), float(control.std(ddof=1))
349
+ se = np.sqrt(s1**2 / n1 + s2**2 / n2)
350
+ if se == 0:
351
+ t_stat, pval, dof = np.nan, np.nan, np.nan
352
+ else:
353
+ dof = (s1**2 / n1 + s2**2 / n2) ** 2 / (
354
+ (s1**2 / n1) ** 2 / (n1 - 1) + (s2**2 / n2) ** 2 / (n2 - 1)
355
+ )
356
+ t_stat = (m1 - m2) / se
357
+ if alternative == "two-sided":
358
+ pval = float(2.0 * stats.t.sf(abs(t_stat), dof))
359
+ elif alternative == "larger":
360
+ pval = float(stats.t.sf(t_stat, dof))
361
+ else:
362
+ pval = float(stats.t.cdf(t_stat, dof))
363
+ diff = m1 - m2
364
+ if np.isnan(dof):
365
+ low, high = np.nan, np.nan
366
+ else:
367
+ crit = stats.t.ppf(1.0 - alpha / 2.0, dof)
368
+ low, high = diff - crit * se, diff + crit * se
369
+ return {
370
+ "treatment_n": n1,
371
+ "control_n": n2,
372
+ "treatment_mean": m1,
373
+ "control_mean": m2,
374
+ "treatment_sd": s1,
375
+ "control_sd": s2,
376
+ "mean_difference": diff,
377
+ "ci_low": float(low),
378
+ "ci_high": float(high),
379
+ "percent_lift": (diff / m2 * 100.0) if m2 != 0 else np.nan,
380
+ "t_statistic": float(t_stat),
381
+ "p_value": float(pval),
382
+ }
383
+
384
+
385
+ def compare_groups(
386
+ data: pd.DataFrame,
387
+ group_col: str,
388
+ metric_cols: Sequence[str],
389
+ control: str,
390
+ treatment: str,
391
+ alpha: float = 0.05,
392
+ alternative: Alternative = "two-sided",
393
+ correction: str | None = "fdr_bh",
394
+ cuped_pre: Mapping[str, str] | None = None,
395
+ ) -> pd.DataFrame:
396
+ """Compare two arms across metrics, one row per metric.
397
+
398
+ Welch's t-test throughout, so unequal variances between arms are handled
399
+ rather than assumed away. ``correction`` applies a multiple-testing
400
+ correction across the metrics and adds a ``p_value_corrected`` column;
401
+ pass ``None`` to skip it, which is only right when there is one metric.
402
+
403
+ ``cuped_pre`` maps a metric to its pre-period column and applies
404
+ :func:`cuped_adjust` before testing, which lowers the variance without
405
+ changing what is being estimated.
406
+ """
407
+ if group_col not in data.columns:
408
+ raise KeyError(f"{group_col!r} is not a column of the data")
409
+ if not metric_cols:
410
+ raise ValueError("no metrics given to compare")
411
+ present = set(data[group_col].unique())
412
+ missing = {control, treatment} - present
413
+ if missing:
414
+ raise ValueError(
415
+ f"group(s) {sorted(missing)!r} do not appear in {group_col!r}. "
416
+ f"Present: {sorted(map(str, present))!r}"
417
+ )
418
+
419
+ rows = []
420
+ for metric in metric_cols:
421
+ if metric not in data.columns:
422
+ raise KeyError(f"{metric!r} is not a column of the data")
423
+ frame = data
424
+ column = metric
425
+ if cuped_pre and metric in cuped_pre:
426
+ frame = data.assign(
427
+ **{f"__cuped_{metric}": cuped_adjust(data, metric, cuped_pre[metric])}
428
+ )
429
+ column = f"__cuped_{metric}"
430
+ treated = frame.loc[frame[group_col] == treatment, column]
431
+ controlled = frame.loc[frame[group_col] == control, column]
432
+ row: dict[str, object] = {
433
+ "metric": metric,
434
+ "treatment_group": treatment,
435
+ "control_group": control,
436
+ "cuped": bool(cuped_pre and metric in cuped_pre),
437
+ }
438
+ row.update(_welch(treated, controlled, alpha, alternative))
439
+ rows.append(row)
440
+
441
+ result = pd.DataFrame(rows)
442
+ if correction is not None and len(result) > 1:
443
+ usable = result["p_value"].notna()
444
+ result["p_value_corrected"] = np.nan
445
+ if usable.any():
446
+ result.loc[usable, "p_value_corrected"] = multipletests(
447
+ result.loc[usable, "p_value"].to_numpy(),
448
+ alpha=alpha,
449
+ method=correction,
450
+ )[1]
451
+ return result
452
+
453
+
454
+ def pretest_balance(
455
+ data: pd.DataFrame,
456
+ group_col: str,
457
+ pre_cols: Sequence[str],
458
+ control: str,
459
+ treatment: str,
460
+ alpha: float = 0.05,
461
+ ) -> pd.DataFrame:
462
+ """Check the arms looked the same BEFORE the treatment ran.
463
+
464
+ Randomisation is supposed to make the arms exchangeable. Testing the
465
+ pre-period metrics is how that gets checked rather than assumed: a
466
+ significant difference here means the split is suspect and the headline
467
+ result should not be read at face value.
468
+
469
+ A pre-period column that is constant or all zero carries no information,
470
+ which happens legitimately when the units are new and have no history. It
471
+ is reported as ``uninformative`` rather than tested, because a degenerate
472
+ t-test returns a confident-looking answer built on nothing.
473
+ """
474
+ rows = []
475
+ informative = []
476
+ for column in pre_cols:
477
+ if column not in data.columns:
478
+ raise KeyError(f"{column!r} is not a column of the data")
479
+ series = data[column].astype(float)
480
+ if float(series.std(ddof=0)) < 1e-12:
481
+ rows.append(
482
+ {
483
+ "metric": column,
484
+ "treatment_group": treatment,
485
+ "control_group": control,
486
+ "uninformative": True,
487
+ }
488
+ )
489
+ else:
490
+ informative.append(column)
491
+
492
+ if informative:
493
+ tested = compare_groups(
494
+ data,
495
+ group_col=group_col,
496
+ metric_cols=informative,
497
+ control=control,
498
+ treatment=treatment,
499
+ alpha=alpha,
500
+ )
501
+ tested["uninformative"] = False
502
+ rows.extend(tested.to_dict("records"))
503
+
504
+ result = pd.DataFrame(rows)
505
+ order = {name: i for i, name in enumerate(pre_cols)}
506
+ return result.sort_values("metric", key=lambda s: s.map(order)).reset_index(
507
+ drop=True
508
+ )