gallop-pds 0.2.0__tar.gz
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.
- gallop_pds-0.2.0/LICENSE +21 -0
- gallop_pds-0.2.0/PKG-INFO +118 -0
- gallop_pds-0.2.0/README.md +94 -0
- gallop_pds-0.2.0/pyproject.toml +40 -0
- gallop_pds-0.2.0/setup.cfg +4 -0
- gallop_pds-0.2.0/src/gallop/__init__.py +17 -0
- gallop_pds-0.2.0/src/gallop/examples/__init__.py +0 -0
- gallop_pds-0.2.0/src/gallop/examples/quickstart.py +89 -0
- gallop_pds-0.2.0/src/gallop/power.py +118 -0
- gallop_pds-0.2.0/src/gallop/priors.py +160 -0
- gallop_pds-0.2.0/src/gallop/sequential.py +123 -0
- gallop_pds-0.2.0/src/gallop/shrink.py +117 -0
- gallop_pds-0.2.0/src/gallop/templates/metric-registry.schema.json +21 -0
- gallop_pds-0.2.0/src/gallop/templates/prior-store.schema.json +24 -0
- gallop_pds-0.2.0/src/gallop/trust.py +174 -0
- gallop_pds-0.2.0/src/gallop/validate.py +348 -0
- gallop_pds-0.2.0/src/gallop/variance.py +111 -0
- gallop_pds-0.2.0/src/gallop_pds.egg-info/PKG-INFO +118 -0
- gallop_pds-0.2.0/src/gallop_pds.egg-info/SOURCES.txt +28 -0
- gallop_pds-0.2.0/src/gallop_pds.egg-info/dependency_links.txt +1 -0
- gallop_pds-0.2.0/src/gallop_pds.egg-info/requires.txt +7 -0
- gallop_pds-0.2.0/src/gallop_pds.egg-info/top_level.txt +1 -0
- gallop_pds-0.2.0/tests/test_power.py +39 -0
- gallop_pds-0.2.0/tests/test_priors.py +89 -0
- gallop_pds-0.2.0/tests/test_sequential.py +60 -0
- gallop_pds-0.2.0/tests/test_shrink.py +56 -0
- gallop_pds-0.2.0/tests/test_sql_templates.py +23 -0
- gallop_pds-0.2.0/tests/test_trust.py +41 -0
- gallop_pds-0.2.0/tests/test_validate.py +118 -0
- gallop_pds-0.2.0/tests/test_variance.py +41 -0
gallop_pds-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 0trm
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gallop-pds
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Product data science checks: power from priors, SRM, CUPED, always-valid inference, empirical Bayes shrinkage, out-of-time model validation, and the prior store they read.
|
|
5
|
+
Author: 0trm
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://0trm.github.io/gallop/
|
|
8
|
+
Project-URL: Repository, https://github.com/0trm/gallop
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: numpy>=1.26
|
|
18
|
+
Requires-Dist: pandas>=2.0
|
|
19
|
+
Requires-Dist: scipy>=1.11
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
<p align="right">
|
|
26
|
+
<img src="site/assets/mark.svg" width="300" alt="Three riders carried on one galloping horse">
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
# gallop
|
|
30
|
+
|
|
31
|
+
**Routes a product question to the method it deserves, then runs that method with checks in place.**
|
|
32
|
+
|
|
33
|
+
A product data science system, packaged as agent skills. Most questions leave at
|
|
34
|
+
intake without an analysis. The ones that stay get the method that matches how
|
|
35
|
+
treatment was assigned, and the checks that decide whether the result is a
|
|
36
|
+
result. What they teach is written back, so the next question starts smaller.
|
|
37
|
+
|
|
38
|
+
## The map
|
|
39
|
+
|
|
40
|
+
A question enters at the left and leaves as a decision. Measurement is a
|
|
41
|
+
foundation rather than a phase, because what ships changes the data. Theory is a
|
|
42
|
+
ceiling rather than a report, because what you learn has to outlive the test that
|
|
43
|
+
produced it.
|
|
44
|
+
|
|
45
|
+
Three method buckets, a floor underneath them, a memory above them, and a layer
|
|
46
|
+
of judgment on top. Each bucket has its own question, its own output, and its own
|
|
47
|
+
failure mode:
|
|
48
|
+
|
|
49
|
+
| Bucket | Asks | Hands back | Fails by |
|
|
50
|
+
|---|---|---|---|
|
|
51
|
+
| Description | What happened? | A hypothesis | Being mistaken for causation |
|
|
52
|
+
| Causation | Did this change cause that? | An effect size | An invalid comparison group |
|
|
53
|
+
| Prediction | What will happen? Who gets what? | A forecast, a ranking, an allocation | Breaking the moment you intervene |
|
|
54
|
+
|
|
55
|
+
Experimentation and causal inference share all three, so they are one bucket
|
|
56
|
+
separated only by who did the randomising: you, or the world.
|
|
57
|
+
|
|
58
|
+
**[Read the full map](https://0trm.github.io/gallop/map/)** ·
|
|
59
|
+
**[Read the intake algorithm](https://0trm.github.io/gallop/intake/)**
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# Claude Code
|
|
65
|
+
/plugin marketplace add 0trm/gallop
|
|
66
|
+
/plugin install gallop@gallop
|
|
67
|
+
|
|
68
|
+
# any other agent, or none: a skill is a directory of markdown
|
|
69
|
+
cp -r gallop/skills/reading-experiments .claude/skills/
|
|
70
|
+
|
|
71
|
+
# the package; the import name is gallop
|
|
72
|
+
pip install gallop-pds
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## The five-minute path
|
|
76
|
+
|
|
77
|
+
One command, one synthetic dataset, every check once:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
python -m gallop.examples.quickstart
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Fifteen seconds of runtime: an MDE, an SRM verdict, an exposure ratio, a
|
|
84
|
+
CUPED-adjusted effect with an always-valid interval, and the same effect shrunk
|
|
85
|
+
toward a seeded prior store. The full loop, from a question arriving to the
|
|
86
|
+
prior store changing on disk, is `python examples/end-to-end/run_loop.py`.
|
|
87
|
+
|
|
88
|
+
## The skills
|
|
89
|
+
|
|
90
|
+
<!-- skills-table:begin (generated by site/build.py; do not edit) -->
|
|
91
|
+
| Skill | What it decides | Reach for it when |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| [`routing-questions`](skills/routing-questions/SKILL.md) | Whether this becomes work at all, and which skill it becomes | a product, analytics, or experimentation request first arrives, when someone asks for a deep dive or a dashboard, or before opening a query editor on any question about impact, lift, or whether something worked |
|
|
94
|
+
| [`defining-metrics`](skills/defining-metrics/SKILL.md) | A metric turned into a computation, a source of truth, and a statement of how it will be gamed | defining a north-star or guardrail metric, when two dashboards disagree on the same number, when arbitrating between conflicting metric definitions, or when a readout depends on a metric nobody has validated |
|
|
95
|
+
| [`designing-experiments`](skills/designing-experiments/SKILL.md) | The four choices that cannot be repaired after launch, with the MDE from the prior store | planning, powering, or pre-registering an experiment, when deciding whether a question is testable at the available traffic, or when a feature is about to ship without a flag |
|
|
96
|
+
| [`reading-experiments`](skills/reading-experiments/SKILL.md) | Whether the result is a result: SRM, exposure, the sequential bound, CUPED, shrinkage | analysing or reviewing A/B test results, when a test looks like a winner, when someone reports a lift, or when deciding whether to ship on an experiment readout |
|
|
97
|
+
| [`choosing-causal-designs`](skills/choosing-causal-designs/SKILL.md) | The method that matches how assignment happened, and the exit that says there is no comparison group | measuring the impact of something already rolled out, a launch, a migration, a pricing change, or a campaign that reached everyone at once |
|
|
98
|
+
| [`automating-decisions`](skills/automating-decisions/SKILL.md) | Whether a repeated decision belongs to a model, validated out of time, and the holdout that measures its impact | someone asks for a churn, propensity, LTV, scoring, forecasting, uplift, recommendation or allocation model, when a model's offline accuracy is offered as evidence that something worked, or when deciding who gets an offer, a discount or an intervention |
|
|
99
|
+
| [`writing-readouts`](skills/writing-readouts/SKILL.md) | The decision rule first, the result last; the belief filed where the next question starts | a test finishes, when documenting a shipped or killed decision, when writing up a null result or a rollback, or when a question needs an entry someone can find in a year |
|
|
100
|
+
<!-- skills-table:end -->
|
|
101
|
+
|
|
102
|
+
## What this is not
|
|
103
|
+
|
|
104
|
+
Not an experimentation platform. It does not assign traffic, hold flags, or
|
|
105
|
+
replace your warehouse. It assumes those exist and writes the part that decides
|
|
106
|
+
whether the number they produced is true.
|
|
107
|
+
|
|
108
|
+
## More
|
|
109
|
+
|
|
110
|
+
The site renders the skills and the two arguments:
|
|
111
|
+
[the map](https://0trm.github.io/gallop/map/) ·
|
|
112
|
+
[the intake](https://0trm.github.io/gallop/intake/) ·
|
|
113
|
+
[the theory layer](https://0trm.github.io/gallop/theory/) ·
|
|
114
|
+
[install](https://0trm.github.io/gallop/install/)
|
|
115
|
+
|
|
116
|
+
## Licence
|
|
117
|
+
|
|
118
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<p align="right">
|
|
2
|
+
<img src="site/assets/mark.svg" width="300" alt="Three riders carried on one galloping horse">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# gallop
|
|
6
|
+
|
|
7
|
+
**Routes a product question to the method it deserves, then runs that method with checks in place.**
|
|
8
|
+
|
|
9
|
+
A product data science system, packaged as agent skills. Most questions leave at
|
|
10
|
+
intake without an analysis. The ones that stay get the method that matches how
|
|
11
|
+
treatment was assigned, and the checks that decide whether the result is a
|
|
12
|
+
result. What they teach is written back, so the next question starts smaller.
|
|
13
|
+
|
|
14
|
+
## The map
|
|
15
|
+
|
|
16
|
+
A question enters at the left and leaves as a decision. Measurement is a
|
|
17
|
+
foundation rather than a phase, because what ships changes the data. Theory is a
|
|
18
|
+
ceiling rather than a report, because what you learn has to outlive the test that
|
|
19
|
+
produced it.
|
|
20
|
+
|
|
21
|
+
Three method buckets, a floor underneath them, a memory above them, and a layer
|
|
22
|
+
of judgment on top. Each bucket has its own question, its own output, and its own
|
|
23
|
+
failure mode:
|
|
24
|
+
|
|
25
|
+
| Bucket | Asks | Hands back | Fails by |
|
|
26
|
+
|---|---|---|---|
|
|
27
|
+
| Description | What happened? | A hypothesis | Being mistaken for causation |
|
|
28
|
+
| Causation | Did this change cause that? | An effect size | An invalid comparison group |
|
|
29
|
+
| Prediction | What will happen? Who gets what? | A forecast, a ranking, an allocation | Breaking the moment you intervene |
|
|
30
|
+
|
|
31
|
+
Experimentation and causal inference share all three, so they are one bucket
|
|
32
|
+
separated only by who did the randomising: you, or the world.
|
|
33
|
+
|
|
34
|
+
**[Read the full map](https://0trm.github.io/gallop/map/)** ·
|
|
35
|
+
**[Read the intake algorithm](https://0trm.github.io/gallop/intake/)**
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Claude Code
|
|
41
|
+
/plugin marketplace add 0trm/gallop
|
|
42
|
+
/plugin install gallop@gallop
|
|
43
|
+
|
|
44
|
+
# any other agent, or none: a skill is a directory of markdown
|
|
45
|
+
cp -r gallop/skills/reading-experiments .claude/skills/
|
|
46
|
+
|
|
47
|
+
# the package; the import name is gallop
|
|
48
|
+
pip install gallop-pds
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## The five-minute path
|
|
52
|
+
|
|
53
|
+
One command, one synthetic dataset, every check once:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python -m gallop.examples.quickstart
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Fifteen seconds of runtime: an MDE, an SRM verdict, an exposure ratio, a
|
|
60
|
+
CUPED-adjusted effect with an always-valid interval, and the same effect shrunk
|
|
61
|
+
toward a seeded prior store. The full loop, from a question arriving to the
|
|
62
|
+
prior store changing on disk, is `python examples/end-to-end/run_loop.py`.
|
|
63
|
+
|
|
64
|
+
## The skills
|
|
65
|
+
|
|
66
|
+
<!-- skills-table:begin (generated by site/build.py; do not edit) -->
|
|
67
|
+
| Skill | What it decides | Reach for it when |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| [`routing-questions`](skills/routing-questions/SKILL.md) | Whether this becomes work at all, and which skill it becomes | a product, analytics, or experimentation request first arrives, when someone asks for a deep dive or a dashboard, or before opening a query editor on any question about impact, lift, or whether something worked |
|
|
70
|
+
| [`defining-metrics`](skills/defining-metrics/SKILL.md) | A metric turned into a computation, a source of truth, and a statement of how it will be gamed | defining a north-star or guardrail metric, when two dashboards disagree on the same number, when arbitrating between conflicting metric definitions, or when a readout depends on a metric nobody has validated |
|
|
71
|
+
| [`designing-experiments`](skills/designing-experiments/SKILL.md) | The four choices that cannot be repaired after launch, with the MDE from the prior store | planning, powering, or pre-registering an experiment, when deciding whether a question is testable at the available traffic, or when a feature is about to ship without a flag |
|
|
72
|
+
| [`reading-experiments`](skills/reading-experiments/SKILL.md) | Whether the result is a result: SRM, exposure, the sequential bound, CUPED, shrinkage | analysing or reviewing A/B test results, when a test looks like a winner, when someone reports a lift, or when deciding whether to ship on an experiment readout |
|
|
73
|
+
| [`choosing-causal-designs`](skills/choosing-causal-designs/SKILL.md) | The method that matches how assignment happened, and the exit that says there is no comparison group | measuring the impact of something already rolled out, a launch, a migration, a pricing change, or a campaign that reached everyone at once |
|
|
74
|
+
| [`automating-decisions`](skills/automating-decisions/SKILL.md) | Whether a repeated decision belongs to a model, validated out of time, and the holdout that measures its impact | someone asks for a churn, propensity, LTV, scoring, forecasting, uplift, recommendation or allocation model, when a model's offline accuracy is offered as evidence that something worked, or when deciding who gets an offer, a discount or an intervention |
|
|
75
|
+
| [`writing-readouts`](skills/writing-readouts/SKILL.md) | The decision rule first, the result last; the belief filed where the next question starts | a test finishes, when documenting a shipped or killed decision, when writing up a null result or a rollback, or when a question needs an entry someone can find in a year |
|
|
76
|
+
<!-- skills-table:end -->
|
|
77
|
+
|
|
78
|
+
## What this is not
|
|
79
|
+
|
|
80
|
+
Not an experimentation platform. It does not assign traffic, hold flags, or
|
|
81
|
+
replace your warehouse. It assumes those exist and writes the part that decides
|
|
82
|
+
whether the number they produced is true.
|
|
83
|
+
|
|
84
|
+
## More
|
|
85
|
+
|
|
86
|
+
The site renders the skills and the two arguments:
|
|
87
|
+
[the map](https://0trm.github.io/gallop/map/) ·
|
|
88
|
+
[the intake](https://0trm.github.io/gallop/intake/) ·
|
|
89
|
+
[the theory layer](https://0trm.github.io/gallop/theory/) ·
|
|
90
|
+
[install](https://0trm.github.io/gallop/install/)
|
|
91
|
+
|
|
92
|
+
## Licence
|
|
93
|
+
|
|
94
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gallop-pds"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Product data science checks: power from priors, SRM, CUPED, always-valid inference, empirical Bayes shrinkage, out-of-time model validation, and the prior store they read."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "0trm" }]
|
|
13
|
+
dependencies = ["numpy>=1.26", "pandas>=2.0", "scipy>=1.11"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://0trm.github.io/gallop/"
|
|
24
|
+
Repository = "https://github.com/0trm/gallop"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest>=8", "ruff>=0.5"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.package-data]
|
|
33
|
+
gallop = ["templates/*.json"]
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
line-length = 100
|
|
37
|
+
target-version = "py311"
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Product data science checks, thin by design.
|
|
2
|
+
|
|
3
|
+
Seven modules, each backing one skill:
|
|
4
|
+
|
|
5
|
+
power MDE, sample size and duration, two-proportion and continuous
|
|
6
|
+
trust sample ratio mismatch and exposure-versus-eligibility
|
|
7
|
+
variance CUPED against a pre-period covariate
|
|
8
|
+
sequential always-valid confidence sequences and group-sequential bounds
|
|
9
|
+
shrink empirical Bayes shrinkage toward the prior store
|
|
10
|
+
priors the prior store and the metric registry on disk
|
|
11
|
+
validate out-of-time validation, lift over the base rate, calibration, Qini, MASE
|
|
12
|
+
|
|
13
|
+
Every function takes and returns arrays or DataFrames, never a database
|
|
14
|
+
connection. Each module runs as a script: python -m gallop.<module> --help
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
__version__ = "0.2.0"
|
|
File without changes
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""The five-minute path: every check once, against synthetic data.
|
|
2
|
+
|
|
3
|
+
One simulated experiment on a 12% activation rate with a real +0.35pp effect,
|
|
4
|
+
plus a seeded prior store. No configuration, no credentials, no warehouse.
|
|
5
|
+
|
|
6
|
+
Run: python -m gallop.examples.quickstart
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from gallop import power, priors, sequential, shrink, trust, variance
|
|
17
|
+
|
|
18
|
+
RULE = "=" * 74
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main():
|
|
22
|
+
rng = np.random.default_rng(7)
|
|
23
|
+
|
|
24
|
+
# -- the experiment: user-level activation with a pre-period covariate
|
|
25
|
+
n = 40_000
|
|
26
|
+
true_effect = 0.0035
|
|
27
|
+
x = rng.beta(2, 14, 2 * n) # pre-period activation propensity
|
|
28
|
+
arm = np.array(["control"] * n + ["treatment"] * n)
|
|
29
|
+
p_unit = np.clip(x + (arm == "treatment") * true_effect, 0, 1)
|
|
30
|
+
y = rng.binomial(1, p_unit).astype(float)
|
|
31
|
+
|
|
32
|
+
print(RULE)
|
|
33
|
+
print("gallop quickstart: one experiment through every check")
|
|
34
|
+
print(RULE)
|
|
35
|
+
|
|
36
|
+
print("\n1 · Size it before running it (gallop.power)")
|
|
37
|
+
m = power.mde(n, baseline_rate=0.125)
|
|
38
|
+
print(f" at n={n:,} per arm on a 12.5% rate, the MDE is {m * 100:.2f}pp;")
|
|
39
|
+
print(f" detecting {true_effect * 100:.2f}pp instead would need "
|
|
40
|
+
f"{power.sample_size(true_effect, baseline_rate=0.125):,.0f} per arm")
|
|
41
|
+
|
|
42
|
+
print("\n2 · The trust gate (gallop.trust)")
|
|
43
|
+
assigned = {"control": n, "treatment": n - int(rng.integers(0, 120))}
|
|
44
|
+
exposed = {k: int(v * 0.97) for k, v in assigned.items()}
|
|
45
|
+
s = trust.srm(assigned)
|
|
46
|
+
print(f" SRM: chi2 {s['chi2']:.2f} p {s['p']:.3f} -> {s['verdict']}")
|
|
47
|
+
e = trust.exposure_check(assigned, exposed)
|
|
48
|
+
print(f" exposure: pooled rate {e['pooled_rate']:.2%} -> {e['verdict']}")
|
|
49
|
+
|
|
50
|
+
print("\n3 · The effect, with CUPED (gallop.variance)")
|
|
51
|
+
r = variance.cuped(y, x, arm, control="control")
|
|
52
|
+
print(f" raw {r['effect_raw'] * 100:+.3f}pp se {r['se_raw'] * 100:.3f}pp")
|
|
53
|
+
print(f" cuped {r['effect_adjusted'] * 100:+.3f}pp se {r['se_adjusted'] * 100:.3f}pp"
|
|
54
|
+
f" variance reduction {r['variance_reduction']:.0%}")
|
|
55
|
+
|
|
56
|
+
print("\n4 · An interval that survives peeking (gallop.sequential)")
|
|
57
|
+
sd_adj = r["se_adjusted"] * np.sqrt(n / 2)
|
|
58
|
+
av = sequential.always_valid_ci(0.0, r["effect_adjusted"], sd_adj, n, tau2=1e-4)
|
|
59
|
+
lo, hi = av["ci"]
|
|
60
|
+
print(f" always-valid 95% CI [{lo * 100:+.3f}pp, {hi * 100:+.3f}pp]"
|
|
61
|
+
f" boundary |z| {av['bound_z']:.2f} (vs 1.96 fixed)")
|
|
62
|
+
print(f" significant under continuous monitoring: {av['significant']}")
|
|
63
|
+
|
|
64
|
+
print("\n5 · Shrunk toward what this metric has done before (gallop.shrink + priors)")
|
|
65
|
+
with tempfile.TemporaryDirectory() as td:
|
|
66
|
+
store_path = Path(td) / "priors.jsonl"
|
|
67
|
+
past = [0.0009, -0.0004, 0.0021, 0.0013, -0.0011, 0.0028, 0.0006, 0.0016]
|
|
68
|
+
for i, eff in enumerate(past):
|
|
69
|
+
priors.append(store_path, {
|
|
70
|
+
"id": f"2026-{i + 1:02d}-activation-test", "metric": "activation_rate",
|
|
71
|
+
"date": f"2026-{i + 1:02d}-15", "design": "experiment", "effect": eff,
|
|
72
|
+
"unit": "pp", "se": 0.0011, "n_per_arm": 35_000, "decision": "ship" if eff > 0.001 else "no-ship",
|
|
73
|
+
})
|
|
74
|
+
store = priors.read(store_path)
|
|
75
|
+
sh = shrink.from_store(r["effect_adjusted"], r["se_adjusted"], store,
|
|
76
|
+
"activation_rate", "pp")
|
|
77
|
+
print(f" prior from {sh['n_priors']} readouts: mu {sh['mu'] * 100:+.3f}pp"
|
|
78
|
+
f" tau {np.sqrt(sh['tau2']) * 100:.3f}pp")
|
|
79
|
+
print(f" observed {sh['effect'] * 100:+.3f}pp -> shrunk {sh['effect_shrunk'] * 100:+.3f}pp"
|
|
80
|
+
f" (weight on data {sh['weight_on_data']:.2f})")
|
|
81
|
+
|
|
82
|
+
print(f"\n{RULE}")
|
|
83
|
+
print(f"true simulated effect: {true_effect * 100:+.3f}pp. The shrunk estimate is the")
|
|
84
|
+
print("one to write back to the store; the raw one is the winner's curse waiting.")
|
|
85
|
+
print(RULE)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
main()
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Power, MDE, sample size and duration for a two-arm test.
|
|
2
|
+
|
|
3
|
+
One family of closed forms for a two-sample difference in means. A proportion
|
|
4
|
+
metric is the same formula with sd = sqrt(p(1-p)), so every function takes
|
|
5
|
+
either `sd` (continuous) or `baseline_rate` (proportion), never both.
|
|
6
|
+
|
|
7
|
+
With the defaults (alpha 0.05 two-sided, power 0.80) the MDE reduces to the
|
|
8
|
+
planning rule of thumb 2.8 * sqrt(2 p (1-p) / n).
|
|
9
|
+
|
|
10
|
+
The closed form assumes the analysis unit is the randomisation unit, and it
|
|
11
|
+
lies for ratio metrics, heavy tails and capped metrics. Sizing from it when
|
|
12
|
+
those assumptions fail is a design question, not an arithmetic one; see the
|
|
13
|
+
designing-experiments skill.
|
|
14
|
+
|
|
15
|
+
Run: python -m gallop.power mde --n 5000 --baseline-rate 0.10
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
from scipy import stats
|
|
24
|
+
|
|
25
|
+
# %% ------------------------------------------------------------- closed forms
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _sd(sd: float | None, baseline_rate: float | None) -> float:
|
|
29
|
+
if (sd is None) == (baseline_rate is None):
|
|
30
|
+
raise ValueError("give exactly one of sd (continuous) or baseline_rate (proportion)")
|
|
31
|
+
if baseline_rate is not None:
|
|
32
|
+
if not 0 < baseline_rate < 1:
|
|
33
|
+
raise ValueError("baseline_rate must be strictly between 0 and 1")
|
|
34
|
+
return float(np.sqrt(baseline_rate * (1 - baseline_rate)))
|
|
35
|
+
return float(sd)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def mde(n_per_arm, *, sd=None, baseline_rate=None, power=0.80, alpha=0.05):
|
|
39
|
+
"""Smallest absolute effect detectable at the stated power. Same unit as the metric."""
|
|
40
|
+
s = _sd(sd, baseline_rate)
|
|
41
|
+
z_alpha = stats.norm.ppf(1 - alpha / 2)
|
|
42
|
+
z_beta = stats.norm.ppf(power)
|
|
43
|
+
return float((z_alpha + z_beta) * s * np.sqrt(2.0 / n_per_arm))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def sample_size(effect, *, sd=None, baseline_rate=None, power=0.80, alpha=0.05):
|
|
47
|
+
"""Units per arm needed to detect an absolute `effect`. Inverse of mde()."""
|
|
48
|
+
s = _sd(sd, baseline_rate)
|
|
49
|
+
z_alpha = stats.norm.ppf(1 - alpha / 2)
|
|
50
|
+
z_beta = stats.norm.ppf(power)
|
|
51
|
+
return float(2 * ((z_alpha + z_beta) * s / abs(effect)) ** 2)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def power_at(n_per_arm, effect, *, sd=None, baseline_rate=None, alpha=0.05):
|
|
55
|
+
"""Power of a two-sided test at the given n and absolute effect."""
|
|
56
|
+
s = _sd(sd, baseline_rate)
|
|
57
|
+
se = s * np.sqrt(2.0 / n_per_arm)
|
|
58
|
+
z_alpha = stats.norm.ppf(1 - alpha / 2)
|
|
59
|
+
z = abs(effect) / se
|
|
60
|
+
return float(stats.norm.sf(z_alpha - z) + stats.norm.cdf(-z_alpha - z))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def duration(effect, units_per_day, *, sd=None, baseline_rate=None, power=0.80,
|
|
64
|
+
alpha=0.05, arms=2, eligible_share=1.0):
|
|
65
|
+
"""Days to reach the required sample, given daily eligible traffic.
|
|
66
|
+
|
|
67
|
+
`units_per_day` is total new units entering the experiment surface per day;
|
|
68
|
+
`eligible_share` is the fraction that actually enters assignment.
|
|
69
|
+
"""
|
|
70
|
+
n = sample_size(effect, sd=sd, baseline_rate=baseline_rate, power=power, alpha=alpha)
|
|
71
|
+
daily = units_per_day * eligible_share
|
|
72
|
+
if daily <= 0:
|
|
73
|
+
raise ValueError("units_per_day * eligible_share must be positive")
|
|
74
|
+
return float(arms * n / daily)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# %% --------------------------------------------------------------------- cli
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main(argv=None):
|
|
81
|
+
p = argparse.ArgumentParser(prog="gallop.power", description=__doc__.splitlines()[0])
|
|
82
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
83
|
+
|
|
84
|
+
def common(sp, effect=False, n=False):
|
|
85
|
+
if effect:
|
|
86
|
+
sp.add_argument("--effect", type=float, required=True, help="absolute effect")
|
|
87
|
+
if n:
|
|
88
|
+
sp.add_argument("--n", type=float, required=True, help="units per arm")
|
|
89
|
+
sp.add_argument("--sd", type=float)
|
|
90
|
+
sp.add_argument("--baseline-rate", type=float)
|
|
91
|
+
sp.add_argument("--power", type=float, default=0.80)
|
|
92
|
+
sp.add_argument("--alpha", type=float, default=0.05)
|
|
93
|
+
|
|
94
|
+
common(sub.add_parser("mde", help="smallest detectable absolute effect"), n=True)
|
|
95
|
+
common(sub.add_parser("n", help="units per arm for an effect"), effect=True)
|
|
96
|
+
sp = sub.add_parser("duration", help="days to reach the required sample")
|
|
97
|
+
common(sp, effect=True)
|
|
98
|
+
sp.add_argument("--units-per-day", type=float, required=True)
|
|
99
|
+
sp.add_argument("--eligible-share", type=float, default=1.0)
|
|
100
|
+
sp.add_argument("--arms", type=int, default=2)
|
|
101
|
+
common(sub.add_parser("power", help="power at a given n and effect"), effect=True, n=True)
|
|
102
|
+
|
|
103
|
+
a = p.parse_args(argv)
|
|
104
|
+
kw = {"sd": a.sd, "baseline_rate": a.baseline_rate, "alpha": a.alpha}
|
|
105
|
+
if a.cmd == "mde":
|
|
106
|
+
print(f"mde (absolute): {mde(a.n, power=a.power, **kw):.6f}")
|
|
107
|
+
elif a.cmd == "n":
|
|
108
|
+
print(f"n per arm: {sample_size(a.effect, power=a.power, **kw):,.0f}")
|
|
109
|
+
elif a.cmd == "duration":
|
|
110
|
+
d = duration(a.effect, a.units_per_day, power=a.power, arms=a.arms,
|
|
111
|
+
eligible_share=a.eligible_share, **kw)
|
|
112
|
+
print(f"days: {d:,.1f}")
|
|
113
|
+
elif a.cmd == "power":
|
|
114
|
+
print(f"power: {power_at(a.n, a.effect, **kw):.4f}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
main()
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""The prior store and the metric registry on disk.
|
|
2
|
+
|
|
3
|
+
Both are append-friendly JSONL, one JSON object per line, validated on write
|
|
4
|
+
against the schemas in gallop/templates/. JSONL because the store must be
|
|
5
|
+
reviewable in a pull request; a store nobody can diff goes stale, which is the
|
|
6
|
+
failure mode the theory layer exists to prevent.
|
|
7
|
+
|
|
8
|
+
The store is a log, windowed on read. A correction is a new record carrying
|
|
9
|
+
`supersedes`, never an edit. `expires_on` names the event that would
|
|
10
|
+
invalidate the entry rather than a date somebody has to remember.
|
|
11
|
+
|
|
12
|
+
Validation is done here in about thirty lines (required keys, types, enums,
|
|
13
|
+
no unknown keys) rather than by the jsonschema package, so the runtime
|
|
14
|
+
dependencies stay at numpy, pandas, scipy. The schema files remain the
|
|
15
|
+
published contract for anything else that writes to the store.
|
|
16
|
+
|
|
17
|
+
Run: python -m gallop.priors read --store priors.jsonl --metric activation_rate
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
from importlib import resources
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import pandas as pd
|
|
29
|
+
|
|
30
|
+
# %% -------------------------------------------------------------- validation
|
|
31
|
+
|
|
32
|
+
_TYPES = {"string": str, "number": (int, float), "integer": int, "object": dict}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _schema(name):
|
|
36
|
+
with resources.files("gallop.templates").joinpath(name).open() as f:
|
|
37
|
+
return json.load(f)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def validate(record, schema_name="prior-store.schema.json"):
|
|
41
|
+
"""Check a dict against one of the packaged schemas. Raises ValueError."""
|
|
42
|
+
sch = _schema(schema_name)
|
|
43
|
+
props = sch["properties"]
|
|
44
|
+
errors = []
|
|
45
|
+
for key in sch["required"]:
|
|
46
|
+
if key not in record:
|
|
47
|
+
errors.append(f"missing required field {key!r}")
|
|
48
|
+
for key, value in record.items():
|
|
49
|
+
if key not in props:
|
|
50
|
+
errors.append(f"unknown field {key!r}")
|
|
51
|
+
continue
|
|
52
|
+
spec = props[key]
|
|
53
|
+
expected = _TYPES[spec["type"]]
|
|
54
|
+
if not isinstance(value, expected) or isinstance(value, bool):
|
|
55
|
+
errors.append(f"{key!r} must be {spec['type']}, got {type(value).__name__}")
|
|
56
|
+
continue
|
|
57
|
+
if "enum" in spec and value not in spec["enum"]:
|
|
58
|
+
errors.append(f"{key!r} must be one of {spec['enum']}, got {value!r}")
|
|
59
|
+
if "pattern" in spec and not re.fullmatch(spec["pattern"], value):
|
|
60
|
+
errors.append(f"{key!r} must match {spec['pattern']}, got {value!r}")
|
|
61
|
+
if errors:
|
|
62
|
+
raise ValueError(f"invalid {sch['title']}: " + "; ".join(errors))
|
|
63
|
+
return record
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# %% ------------------------------------------------------------- prior store
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def append(path, record):
|
|
70
|
+
"""Validate a record and append it to the store. Returns the record."""
|
|
71
|
+
validate(record, "prior-store.schema.json")
|
|
72
|
+
p = Path(path)
|
|
73
|
+
existing = {r["id"] for _, r in _iter_records(p)} if p.exists() else set()
|
|
74
|
+
if record["id"] in existing:
|
|
75
|
+
raise ValueError(f"id {record['id']!r} already in {path}; corrections use supersedes")
|
|
76
|
+
with p.open("a") as f:
|
|
77
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
78
|
+
return record
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _iter_records(p):
|
|
82
|
+
with Path(p).open() as f:
|
|
83
|
+
for i, line in enumerate(f, 1):
|
|
84
|
+
if line.strip():
|
|
85
|
+
yield i, json.loads(line)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def read(path, metric=None, window=100):
|
|
89
|
+
"""The store as a DataFrame: superseded records dropped, windowed on read.
|
|
90
|
+
|
|
91
|
+
`window` keeps the most recent records per metric, by date then file
|
|
92
|
+
order. Every record is validated; a malformed line fails loudly with its
|
|
93
|
+
line number rather than flowing into a shrinkage estimate.
|
|
94
|
+
"""
|
|
95
|
+
rows = []
|
|
96
|
+
for i, rec in _iter_records(path):
|
|
97
|
+
try:
|
|
98
|
+
validate(rec, "prior-store.schema.json")
|
|
99
|
+
except ValueError as e:
|
|
100
|
+
raise ValueError(f"{path}:{i}: {e}") from None
|
|
101
|
+
rows.append(rec)
|
|
102
|
+
df = pd.DataFrame(rows)
|
|
103
|
+
if df.empty:
|
|
104
|
+
return df
|
|
105
|
+
superseded = set(df["supersedes"].dropna()) if "supersedes" in df.columns else set()
|
|
106
|
+
df = df[~df["id"].isin(superseded)]
|
|
107
|
+
if metric is not None:
|
|
108
|
+
df = df[df["metric"] == metric]
|
|
109
|
+
df = df.assign(_order=range(len(df))).sort_values(["metric", "date", "_order"])
|
|
110
|
+
df = df.groupby("metric", group_keys=False).tail(window).drop(columns="_order")
|
|
111
|
+
return df.reset_index(drop=True)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# %% ---------------------------------------------------------------- registry
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def read_registry(path, status=None):
|
|
118
|
+
"""The metric registry as a DataFrame, optionally filtered by status."""
|
|
119
|
+
rows = []
|
|
120
|
+
for i, rec in _iter_records(path):
|
|
121
|
+
try:
|
|
122
|
+
validate(rec, "metric-registry.schema.json")
|
|
123
|
+
except ValueError as e:
|
|
124
|
+
raise ValueError(f"{path}:{i}: {e}") from None
|
|
125
|
+
rows.append(rec)
|
|
126
|
+
df = pd.DataFrame(rows)
|
|
127
|
+
if status is not None and not df.empty:
|
|
128
|
+
df = df[df["status"] == status].reset_index(drop=True)
|
|
129
|
+
return df
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# %% --------------------------------------------------------------------- cli
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main(argv=None):
|
|
136
|
+
p = argparse.ArgumentParser(prog="gallop.priors", description=__doc__.splitlines()[0])
|
|
137
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
138
|
+
sp = sub.add_parser("append", help="validate and append one record")
|
|
139
|
+
sp.add_argument("--store", required=True)
|
|
140
|
+
sp.add_argument("--json", required=True, help="the record as a JSON object")
|
|
141
|
+
sp = sub.add_parser("read", help="print the windowed store")
|
|
142
|
+
sp.add_argument("--store", required=True)
|
|
143
|
+
sp.add_argument("--metric")
|
|
144
|
+
sp.add_argument("--window", type=int, default=100)
|
|
145
|
+
sp = sub.add_parser("registry", help="print the metric registry")
|
|
146
|
+
sp.add_argument("--registry", required=True)
|
|
147
|
+
sp.add_argument("--status")
|
|
148
|
+
|
|
149
|
+
a = p.parse_args(argv)
|
|
150
|
+
if a.cmd == "append":
|
|
151
|
+
rec = append(a.store, json.loads(a.json))
|
|
152
|
+
print(f"appended {rec['id']} to {a.store}")
|
|
153
|
+
elif a.cmd == "read":
|
|
154
|
+
print(read(a.store, metric=a.metric, window=a.window).to_string(index=False))
|
|
155
|
+
else:
|
|
156
|
+
print(read_registry(a.registry, status=a.status).to_string(index=False))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
main()
|