artest-stats 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.
- artest_stats-0.2.0/LICENSE +202 -0
- artest_stats-0.2.0/PKG-INFO +20 -0
- artest_stats-0.2.0/README.md +8 -0
- artest_stats-0.2.0/pyproject.toml +22 -0
- artest_stats-0.2.0/pyproject.toml.orig +22 -0
- artest_stats-0.2.0/src/artest/stats/__init__.py +46 -0
- artest_stats-0.2.0/src/artest/stats/base.py +16 -0
- artest_stats-0.2.0/src/artest/stats/power.py +125 -0
- artest_stats-0.2.0/src/artest/stats/proportions.py +63 -0
- artest_stats-0.2.0/src/artest/stats/py.typed +0 -0
- artest_stats-0.2.0/src/artest/stats/segments.py +153 -0
- artest_stats-0.2.0/src/artest/stats/srm.py +80 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: artest-stats
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Experiment statistics: proportions, segment comparison, power analysis and the SRM check.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: pydantic>=2.14
|
|
8
|
+
Requires-Dist: scipy>=1.16
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Project-URL: Repository, https://gitlab.com/Arta_Shoja/artest
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# artest-stats
|
|
14
|
+
|
|
15
|
+
Experiment statistics: proportions, segment comparison, power analysis and the SRM check.
|
|
16
|
+
|
|
17
|
+
Pure numerical functions with no I/O and no language models. Every number Artest works out for itself
|
|
18
|
+
to drive a decision comes from here; an experiment's verdict is GrowthBook's.
|
|
19
|
+
|
|
20
|
+
Import as `artest.stats`. See the [decision record](https://gitlab.com/Arta_Shoja/artest/-/blob/main/docs/adr/0018-statistics.md).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# artest-stats
|
|
2
|
+
|
|
3
|
+
Experiment statistics: proportions, segment comparison, power analysis and the SRM check.
|
|
4
|
+
|
|
5
|
+
Pure numerical functions with no I/O and no language models. Every number Artest works out for itself
|
|
6
|
+
to drive a decision comes from here; an experiment's verdict is GrowthBook's.
|
|
7
|
+
|
|
8
|
+
Import as `artest.stats`. See the [decision record](https://gitlab.com/Arta_Shoja/artest/-/blob/main/docs/adr/0018-statistics.md).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "artest-stats"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Experiment statistics: proportions, segment comparison, power analysis and the SRM check."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"pydantic>=2.14",
|
|
11
|
+
"scipy>=1.16",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Repository = "https://gitlab.com/Arta_Shoja/artest"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.13,<0.14"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[tool.uv.build-backend]
|
|
22
|
+
module-name = "artest.stats"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "artest-stats"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Experiment statistics: proportions, segment comparison, power analysis and the SRM check."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"pydantic>=2.14",
|
|
11
|
+
"scipy>=1.16",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Repository = "https://gitlab.com/Arta_Shoja/artest"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.13,<0.14"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[tool.uv.build-backend]
|
|
22
|
+
module-name = "artest.stats"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Experiment statistics: proportions, segment comparison, power analysis and the SRM check.
|
|
2
|
+
|
|
3
|
+
Pure numerical functions with no I/O and no language models. Every number that drives a decision
|
|
4
|
+
in Artest comes from here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from artest.stats.power import (
|
|
8
|
+
SampleSize,
|
|
9
|
+
SampleSizeRange,
|
|
10
|
+
cohens_h,
|
|
11
|
+
duration_days,
|
|
12
|
+
sample_size,
|
|
13
|
+
sample_size_for_lift,
|
|
14
|
+
)
|
|
15
|
+
from artest.stats.proportions import Proportion, step_rates, wilson_interval
|
|
16
|
+
from artest.stats.segments import (
|
|
17
|
+
Counts,
|
|
18
|
+
SegmentComparison,
|
|
19
|
+
StepCounts,
|
|
20
|
+
TwoProportionTest,
|
|
21
|
+
benjamini_hochberg,
|
|
22
|
+
compare_segments,
|
|
23
|
+
two_proportion_test,
|
|
24
|
+
)
|
|
25
|
+
from artest.stats.srm import SrmCheck, check_srm
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Counts",
|
|
29
|
+
"Proportion",
|
|
30
|
+
"SampleSize",
|
|
31
|
+
"SampleSizeRange",
|
|
32
|
+
"SegmentComparison",
|
|
33
|
+
"SrmCheck",
|
|
34
|
+
"StepCounts",
|
|
35
|
+
"TwoProportionTest",
|
|
36
|
+
"benjamini_hochberg",
|
|
37
|
+
"check_srm",
|
|
38
|
+
"cohens_h",
|
|
39
|
+
"compare_segments",
|
|
40
|
+
"duration_days",
|
|
41
|
+
"sample_size",
|
|
42
|
+
"sample_size_for_lift",
|
|
43
|
+
"step_rates",
|
|
44
|
+
"two_proportion_test",
|
|
45
|
+
"wilson_interval",
|
|
46
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from pydantic import BaseModel, ConfigDict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class StatsModel(BaseModel):
|
|
5
|
+
"""Base for every result.
|
|
6
|
+
|
|
7
|
+
Immutable, and refuses NaN and infinity, so a division by zero fails where it happens instead of
|
|
8
|
+
reaching a decision as a number.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
model_config = ConfigDict(
|
|
12
|
+
frozen=True,
|
|
13
|
+
extra="forbid",
|
|
14
|
+
allow_inf_nan=False,
|
|
15
|
+
json_schema_serialization_defaults_required=True,
|
|
16
|
+
)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""How many users a test needs, and how long they take to arrive."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from fractions import Fraction
|
|
5
|
+
|
|
6
|
+
from pydantic import Field
|
|
7
|
+
from scipy.stats import norm
|
|
8
|
+
|
|
9
|
+
from artest.stats.base import StatsModel
|
|
10
|
+
from artest.stats.proportions import Proportion
|
|
11
|
+
|
|
12
|
+
# Below this expected count the normal approximation breaks down (Cochran 1954), as for the segment test.
|
|
13
|
+
_MIN_EXPECTED_COUNT = 5
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SampleSize(StatsModel):
|
|
17
|
+
"""The users each arm needs to detect a move from `baseline` to `target`, if the move is really that big.
|
|
18
|
+
|
|
19
|
+
Both rates are taken as given. `target` is what the test is sized to catch, not what a change will do: a
|
|
20
|
+
smaller real effect is missed more often than `power` says. A baseline that's itself an estimate moves
|
|
21
|
+
this number with it, about as one over the rate when the rate is small: `sample_size_for_lift` says how
|
|
22
|
+
far.
|
|
23
|
+
|
|
24
|
+
`small_sample` means an arm expects too few successes or failures for the normal approximation behind
|
|
25
|
+
`per_arm`. The exact test that runs at such counts needs more users: in the cases worked, a size flagged
|
|
26
|
+
so reached 52% to 79% power where 80% was asked. Read it as a rough lower bound.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
baseline: float = Field(gt=0, lt=1)
|
|
30
|
+
target: float = Field(gt=0, lt=1)
|
|
31
|
+
effect_size: float = Field(
|
|
32
|
+
gt=0, description="Cohen's h, the distance between the rates on the arcsine scale"
|
|
33
|
+
)
|
|
34
|
+
alpha: float = Field(gt=0, lt=1)
|
|
35
|
+
power: float = Field(gt=0, lt=1)
|
|
36
|
+
per_arm: int = Field(ge=1)
|
|
37
|
+
small_sample: bool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cohens_h(rate_a: float, rate_b: float) -> float:
|
|
41
|
+
"""Cohen's effect size for two proportions: how far apart they are, with the variance made even."""
|
|
42
|
+
return abs(2 * math.asin(math.sqrt(rate_a)) - 2 * math.asin(math.sqrt(rate_b)))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def sample_size(baseline: float, target: float, *, alpha: float = 0.05, power: float = 0.8) -> SampleSize:
|
|
46
|
+
"""Users per arm for a two-sided two-proportion test with equal arms (Cohen 1988, chapter 6).
|
|
47
|
+
|
|
48
|
+
A rate of 0% or 100% is refused: no variation means nothing to size a test from, and the approximation
|
|
49
|
+
says nothing there.
|
|
50
|
+
"""
|
|
51
|
+
for name, rate in (("baseline", baseline), ("target", target)):
|
|
52
|
+
if not 0 < rate < 1:
|
|
53
|
+
raise ValueError(f"the {name} rate must lie strictly between 0 and 1, got {rate}")
|
|
54
|
+
if baseline == target:
|
|
55
|
+
raise ValueError(f"the baseline and the target are both {baseline}, so there's no effect to detect")
|
|
56
|
+
if not 0 < alpha < 1:
|
|
57
|
+
raise ValueError(f"alpha must lie between 0 and 1, got {alpha}")
|
|
58
|
+
if not alpha < power < 1:
|
|
59
|
+
raise ValueError(f"power must lie between alpha ({alpha}) and 1, got {power}")
|
|
60
|
+
|
|
61
|
+
effect = cohens_h(baseline, target)
|
|
62
|
+
z = float(norm.ppf(1 - alpha / 2)) + float(norm.ppf(power))
|
|
63
|
+
per_arm = math.ceil(2 * (z / effect) ** 2)
|
|
64
|
+
rarest = min(baseline, 1 - baseline, target, 1 - target)
|
|
65
|
+
return SampleSize(
|
|
66
|
+
baseline=baseline,
|
|
67
|
+
target=target,
|
|
68
|
+
effect_size=effect,
|
|
69
|
+
alpha=alpha,
|
|
70
|
+
power=power,
|
|
71
|
+
per_arm=per_arm,
|
|
72
|
+
small_sample=per_arm * rarest < _MIN_EXPECTED_COUNT,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SampleSizeRange(StatsModel):
|
|
77
|
+
"""The size at a measured rate, and how far it moves across that rate's interval at the same lift.
|
|
78
|
+
|
|
79
|
+
The size depends on the rate, and a measured rate is uncertain: 30 of 1,000 is 2.1% to 4.3%, and at a 20%
|
|
80
|
+
lift that's anywhere from 9,663 to 19,950 users per arm around the 13,887 at 3%. `fewest` and `most` cover
|
|
81
|
+
the interval's ends. An end whose lift would pass 100% isn't counted, as no test could reach it.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
likely: SampleSize
|
|
85
|
+
fewest: int = Field(ge=1)
|
|
86
|
+
most: int = Field(ge=1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def sample_size_for_lift(
|
|
90
|
+
rate: Proportion, relative_lift: float, *, alpha: float = 0.05, power: float = 0.8
|
|
91
|
+
) -> SampleSizeRange:
|
|
92
|
+
"""Users per arm to detect `relative_lift` on a measured `rate`, with the range its interval allows.
|
|
93
|
+
|
|
94
|
+
A rate of 0% or 100%, or none, sizes no test: it's refused, and so is a lift past 100%.
|
|
95
|
+
"""
|
|
96
|
+
if rate.rate is None:
|
|
97
|
+
raise ValueError("there's no measured rate to size a test from")
|
|
98
|
+
if relative_lift <= 0:
|
|
99
|
+
raise ValueError(f"the lift must be above zero, got {relative_lift}")
|
|
100
|
+
likely = sample_size(rate.rate, rate.rate * (1 + relative_lift), alpha=alpha, power=power)
|
|
101
|
+
sizes = [
|
|
102
|
+
likely.per_arm,
|
|
103
|
+
*(
|
|
104
|
+
sample_size(end, end * (1 + relative_lift), alpha=alpha, power=power).per_arm
|
|
105
|
+
for end in (rate.lower, rate.upper)
|
|
106
|
+
if end * (1 + relative_lift) < 1
|
|
107
|
+
),
|
|
108
|
+
]
|
|
109
|
+
return SampleSizeRange(likely=likely, fewest=min(sizes), most=max(sizes))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def duration_days(per_arm: int, users: int, days: int = 1, *, arms: int = 2) -> int | None:
|
|
113
|
+
"""Whole days until every arm has its users, when the `users` seen over `days` all enter the test.
|
|
114
|
+
|
|
115
|
+
None when no users came, as such a test never ends. It assumes traffic stays as it is, and says nothing
|
|
116
|
+
of weekly cycles, so run a test for whole weeks whatever this says. In whole numbers, as a rate per day
|
|
117
|
+
rounds: 300 users over 7 days fill 600 places in exactly 14 days, where floats say 15.
|
|
118
|
+
"""
|
|
119
|
+
if per_arm < 1 or arms < 1:
|
|
120
|
+
raise ValueError(f"a test needs users and arms, got {per_arm} per arm and {arms} arms")
|
|
121
|
+
if users < 0 or days < 1:
|
|
122
|
+
raise ValueError(f"traffic is users over at least a day, got {users} over {days}")
|
|
123
|
+
if users == 0:
|
|
124
|
+
return None
|
|
125
|
+
return math.ceil(Fraction(arms * per_arm * days, users))
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Rates and how far to trust them."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from itertools import pairwise
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
from scipy.stats import norm
|
|
9
|
+
|
|
10
|
+
from artest.stats.base import StatsModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Proportion(StatsModel):
|
|
14
|
+
"""How many of `trials` succeeded, with a Wilson score interval.
|
|
15
|
+
|
|
16
|
+
With no trials there's no rate to state: `rate` is None and the interval spans everything, which is
|
|
17
|
+
also the limit Wilson's formula reaches as trials fall to zero.
|
|
18
|
+
|
|
19
|
+
The interval covers sampling noise and nothing else. It doesn't say why the rate is what it is, or
|
|
20
|
+
what a change would do to it.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
successes: int = Field(ge=0)
|
|
24
|
+
trials: int = Field(ge=0)
|
|
25
|
+
rate: float | None = Field(ge=0, le=1)
|
|
26
|
+
lower: float = Field(ge=0, le=1)
|
|
27
|
+
upper: float = Field(ge=0, le=1)
|
|
28
|
+
confidence: float = Field(gt=0, lt=1)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def wilson_interval(successes: int, trials: int, *, confidence: float = 0.95) -> Proportion:
|
|
32
|
+
"""The rate `successes / trials` with its Wilson score interval (Wilson 1927).
|
|
33
|
+
|
|
34
|
+
Unlike the textbook Wald interval, it stays inside [0, 1] and keeps a real width at 0% and 100%. Across
|
|
35
|
+
rates it covers the true rate about as often as it says, even at small samples (Newcombe 1998), though a
|
|
36
|
+
95% interval dips to about 90% close to 0% and 100% (Brown, Cai and DasGupta 2001).
|
|
37
|
+
"""
|
|
38
|
+
if not 0 <= successes <= trials:
|
|
39
|
+
raise ValueError(f"{successes} successes out of {trials} trials isn't a proportion")
|
|
40
|
+
if not 0 < confidence < 1:
|
|
41
|
+
raise ValueError(f"confidence must lie between 0 and 1, got {confidence}")
|
|
42
|
+
if trials == 0:
|
|
43
|
+
return Proportion(successes=0, trials=0, rate=None, lower=0.0, upper=1.0, confidence=confidence)
|
|
44
|
+
|
|
45
|
+
z = float(norm.ppf(0.5 + confidence / 2))
|
|
46
|
+
rate = successes / trials
|
|
47
|
+
denominator = trials + z**2
|
|
48
|
+
centre = (successes + z**2 / 2) / denominator
|
|
49
|
+
half_width = z / denominator * math.sqrt(successes * (trials - successes) / trials + z**2 / 4)
|
|
50
|
+
# At 0 and 1 the limit is exact by the formula; the arithmetic alone can land a rounding error beside it.
|
|
51
|
+
lower = 0.0 if successes == 0 else centre - half_width
|
|
52
|
+
upper = 1.0 if successes == trials else centre + half_width
|
|
53
|
+
return Proportion(
|
|
54
|
+
successes=successes, trials=trials, rate=rate, lower=lower, upper=upper, confidence=confidence
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def step_rates(counts: Sequence[int], *, confidence: float = 0.95) -> list[Proportion]:
|
|
59
|
+
"""The rate from each count to the next, for counts of users in the order they pass through.
|
|
60
|
+
|
|
61
|
+
Users can only be lost along the way, so a count above the one before it isn't a rate and raises.
|
|
62
|
+
"""
|
|
63
|
+
return [wilson_interval(after, before, confidence=confidence) for before, after in pairwise(counts)]
|
|
File without changes
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Does a segment convert differently from everyone else at a step?"""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from typing import Literal, Self
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, model_validator
|
|
8
|
+
from scipy.stats import false_discovery_control, fisher_exact
|
|
9
|
+
|
|
10
|
+
from artest.stats.base import StatsModel
|
|
11
|
+
from artest.stats.proportions import Proportion, wilson_interval
|
|
12
|
+
|
|
13
|
+
# Below this expected count the normal approximation behind the z-test can't be trusted (Cochran 1954).
|
|
14
|
+
_MIN_EXPECTED_COUNT = 5
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TwoProportionTest(StatsModel):
|
|
18
|
+
"""A two-sided test of whether two rates differ.
|
|
19
|
+
|
|
20
|
+
`z` is above zero when the first rate is the higher one, and None for the exact test, which has no z.
|
|
21
|
+
`p_value` reads exactly 0 once it falls below the smallest float, from a z of about 38.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
method: Literal["z_test", "fisher_exact"]
|
|
25
|
+
z: float | None
|
|
26
|
+
p_value: float = Field(ge=0, le=1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def two_proportion_test(
|
|
30
|
+
successes_a: int, trials_a: int, successes_b: int, trials_b: int
|
|
31
|
+
) -> TwoProportionTest:
|
|
32
|
+
"""A two-proportion z-test with the pooled variance, or Fisher's exact test when counts are small.
|
|
33
|
+
|
|
34
|
+
Exact whenever an expected count falls below 5. That includes two rates that are both 0% or both 100%,
|
|
35
|
+
where the z-test divides by zero and the exact test gives the honest answer: p = 1, no evidence of any
|
|
36
|
+
difference.
|
|
37
|
+
"""
|
|
38
|
+
for arm_successes, arm_trials in ((successes_a, trials_a), (successes_b, trials_b)):
|
|
39
|
+
if not 0 <= arm_successes <= arm_trials or arm_trials == 0:
|
|
40
|
+
raise ValueError(f"can't test {arm_successes} successes out of {arm_trials} trials")
|
|
41
|
+
|
|
42
|
+
trials = trials_a + trials_b
|
|
43
|
+
successes = successes_a + successes_b
|
|
44
|
+
# The smallest expected count, in whole numbers, as rounding would land a count of 5 just below it.
|
|
45
|
+
if min(trials_a, trials_b) * min(successes, trials - successes) < _MIN_EXPECTED_COUNT * trials:
|
|
46
|
+
table = [[successes_a, trials_a - successes_a], [successes_b, trials_b - successes_b]]
|
|
47
|
+
return TwoProportionTest(method="fisher_exact", z=None, p_value=float(fisher_exact(table).pvalue))
|
|
48
|
+
|
|
49
|
+
pooled = successes / trials
|
|
50
|
+
standard_error = math.sqrt(pooled * (1 - pooled) * (1 / trials_a + 1 / trials_b))
|
|
51
|
+
z = (successes_a / trials_a - successes_b / trials_b) / standard_error
|
|
52
|
+
return TwoProportionTest(method="z_test", z=z, p_value=math.erfc(abs(z) / math.sqrt(2)))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def benjamini_hochberg(p_values: Sequence[float]) -> list[float]:
|
|
56
|
+
"""Benjamini-Hochberg adjusted p-values: those at or below q are discoveries at false discovery rate q.
|
|
57
|
+
|
|
58
|
+
The guarantee holds for independent or positively dependent tests (Benjamini and Yekutieli 2001).
|
|
59
|
+
Comparisons in one funnel share users, so read it as close control and not as a proof.
|
|
60
|
+
"""
|
|
61
|
+
return [float(adjusted) for adjusted in false_discovery_control(p_values, method="bh")]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Counts(StatsModel):
|
|
65
|
+
"""Of `trials` users, `successes` went on."""
|
|
66
|
+
|
|
67
|
+
successes: int = Field(ge=0)
|
|
68
|
+
trials: int = Field(ge=0)
|
|
69
|
+
|
|
70
|
+
@model_validator(mode="after")
|
|
71
|
+
def _possible(self) -> Self:
|
|
72
|
+
if self.successes > self.trials:
|
|
73
|
+
raise ValueError(f"{self.successes} successes out of {self.trials} trials isn't a proportion")
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class StepCounts(StatsModel):
|
|
78
|
+
"""One step's counts: everyone's and each segment's. Segments may overlap; each faces the rest."""
|
|
79
|
+
|
|
80
|
+
step: str
|
|
81
|
+
total: Counts
|
|
82
|
+
segments: Mapping[str, Counts]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SegmentComparison(StatsModel):
|
|
86
|
+
"""One segment against everyone else at one step.
|
|
87
|
+
|
|
88
|
+
`test` is None when one side has no users, so there's nothing to compare, and such a comparison doesn't
|
|
89
|
+
count towards the correction. `significant` means the gap is unlikely to be sampling noise, across all
|
|
90
|
+
the comparisons made. It's an observation and not an effect: these users may differ from the rest in
|
|
91
|
+
many ways, so it doesn't say a change would close the gap, or how far.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
step: str
|
|
95
|
+
segment: str
|
|
96
|
+
segment_rate: Proportion
|
|
97
|
+
rest_rate: Proportion
|
|
98
|
+
test: TwoProportionTest | None
|
|
99
|
+
adjusted_p_value: float | None = Field(ge=0, le=1)
|
|
100
|
+
significant: bool
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def compare_segments(
|
|
104
|
+
steps: Sequence[StepCounts], *, fdr: float = 0.05, confidence: float = 0.95
|
|
105
|
+
) -> list[SegmentComparison]:
|
|
106
|
+
"""Every segment against the rest at every step, with Benjamini-Hochberg across all those comparisons.
|
|
107
|
+
|
|
108
|
+
`confidence` sets the rates' intervals only; they aren't corrected for the number of comparisons.
|
|
109
|
+
"""
|
|
110
|
+
if not 0 < fdr < 1:
|
|
111
|
+
raise ValueError(f"the false discovery rate must lie between 0 and 1, got {fdr}")
|
|
112
|
+
if not 0 < confidence < 1:
|
|
113
|
+
raise ValueError(f"confidence must lie between 0 and 1, got {confidence}")
|
|
114
|
+
|
|
115
|
+
pairs = [
|
|
116
|
+
(step.step, name, segment, _rest(step, name, segment))
|
|
117
|
+
for step in steps
|
|
118
|
+
for name, segment in step.segments.items()
|
|
119
|
+
]
|
|
120
|
+
tests = [
|
|
121
|
+
two_proportion_test(segment.successes, segment.trials, rest.successes, rest.trials)
|
|
122
|
+
if segment.trials and rest.trials
|
|
123
|
+
else None
|
|
124
|
+
for _, _, segment, rest in pairs
|
|
125
|
+
]
|
|
126
|
+
adjusted = iter(benjamini_hochberg([test.p_value for test in tests if test is not None]))
|
|
127
|
+
|
|
128
|
+
comparisons: list[SegmentComparison] = []
|
|
129
|
+
for (step, name, segment, rest), test in zip(pairs, tests, strict=True):
|
|
130
|
+
adjusted_p = None if test is None else next(adjusted)
|
|
131
|
+
comparisons.append(
|
|
132
|
+
SegmentComparison(
|
|
133
|
+
step=step,
|
|
134
|
+
segment=name,
|
|
135
|
+
segment_rate=wilson_interval(segment.successes, segment.trials, confidence=confidence),
|
|
136
|
+
rest_rate=wilson_interval(rest.successes, rest.trials, confidence=confidence),
|
|
137
|
+
test=test,
|
|
138
|
+
adjusted_p_value=adjusted_p,
|
|
139
|
+
significant=adjusted_p is not None and adjusted_p <= fdr,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
return comparisons
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _rest(step: StepCounts, name: str, segment: Counts) -> Counts:
|
|
146
|
+
trials = step.total.trials - segment.trials
|
|
147
|
+
successes = step.total.successes - segment.successes
|
|
148
|
+
if trials < 0 or successes < 0 or successes > trials:
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"at {step.step!r}, segment {name!r} ({segment.successes} of {segment.trials}) doesn't fit "
|
|
151
|
+
f"inside the total ({step.total.successes} of {step.total.trials})"
|
|
152
|
+
)
|
|
153
|
+
return Counts(successes=successes, trials=trials)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Did the users split the way the experiment planned?"""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
from scipy.stats import binomtest, chi2
|
|
7
|
+
|
|
8
|
+
from artest.stats.base import StatsModel
|
|
9
|
+
|
|
10
|
+
# The threshold GrowthBook flags at: a real mismatch shows very clearly, and a false alarm halts a good test.
|
|
11
|
+
_THRESHOLD = 0.001
|
|
12
|
+
# Below this expected count the chi-squared approximation reads far too sure (Cochran 1954).
|
|
13
|
+
_MIN_EXPECTED_COUNT = 5
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SrmCheck(StatsModel):
|
|
17
|
+
"""A sample ratio mismatch check: the users each arm got, against what the planned split expected.
|
|
18
|
+
|
|
19
|
+
A `mismatch` means assignment or tracking broke, so the experiment's results can't be trusted whatever
|
|
20
|
+
they say (Fabijan et al. 2019). No mismatch isn't proof that all is well: the check finds only a problem
|
|
21
|
+
in the split, and a small one only with many users.
|
|
22
|
+
|
|
23
|
+
`p_value` is the chi-squared test's, as GrowthBook reports it. When an arm expects too few users for that
|
|
24
|
+
approximation, `exact_p_value` decides instead: an exact binomial test of each arm, Bonferroni corrected.
|
|
25
|
+
It's None otherwise. An arm planned for no traffic takes no part in either, though users in it are a
|
|
26
|
+
mismatch whatever the numbers say.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
observed: tuple[int, ...]
|
|
30
|
+
expected: tuple[float, ...]
|
|
31
|
+
chi_square: float = Field(ge=0)
|
|
32
|
+
p_value: float = Field(ge=0, le=1)
|
|
33
|
+
exact_p_value: float | None = Field(ge=0, le=1)
|
|
34
|
+
mismatch: bool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def check_srm(
|
|
38
|
+
observed: Sequence[int], planned: Sequence[float], *, threshold: float = _THRESHOLD
|
|
39
|
+
) -> SrmCheck:
|
|
40
|
+
"""Chi-squared goodness of fit of `observed` users against the `planned` split, in any scale."""
|
|
41
|
+
if len(observed) != len(planned) or len(observed) < 2:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"need the same two or more arms, got {len(observed)} counts and {len(planned)} shares"
|
|
44
|
+
)
|
|
45
|
+
if any(users < 0 for users in observed):
|
|
46
|
+
raise ValueError(f"an arm can't have fewer than no users, got {list(observed)}")
|
|
47
|
+
if any(share < 0 for share in planned) or not any(share > 0 for share in planned):
|
|
48
|
+
raise ValueError(f"the shares can't be negative, and one must be above zero, got {list(planned)}")
|
|
49
|
+
if not 0 < threshold < 1:
|
|
50
|
+
raise ValueError(f"the threshold must lie between 0 and 1, got {threshold}")
|
|
51
|
+
|
|
52
|
+
# Only the arms that were planned take part. Users in any other decide the check by themselves.
|
|
53
|
+
shares = tuple(share / sum(planned) for share in planned)
|
|
54
|
+
live = [(o, share) for o, share in zip(observed, shares, strict=True) if share > 0]
|
|
55
|
+
users = sum(o for o, _ in live)
|
|
56
|
+
expected = tuple(share * users for share in shares)
|
|
57
|
+
statistic = sum((o - share * users) ** 2 / (share * users) for o, share in live) if users else 0.0
|
|
58
|
+
p_value = float(chi2.sf(statistic, len(live) - 1)) if users and len(live) > 1 else 1.0
|
|
59
|
+
exact = (
|
|
60
|
+
_exact_p_value(live, users) if min(share for _, share in live) * users < _MIN_EXPECTED_COUNT else None
|
|
61
|
+
)
|
|
62
|
+
return SrmCheck(
|
|
63
|
+
observed=tuple(observed),
|
|
64
|
+
expected=expected,
|
|
65
|
+
chi_square=statistic,
|
|
66
|
+
p_value=p_value,
|
|
67
|
+
exact_p_value=exact,
|
|
68
|
+
mismatch=users < sum(observed) or (p_value if exact is None else exact) < threshold,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _exact_p_value(arms: Sequence[tuple[int, float]], users: int) -> float:
|
|
73
|
+
"""The smallest of the arms' exact binomial p-values, corrected for how many different tests they are.
|
|
74
|
+
|
|
75
|
+
Two arms are one test: each is the other's complement. No users, no evidence.
|
|
76
|
+
"""
|
|
77
|
+
if not users:
|
|
78
|
+
return 1.0
|
|
79
|
+
tests = 1 if len(arms) == 2 else len(arms)
|
|
80
|
+
return min(1.0, tests * min(float(binomtest(o, users, share).pvalue) for o, share in arms))
|