geoexp 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.
- geoexp/__init__.py +19 -0
- geoexp/_version.py +3 -0
- geoexp/exceptions.py +12 -0
- geoexp/py.typed +0 -0
- geoexp/selection.py +650 -0
- geoexp-0.1.0.dist-info/METADATA +181 -0
- geoexp-0.1.0.dist-info/RECORD +9 -0
- geoexp-0.1.0.dist-info/WHEEL +4 -0
- geoexp-0.1.0.dist-info/licenses/LICENSE +21 -0
geoexp/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""geoexp: geo-experiment design on top of augsynth-py.
|
|
2
|
+
|
|
3
|
+
Market selection, power-based design ranking, and experiment reporting for
|
|
4
|
+
geo-experiments. This package answers *which design should I run?*; its
|
|
5
|
+
dependency `augsynth-py` answers *what can this design detect?* — see the
|
|
6
|
+
package-boundary section of CLAUDE.md.
|
|
7
|
+
|
|
8
|
+
The public API is fixed by docs/design-2026-09-05-v0.5-api.md.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from geoexp._version import __version__
|
|
12
|
+
from geoexp.selection import DesignRanking, enumerate_candidates, rank_designs
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DesignRanking",
|
|
16
|
+
"__version__",
|
|
17
|
+
"enumerate_candidates",
|
|
18
|
+
"rank_designs",
|
|
19
|
+
]
|
geoexp/_version.py
ADDED
geoexp/exceptions.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Domain exceptions for geoexp.
|
|
2
|
+
|
|
3
|
+
Mirrors augsynth-py's convention: ``ValueError`` for bad input, subclasses of
|
|
4
|
+
:class:`GeoexpError` for domain errors a caller may want to catch
|
|
5
|
+
specifically.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GeoexpError(Exception):
|
|
12
|
+
"""Base class for geoexp domain errors."""
|
geoexp/py.typed
ADDED
|
File without changes
|
geoexp/selection.py
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
"""Market selection for single-cell geo-experiments.
|
|
2
|
+
|
|
3
|
+
Ranks candidate treated sets x experiment durations by what each design can
|
|
4
|
+
detect: :func:`enumerate_candidates` builds candidates by similarity to the
|
|
5
|
+
donor pool, and :func:`rank_designs` calls ``augsynth_py.simulate_power`` once
|
|
6
|
+
per candidate and reduces each power curve to a ranking row --- interpolated
|
|
7
|
+
MDE, power at a target effect, empirical size, pre-period fit, bias
|
|
8
|
+
diagnostics, and treated share of the outcome.
|
|
9
|
+
|
|
10
|
+
The public surface is fixed by ``docs/design-2026-09-05-v0.5-api.md``; what
|
|
11
|
+
each column means and where it comes from is in ``docs/methodology.md``.
|
|
12
|
+
|
|
13
|
+
Contract rules binding on this module: consume augsynth-py through its public
|
|
14
|
+
API only; one child generator per candidate via ``rng.spawn``; parallelize
|
|
15
|
+
across candidates with ``n_jobs=1`` passed down to ``simulate_power``.
|
|
16
|
+
|
|
17
|
+
Parity oracle: R ``GeoLiftMarketSelection``, driven via rpy2 in
|
|
18
|
+
``tests/validation_against_r/`` --- clean-room rule, never translated.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import itertools
|
|
24
|
+
import warnings
|
|
25
|
+
from collections.abc import Iterable, Sequence
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any, Literal
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
import polars as pl
|
|
31
|
+
from augsynth_py import (
|
|
32
|
+
DEFAULT_EFFECT_SIZES,
|
|
33
|
+
EffectType,
|
|
34
|
+
PowerEstimator,
|
|
35
|
+
PowerResults,
|
|
36
|
+
simulate_power,
|
|
37
|
+
)
|
|
38
|
+
from joblib import Parallel, delayed
|
|
39
|
+
|
|
40
|
+
# augsynth_py exports EffectType but not these two: they live in
|
|
41
|
+
# augsynth_py.inference and are absent from augsynth_py.__all__. The
|
|
42
|
+
# consumption rule forbids reaching into a submodule, so they are mirrored
|
|
43
|
+
# here. An upstream proposal to export them is open; when it lands these
|
|
44
|
+
# become re-exports.
|
|
45
|
+
PermutationType = Literal["block", "iid"]
|
|
46
|
+
Side = Literal["two-sided", "left", "right"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _candidate_key(units: Iterable[Any]) -> str:
|
|
50
|
+
"""Canonical, stable key for a candidate treated set.
|
|
51
|
+
|
|
52
|
+
Members are sorted by their string form and joined with ``", "``. This
|
|
53
|
+
reproduces the convention R ``GeoLiftMarketSelection`` uses for
|
|
54
|
+
``BestMarkets$location``, so parity joins are a plain string match with no
|
|
55
|
+
translation layer.
|
|
56
|
+
|
|
57
|
+
Sorting on ``str(unit)`` rather than on the unit itself gives a total order
|
|
58
|
+
for any unit type. ``augsynth_py.PowerParams.treated`` is typed
|
|
59
|
+
``tuple[Any, ...]``, so units are not guaranteed to be mutually orderable.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
units : iterable of object
|
|
64
|
+
The treated units making up one candidate.
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
str
|
|
69
|
+
The canonical key, e.g. ``"nashville, san diego"``.
|
|
70
|
+
"""
|
|
71
|
+
return ", ".join(sorted(str(u) for u in units))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _candidate_keys(candidates: Sequence[Iterable[Any]]) -> list[str]:
|
|
75
|
+
"""Canonical keys for a candidate list, rejecting collisions.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
candidates : sequence of iterable of object
|
|
80
|
+
Candidate treated sets.
|
|
81
|
+
|
|
82
|
+
Returns
|
|
83
|
+
-------
|
|
84
|
+
list of str
|
|
85
|
+
One key per candidate, in the order given.
|
|
86
|
+
|
|
87
|
+
Raises
|
|
88
|
+
------
|
|
89
|
+
ValueError
|
|
90
|
+
If two distinct candidates map to the same canonical key (e.g. ``{1}``
|
|
91
|
+
and ``{"1"}``). Collapsing them would silently drop a row from the
|
|
92
|
+
ranking frame.
|
|
93
|
+
"""
|
|
94
|
+
keys = [_candidate_key(c) for c in candidates]
|
|
95
|
+
seen: dict[str, int] = {}
|
|
96
|
+
for i, key in enumerate(keys):
|
|
97
|
+
if key in seen:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"candidates {seen[key]} and {i} collide on the canonical key {key!r}; "
|
|
100
|
+
"give them distinct units or normalize the unit type"
|
|
101
|
+
)
|
|
102
|
+
seen[key] = i
|
|
103
|
+
return keys
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _interp_mde(
|
|
107
|
+
effect_sizes: Sequence[float],
|
|
108
|
+
powers: Sequence[float],
|
|
109
|
+
*,
|
|
110
|
+
target_power: float,
|
|
111
|
+
) -> float | None:
|
|
112
|
+
"""Minimum detectable effect, interpolated off the power curve.
|
|
113
|
+
|
|
114
|
+
``augsynth_py.PowerResults.mde`` returns a grid point, which leaves most
|
|
115
|
+
candidates tied on a coarse grid and makes a ranking degenerate. This
|
|
116
|
+
interpolates the crossing instead. It is a geoexp construct and is **not**
|
|
117
|
+
R GeoLift's ``Average_MDE`` — see ``docs/methodology.md``.
|
|
118
|
+
|
|
119
|
+
Rows at ``effect_size == 0`` are dropped: that row is the empirical size,
|
|
120
|
+
not a detectable effect. The curve is monotonized by running maximum before
|
|
121
|
+
interpolating, because simulated power curves dip.
|
|
122
|
+
|
|
123
|
+
Parameters
|
|
124
|
+
----------
|
|
125
|
+
effect_sizes : sequence of float
|
|
126
|
+
Grid of effect sizes, ascending.
|
|
127
|
+
powers : sequence of float
|
|
128
|
+
Power at each effect size.
|
|
129
|
+
target_power : float
|
|
130
|
+
Power the design must reach.
|
|
131
|
+
|
|
132
|
+
Returns
|
|
133
|
+
-------
|
|
134
|
+
float or None
|
|
135
|
+
The interpolated crossing, or ``None`` if the curve never reaches
|
|
136
|
+
``target_power`` on the evaluated grid. Never extrapolated.
|
|
137
|
+
"""
|
|
138
|
+
pairs = sorted((e, p) for e, p in zip(effect_sizes, powers, strict=True) if e > 0)
|
|
139
|
+
if not pairs:
|
|
140
|
+
return None
|
|
141
|
+
xs = np.asarray([e for e, _ in pairs], dtype=float)
|
|
142
|
+
ys = np.maximum.accumulate(np.asarray([p for _, p in pairs], dtype=float))
|
|
143
|
+
if ys[-1] < target_power:
|
|
144
|
+
return None
|
|
145
|
+
i = int(np.argmax(ys >= target_power))
|
|
146
|
+
if i == 0:
|
|
147
|
+
return float(xs[0])
|
|
148
|
+
x0, x1, y0, y1 = xs[i - 1], xs[i], ys[i - 1], ys[i]
|
|
149
|
+
if y1 == y0:
|
|
150
|
+
return float(x1)
|
|
151
|
+
return float(x0 + (target_power - y0) * (x1 - x0) / (y1 - y0))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
#: Substantive ranking criteria, in priority order. ``candidate`` is appended
|
|
155
|
+
#: for a deterministic display order but is deliberately not a rank key: two
|
|
156
|
+
#: designs identical on every criterion below share a rank.
|
|
157
|
+
_RANK_KEYS = ("mde", "empirical_size", "rmspe_pre")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _apply_ranking(ranking: pl.DataFrame) -> pl.DataFrame:
|
|
161
|
+
"""Sort a ranking frame and attach a competition rank.
|
|
162
|
+
|
|
163
|
+
Ordering is ``mde`` ascending, then ``empirical_size`` ascending, then
|
|
164
|
+
``rmspe_pre`` ascending, then ``candidate`` lexicographic. The reasoning:
|
|
165
|
+
MDE answers the question the package exists to ask; empirical size guards
|
|
166
|
+
against a design that "detects" because it over-rejects; ``rmspe_pre``
|
|
167
|
+
guards against a poor pre-period fit; the candidate key makes the order
|
|
168
|
+
fully deterministic so two runs never disagree on ties.
|
|
169
|
+
|
|
170
|
+
A null ``mde`` (the curve never reached target power) sorts last — such a
|
|
171
|
+
design is the worst available, not a missing value.
|
|
172
|
+
|
|
173
|
+
Parameters
|
|
174
|
+
----------
|
|
175
|
+
ranking : polars.DataFrame
|
|
176
|
+
Must carry ``candidate`` and the columns in :data:`_RANK_KEYS`.
|
|
177
|
+
|
|
178
|
+
Returns
|
|
179
|
+
-------
|
|
180
|
+
polars.DataFrame
|
|
181
|
+
Sorted, with an integer ``rank`` column. Ties on every substantive
|
|
182
|
+
criterion share a rank and consume the positions they span, matching
|
|
183
|
+
the oracle's competition ranking.
|
|
184
|
+
"""
|
|
185
|
+
ordered = ranking.sort([*_RANK_KEYS, "candidate"], nulls_last=True)
|
|
186
|
+
return (
|
|
187
|
+
ordered.with_row_index("_pos", offset=1)
|
|
188
|
+
.with_columns(pl.col("_pos").min().over(_RANK_KEYS).cast(pl.Int64).alias("rank"))
|
|
189
|
+
.drop("_pos")
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class DesignRanking:
|
|
195
|
+
"""Result of :func:`rank_designs`.
|
|
196
|
+
|
|
197
|
+
Attributes
|
|
198
|
+
----------
|
|
199
|
+
ranking : polars.DataFrame
|
|
200
|
+
One row per candidate x duration, sorted best-first, with a ``rank``
|
|
201
|
+
column. See :func:`rank_designs` for the column meanings.
|
|
202
|
+
candidates : dict of str to tuple
|
|
203
|
+
Canonical key -> the original treated units, so the caller can recover
|
|
204
|
+
the objects they passed in.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
ranking: pl.DataFrame
|
|
208
|
+
candidates: dict[str, tuple[Any, ...]]
|
|
209
|
+
_results: dict[str, PowerResults] = field(repr=False, default_factory=dict)
|
|
210
|
+
|
|
211
|
+
def power(self, candidate: str | Iterable[Any], duration: int) -> PowerResults:
|
|
212
|
+
"""Return the power results behind one candidate.
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
candidate : str or iterable of object
|
|
217
|
+
Either the canonical key from the ranking frame or the original
|
|
218
|
+
treated set.
|
|
219
|
+
duration : int
|
|
220
|
+
A duration that was evaluated. Validated, not used to subset: one
|
|
221
|
+
``simulate_power`` call covers all durations for a candidate, per
|
|
222
|
+
the contract pattern of one child generator per *candidate*.
|
|
223
|
+
|
|
224
|
+
Returns
|
|
225
|
+
-------
|
|
226
|
+
augsynth_py.PowerResults
|
|
227
|
+
The candidate's results, spanning every evaluated duration.
|
|
228
|
+
|
|
229
|
+
Raises
|
|
230
|
+
------
|
|
231
|
+
KeyError
|
|
232
|
+
If the candidate was not evaluated.
|
|
233
|
+
ValueError
|
|
234
|
+
If ``duration`` was not among the evaluated durations.
|
|
235
|
+
"""
|
|
236
|
+
key = candidate if isinstance(candidate, str) else _candidate_key(candidate)
|
|
237
|
+
if key not in self._results:
|
|
238
|
+
raise KeyError(f"no results for candidate {key!r}")
|
|
239
|
+
results = self._results[key]
|
|
240
|
+
durations = results.params.durations if results.params is not None else ()
|
|
241
|
+
if duration not in durations:
|
|
242
|
+
raise ValueError(f"duration {duration} was not evaluated; got {list(durations)}")
|
|
243
|
+
return results
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def rank_designs(
|
|
247
|
+
panel: pl.DataFrame,
|
|
248
|
+
*,
|
|
249
|
+
estimator: PowerEstimator,
|
|
250
|
+
unit: str,
|
|
251
|
+
time: str,
|
|
252
|
+
outcome: str,
|
|
253
|
+
candidates: Sequence[Iterable[Any]],
|
|
254
|
+
durations: int | Sequence[int],
|
|
255
|
+
rng: np.random.Generator,
|
|
256
|
+
target_power: float = 0.8,
|
|
257
|
+
target_effect_size: float = 0.10,
|
|
258
|
+
cpic: float | None = None,
|
|
259
|
+
n_jobs: int = 1,
|
|
260
|
+
effect_sizes: Sequence[float] = DEFAULT_EFFECT_SIZES,
|
|
261
|
+
effect_type: EffectType = "multiplicative",
|
|
262
|
+
lookback_window: int = 1,
|
|
263
|
+
alpha: float = 0.1,
|
|
264
|
+
permutation_type: PermutationType = "block",
|
|
265
|
+
block_size: int | None = None,
|
|
266
|
+
side: Side = "two-sided",
|
|
267
|
+
ns: int = 1000,
|
|
268
|
+
on_error: Literal["raise", "record"] = "raise",
|
|
269
|
+
) -> DesignRanking:
|
|
270
|
+
"""Rank candidate geo-experiment designs by what each one can detect.
|
|
271
|
+
|
|
272
|
+
Calls :func:`augsynth_py.simulate_power` once per candidate and reduces each
|
|
273
|
+
power curve to a row: interpolated MDE, power at a target effect, empirical
|
|
274
|
+
size, pre-period fit, and treated share of the outcome.
|
|
275
|
+
|
|
276
|
+
Parameters
|
|
277
|
+
----------
|
|
278
|
+
panel : polars.DataFrame
|
|
279
|
+
Long-format panel, same contract as ``augsynth_py.simulate_power``.
|
|
280
|
+
estimator : augsynth_py.PowerEstimator
|
|
281
|
+
Prototype estimator. Freeze the penalty with ``AugSynth(lambda_=...)``
|
|
282
|
+
for large candidate grids: market selection multiplies the
|
|
283
|
+
per-simulation CV cost by the candidate count.
|
|
284
|
+
candidates : sequence of iterable of object
|
|
285
|
+
Explicit treated sets. Automatic enumeration is out of scope for this
|
|
286
|
+
release.
|
|
287
|
+
durations : int or sequence of int
|
|
288
|
+
Treatment durations to evaluate; fanned out across every candidate.
|
|
289
|
+
rng : numpy.random.Generator
|
|
290
|
+
Required, with no default. One child generator per candidate via
|
|
291
|
+
``rng.spawn`` makes the result order-independent under candidate-level
|
|
292
|
+
parallelism and restartable.
|
|
293
|
+
target_power : float, default 0.8
|
|
294
|
+
Power the MDE is defined against.
|
|
295
|
+
target_effect_size : float, default 0.10
|
|
296
|
+
Effect size reported in ``power_at_target``. Need not be on the grid.
|
|
297
|
+
cpic : float, optional
|
|
298
|
+
Cost per incremental conversion. When given, an ``investment`` column
|
|
299
|
+
is added; when omitted, no cost is reported rather than assumed.
|
|
300
|
+
n_jobs : int, default 1
|
|
301
|
+
Parallelism **across candidates**. ``n_jobs=1`` is always passed down to
|
|
302
|
+
``simulate_power`` — joblib does not nest workers.
|
|
303
|
+
|
|
304
|
+
Returns
|
|
305
|
+
-------
|
|
306
|
+
DesignRanking
|
|
307
|
+
Ranking frame plus the underlying power results.
|
|
308
|
+
|
|
309
|
+
Raises
|
|
310
|
+
------
|
|
311
|
+
ValueError
|
|
312
|
+
If two candidates collide on the canonical key, or ``candidates`` is
|
|
313
|
+
empty.
|
|
314
|
+
|
|
315
|
+
Notes
|
|
316
|
+
-----
|
|
317
|
+
``mde`` is interpolated off the power curve and is a geoexp construct: it
|
|
318
|
+
is **not** R GeoLift's ``Average_MDE``, which cannot be reproduced from
|
|
319
|
+
published material. ``mde_grid`` carries the unmodified
|
|
320
|
+
``PowerResults.mde`` grid point alongside it.
|
|
321
|
+
|
|
322
|
+
Rank order by ``mde`` is stable within an implementation but not across
|
|
323
|
+
them: small differences near ties are not meaningful. See
|
|
324
|
+
``docs/geolift-oracle-characterization.md`` §9.
|
|
325
|
+
"""
|
|
326
|
+
if not candidates:
|
|
327
|
+
raise ValueError("candidates must not be empty")
|
|
328
|
+
keys = _candidate_keys(candidates)
|
|
329
|
+
members = [tuple(c) for c in candidates]
|
|
330
|
+
duration_list = [durations] if isinstance(durations, int) else list(durations)
|
|
331
|
+
|
|
332
|
+
if lookback_window < 10:
|
|
333
|
+
warnings.warn(
|
|
334
|
+
f"lookback_window={lookback_window} makes empirical_size a coarse rate over "
|
|
335
|
+
f"{lookback_window} placebo test(s); it participates in the ranking order. "
|
|
336
|
+
"Use a larger lookback_window for a meaningful false-positive rate.",
|
|
337
|
+
UserWarning,
|
|
338
|
+
stacklevel=2,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
children = rng.spawn(len(keys))
|
|
342
|
+
|
|
343
|
+
def _one(treated: tuple[Any, ...], child: np.random.Generator) -> PowerResults:
|
|
344
|
+
return simulate_power(
|
|
345
|
+
panel,
|
|
346
|
+
estimator=estimator,
|
|
347
|
+
unit=unit,
|
|
348
|
+
time=time,
|
|
349
|
+
outcome=outcome,
|
|
350
|
+
treated=list(treated),
|
|
351
|
+
durations=duration_list,
|
|
352
|
+
effect_sizes=effect_sizes,
|
|
353
|
+
effect_type=effect_type,
|
|
354
|
+
lookback_window=lookback_window,
|
|
355
|
+
alpha=alpha,
|
|
356
|
+
permutation_type=permutation_type,
|
|
357
|
+
block_size=block_size,
|
|
358
|
+
side=side,
|
|
359
|
+
ns=ns,
|
|
360
|
+
rng=child,
|
|
361
|
+
n_jobs=1,
|
|
362
|
+
on_error=on_error,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
computed: list[PowerResults] = Parallel(n_jobs=n_jobs)(
|
|
366
|
+
delayed(_one)(m, c) for m, c in zip(members, children, strict=True)
|
|
367
|
+
)
|
|
368
|
+
results = dict(zip(keys, computed, strict=True))
|
|
369
|
+
|
|
370
|
+
rows = [
|
|
371
|
+
_summarize(
|
|
372
|
+
key=key,
|
|
373
|
+
treated=treated,
|
|
374
|
+
results=results[key],
|
|
375
|
+
duration=duration,
|
|
376
|
+
panel=panel,
|
|
377
|
+
unit=unit,
|
|
378
|
+
time=time,
|
|
379
|
+
outcome=outcome,
|
|
380
|
+
target_power=target_power,
|
|
381
|
+
target_effect_size=target_effect_size,
|
|
382
|
+
alpha=alpha,
|
|
383
|
+
effect_type=effect_type,
|
|
384
|
+
cpic=cpic,
|
|
385
|
+
)
|
|
386
|
+
for key, treated in zip(keys, members, strict=True)
|
|
387
|
+
for duration in duration_list
|
|
388
|
+
]
|
|
389
|
+
return DesignRanking(
|
|
390
|
+
ranking=_apply_ranking(pl.DataFrame(rows, schema=_ranking_schema(cpic))),
|
|
391
|
+
candidates=dict(zip(keys, members, strict=True)),
|
|
392
|
+
_results=results,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _ranking_schema(cpic: float | None) -> dict[str, Any]:
|
|
397
|
+
"""Column order and dtypes of the ranking frame."""
|
|
398
|
+
schema: dict[str, Any] = {
|
|
399
|
+
"candidate": pl.String,
|
|
400
|
+
"duration": pl.Int64,
|
|
401
|
+
"n_units": pl.Int64,
|
|
402
|
+
"mde": pl.Float64,
|
|
403
|
+
"mde_grid": pl.Float64,
|
|
404
|
+
"power_at_target": pl.Float64,
|
|
405
|
+
"empirical_size": pl.Float64,
|
|
406
|
+
"rmspe_pre": pl.Float64,
|
|
407
|
+
"null_bias": pl.Float64,
|
|
408
|
+
"h1_calibration_error": pl.Float64,
|
|
409
|
+
"proportion_total_y": pl.Float64,
|
|
410
|
+
"holdout": pl.Float64,
|
|
411
|
+
}
|
|
412
|
+
if cpic is not None:
|
|
413
|
+
schema["investment"] = pl.Float64
|
|
414
|
+
return schema
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _power_at(curve: pl.DataFrame, effect_size: float) -> float | None:
|
|
418
|
+
"""Power at ``effect_size``, interpolated when it is off the grid."""
|
|
419
|
+
exact = curve.filter(pl.col("effect_size") == effect_size)
|
|
420
|
+
if exact.height:
|
|
421
|
+
return float(exact.get_column("power").item())
|
|
422
|
+
xs = curve.get_column("effect_size").to_numpy()
|
|
423
|
+
ys = np.maximum.accumulate(curve.get_column("power").to_numpy())
|
|
424
|
+
if effect_size < xs.min() or effect_size > xs.max():
|
|
425
|
+
return None
|
|
426
|
+
return float(np.interp(effect_size, xs, ys))
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _mean_or_none(frame: pl.DataFrame, column: str) -> float | None:
|
|
430
|
+
"""Mean of ``column``, or ``None`` when the frame is empty or all-null."""
|
|
431
|
+
if not frame.height:
|
|
432
|
+
return None
|
|
433
|
+
value = frame.select(pl.col(column).mean()).item()
|
|
434
|
+
return None if value is None else float(value)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _calibration_error(
|
|
438
|
+
sims: pl.DataFrame, grid_mde: float | None, effect_type: EffectType
|
|
439
|
+
) -> float | None:
|
|
440
|
+
"""How far the recovered effect sits from the injected one, at the MDE.
|
|
441
|
+
|
|
442
|
+
Catches a design that detects with high power but recovers the wrong
|
|
443
|
+
magnitude --- power alone cannot see that. Adapted from the
|
|
444
|
+
``h1_calibration_error`` diagnostic in mrcsvg/augsynth-py#22.
|
|
445
|
+
|
|
446
|
+
Defined for multiplicative effects only: ``att_pct`` and a multiplicative
|
|
447
|
+
effect size are the same quantity, while an additive effect size is on the
|
|
448
|
+
outcome's scale and the comparison would be meaningless. Returns ``None``
|
|
449
|
+
rather than a wrong number.
|
|
450
|
+
"""
|
|
451
|
+
if effect_type != "multiplicative" or grid_mde is None:
|
|
452
|
+
return None
|
|
453
|
+
recovered = _mean_or_none(sims.filter(pl.col("effect_size") == grid_mde), "att_pct")
|
|
454
|
+
return None if recovered is None else abs(recovered - grid_mde)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _summarize(
|
|
458
|
+
*,
|
|
459
|
+
key: str,
|
|
460
|
+
treated: tuple[Any, ...],
|
|
461
|
+
results: PowerResults,
|
|
462
|
+
duration: int,
|
|
463
|
+
panel: pl.DataFrame,
|
|
464
|
+
unit: str,
|
|
465
|
+
time: str,
|
|
466
|
+
outcome: str,
|
|
467
|
+
target_power: float,
|
|
468
|
+
target_effect_size: float,
|
|
469
|
+
alpha: float,
|
|
470
|
+
effect_type: EffectType,
|
|
471
|
+
cpic: float | None,
|
|
472
|
+
) -> dict[str, Any]:
|
|
473
|
+
"""Reduce one candidate x duration cell to a ranking row."""
|
|
474
|
+
curve = (
|
|
475
|
+
results.power_curve(alpha=alpha).filter(pl.col("duration") == duration).sort("effect_size")
|
|
476
|
+
)
|
|
477
|
+
sims = results.simulations.filter(pl.col("duration") == duration)
|
|
478
|
+
zero = curve.filter(pl.col("effect_size") == 0.0)
|
|
479
|
+
|
|
480
|
+
# Treated share of the outcome over the treatment window (the last
|
|
481
|
+
# `duration` periods). GeoLift reports a near-identical quantity under a
|
|
482
|
+
# slightly different window convention; see the characterization doc.
|
|
483
|
+
window = panel.filter(pl.col(time) > pl.col(time).max() - duration)
|
|
484
|
+
totals = window.select(
|
|
485
|
+
total=pl.col(outcome).sum(),
|
|
486
|
+
treated=pl.col(outcome).filter(pl.col(unit).is_in(list(treated))).sum(),
|
|
487
|
+
).row(0)
|
|
488
|
+
total, treated_y = float(totals[0] or 0.0), float(totals[1] or 0.0)
|
|
489
|
+
share = treated_y / total if total else None
|
|
490
|
+
|
|
491
|
+
grid_mde = results.mde(target_power=target_power, alpha=alpha, duration=duration)
|
|
492
|
+
row: dict[str, Any] = {
|
|
493
|
+
"candidate": key,
|
|
494
|
+
"duration": duration,
|
|
495
|
+
"n_units": len(treated),
|
|
496
|
+
"mde": _interp_mde(
|
|
497
|
+
curve.get_column("effect_size").to_list(),
|
|
498
|
+
curve.get_column("power").to_list(),
|
|
499
|
+
target_power=target_power,
|
|
500
|
+
),
|
|
501
|
+
"mde_grid": grid_mde,
|
|
502
|
+
"power_at_target": _power_at(curve, target_effect_size),
|
|
503
|
+
"empirical_size": float(zero.get_column("power").item()) if zero.height else None,
|
|
504
|
+
"rmspe_pre": _mean_or_none(sims, "rmspe_pre"),
|
|
505
|
+
"null_bias": _mean_or_none(sims.filter(pl.col("effect_size") == 0.0), "att_pct"),
|
|
506
|
+
"h1_calibration_error": _calibration_error(sims, grid_mde, effect_type),
|
|
507
|
+
"proportion_total_y": share,
|
|
508
|
+
"holdout": None if share is None else 1.0 - share,
|
|
509
|
+
}
|
|
510
|
+
if cpic is not None:
|
|
511
|
+
row["investment"] = cpic * target_effect_size * treated_y
|
|
512
|
+
return row
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _rank_by_similarity(
|
|
516
|
+
panel: pl.DataFrame, *, unit: str, time: str, outcome: str, units: Sequence[Any]
|
|
517
|
+
) -> list[Any]:
|
|
518
|
+
"""Order units by how well the rest of the panel explains them.
|
|
519
|
+
|
|
520
|
+
Each unit's series is correlated with the mean of every *other* unit's
|
|
521
|
+
series; higher correlation first. A test market that moves with the donor
|
|
522
|
+
pool is one a synthetic control can reconstruct, which is the published
|
|
523
|
+
GeoLift guidance ("test and control areas should resemble each other
|
|
524
|
+
historically"). Correlation is GeoLift's own documented default criterion
|
|
525
|
+
(``dtw_emphasis = 0``).
|
|
526
|
+
|
|
527
|
+
Ties and non-finite correlations fall back to the canonical key, so the
|
|
528
|
+
order is total and reproducible.
|
|
529
|
+
"""
|
|
530
|
+
wide = panel.pivot(on=unit, index=time, values=outcome).sort(time)
|
|
531
|
+
matrix = np.column_stack([wide.get_column(str(u)).to_numpy() for u in units]).astype(float)
|
|
532
|
+
scores: dict[Any, float] = {}
|
|
533
|
+
for i, u in enumerate(units):
|
|
534
|
+
others = np.delete(matrix, i, axis=1)
|
|
535
|
+
if others.shape[1] == 0:
|
|
536
|
+
scores[u] = 0.0
|
|
537
|
+
continue
|
|
538
|
+
pool = others.mean(axis=1)
|
|
539
|
+
series = matrix[:, i]
|
|
540
|
+
if series.std() == 0 or pool.std() == 0:
|
|
541
|
+
scores[u] = -np.inf
|
|
542
|
+
continue
|
|
543
|
+
corr = float(np.corrcoef(series, pool)[0, 1])
|
|
544
|
+
scores[u] = corr if np.isfinite(corr) else -np.inf
|
|
545
|
+
return sorted(units, key=lambda u: (-scores[u], _candidate_key([u])))
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def enumerate_candidates(
|
|
549
|
+
panel: pl.DataFrame,
|
|
550
|
+
*,
|
|
551
|
+
unit: str,
|
|
552
|
+
time: str,
|
|
553
|
+
outcome: str,
|
|
554
|
+
sizes: Sequence[int],
|
|
555
|
+
include: Iterable[Any] = (),
|
|
556
|
+
exclude: Iterable[Any] = (),
|
|
557
|
+
max_per_size: int = 50,
|
|
558
|
+
rng: np.random.Generator | None = None,
|
|
559
|
+
) -> list[frozenset[Any]]:
|
|
560
|
+
"""Build candidate treated sets for :func:`rank_designs`.
|
|
561
|
+
|
|
562
|
+
The combination space is large --- C(40, 3) is 9880 --- so candidates are
|
|
563
|
+
drawn from units the donor pool explains well, ranked by
|
|
564
|
+
:func:`_rank_by_similarity`, and capped per size.
|
|
565
|
+
|
|
566
|
+
This is a geoexp construct. It is **not** a reimplementation of R GeoLift's
|
|
567
|
+
selection step, which uses its own (unpublished) combination of correlation
|
|
568
|
+
and dynamic time warping; no parity test compares the two candidate lists.
|
|
569
|
+
Its output is a plain list of sets, so a caller who wants different
|
|
570
|
+
candidates can build them any way they like and pass them straight to
|
|
571
|
+
``rank_designs``.
|
|
572
|
+
|
|
573
|
+
Parameters
|
|
574
|
+
----------
|
|
575
|
+
panel : polars.DataFrame
|
|
576
|
+
Long-format panel.
|
|
577
|
+
unit, time, outcome : str
|
|
578
|
+
Column names.
|
|
579
|
+
sizes : sequence of int
|
|
580
|
+
Treated-set sizes to generate, e.g. ``[1, 2, 3]``.
|
|
581
|
+
include : iterable of object, optional
|
|
582
|
+
Units that must appear in every candidate.
|
|
583
|
+
exclude : iterable of object, optional
|
|
584
|
+
Units that must never be treated. They stay in the donor pool.
|
|
585
|
+
max_per_size : int, default 50
|
|
586
|
+
Cap on candidates generated per size.
|
|
587
|
+
rng : numpy.random.Generator, optional
|
|
588
|
+
When given, candidates are sampled at random from the eligible pool
|
|
589
|
+
instead of taken in similarity order. Sampling is reproducible for a
|
|
590
|
+
given generator. Random sampling risks interpolation bias unless the
|
|
591
|
+
units are already similar; prefer the deterministic default until you
|
|
592
|
+
have checked that.
|
|
593
|
+
|
|
594
|
+
Returns
|
|
595
|
+
-------
|
|
596
|
+
list of frozenset
|
|
597
|
+
Candidate treated sets, ready to pass as ``candidates``.
|
|
598
|
+
|
|
599
|
+
Raises
|
|
600
|
+
------
|
|
601
|
+
ValueError
|
|
602
|
+
If a size is not positive, exceeds the eligible pool, or is smaller
|
|
603
|
+
than ``include``; or if ``include``/``exclude`` name unknown units.
|
|
604
|
+
"""
|
|
605
|
+
all_units = list(panel.get_column(unit).unique().sort())
|
|
606
|
+
required = list(dict.fromkeys(include))
|
|
607
|
+
excluded = set(map(_candidate_key, ([e] for e in exclude)))
|
|
608
|
+
for name, given in (("include", required), ("exclude", list(exclude))):
|
|
609
|
+
unknown = [u for u in given if u not in all_units]
|
|
610
|
+
if unknown:
|
|
611
|
+
raise ValueError(f"{name} names units absent from the panel: {unknown}")
|
|
612
|
+
|
|
613
|
+
eligible = [u for u in all_units if _candidate_key([u]) not in excluded and u not in required]
|
|
614
|
+
ranked = _rank_by_similarity(panel, unit=unit, time=time, outcome=outcome, units=eligible)
|
|
615
|
+
|
|
616
|
+
out: list[frozenset[Any]] = []
|
|
617
|
+
for size in sizes:
|
|
618
|
+
if size <= 0:
|
|
619
|
+
raise ValueError(f"sizes must be positive, got {size}")
|
|
620
|
+
free = size - len(required)
|
|
621
|
+
if free < 0:
|
|
622
|
+
raise ValueError(f"size {size} is smaller than include ({len(required)} units)")
|
|
623
|
+
if free > len(ranked):
|
|
624
|
+
raise ValueError(
|
|
625
|
+
f"size {size} exceeds the eligible pool ({len(ranked)} units after include/exclude)"
|
|
626
|
+
)
|
|
627
|
+
out.extend(_combinations(ranked, required, free, max_per_size, rng))
|
|
628
|
+
return out
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _combinations(
|
|
632
|
+
ranked: Sequence[Any],
|
|
633
|
+
required: Sequence[Any],
|
|
634
|
+
free: int,
|
|
635
|
+
max_per_size: int,
|
|
636
|
+
rng: np.random.Generator | None,
|
|
637
|
+
) -> list[frozenset[Any]]:
|
|
638
|
+
"""Pick up to ``max_per_size`` combinations of ``free`` units from ``ranked``."""
|
|
639
|
+
if rng is None:
|
|
640
|
+
picked = itertools.islice(itertools.combinations(ranked, free), max_per_size)
|
|
641
|
+
return [frozenset([*required, *combo]) for combo in picked]
|
|
642
|
+
|
|
643
|
+
seen: set[tuple[Any, ...]] = set()
|
|
644
|
+
for _ in range(max_per_size * 50):
|
|
645
|
+
if len(seen) >= max_per_size:
|
|
646
|
+
break
|
|
647
|
+
idx = rng.choice(len(ranked), size=free, replace=False)
|
|
648
|
+
seen.add(tuple(sorted((ranked[int(i)] for i in idx), key=lambda u: _candidate_key([u]))))
|
|
649
|
+
ordered = sorted(seen, key=lambda combo: _candidate_key(combo))
|
|
650
|
+
return [frozenset([*required, *combo]) for combo in ordered]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: geoexp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Geo-experiment design on top of augsynth-py: market selection, power-based design ranking, and experiment reporting.
|
|
5
|
+
Project-URL: Repository, https://github.com/mrcsvg/geoexp
|
|
6
|
+
Project-URL: Issues, https://github.com/mrcsvg/geoexp/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/mrcsvg/geoexp/blob/main/CHANGELOG.md
|
|
8
|
+
Author: geoexp contributors
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 geoexp contributors
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: causal-inference,geo-experiments,market-selection,marketing-science,synthetic-control
|
|
32
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
33
|
+
Classifier: Intended Audience :: Science/Research
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
38
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
39
|
+
Classifier: Typing :: Typed
|
|
40
|
+
Requires-Python: >=3.11
|
|
41
|
+
Requires-Dist: augsynth-py>=0.5.0
|
|
42
|
+
Requires-Dist: joblib>=1.3
|
|
43
|
+
Requires-Dist: numpy>=1.26
|
|
44
|
+
Requires-Dist: polars>=1.0
|
|
45
|
+
Provides-Extra: dev
|
|
46
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
47
|
+
Requires-Dist: pre-commit>=3.7; extra == 'dev'
|
|
48
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
49
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
50
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
51
|
+
Provides-Extra: validation
|
|
52
|
+
Requires-Dist: rpy2>=3.5; extra == 'validation'
|
|
53
|
+
Description-Content-Type: text/markdown
|
|
54
|
+
|
|
55
|
+
# geoexp
|
|
56
|
+
|
|
57
|
+
Geo-experiment **design** on top of
|
|
58
|
+
[augsynth-py](https://github.com/mrcsvg/augsynth-py): market selection,
|
|
59
|
+
power-based design ranking, and experiment reporting.
|
|
60
|
+
|
|
61
|
+
The boundary between the two packages is *"given a design"* vs. *"choose a
|
|
62
|
+
design"*:
|
|
63
|
+
|
|
64
|
+
- **augsynth-py** answers *what can this design detect?* — synthetic-control
|
|
65
|
+
estimators, conformal inference, and power/MDE for a treated set the caller
|
|
66
|
+
already fixed.
|
|
67
|
+
- **geoexp** (this package) answers *which design should I run?* — ranking
|
|
68
|
+
candidate treated sets x durations by what each can detect, with
|
|
69
|
+
GeoLift-shaped reporting. It consumes augsynth-py through its public API
|
|
70
|
+
only.
|
|
71
|
+
|
|
72
|
+
## Quickstart
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import numpy as np
|
|
76
|
+
import polars as pl
|
|
77
|
+
from augsynth_py import AugSynth
|
|
78
|
+
|
|
79
|
+
from geoexp import enumerate_candidates, rank_designs
|
|
80
|
+
|
|
81
|
+
# Build candidates, or pass your own explicit sets.
|
|
82
|
+
candidates = enumerate_candidates(
|
|
83
|
+
panel,
|
|
84
|
+
unit="location",
|
|
85
|
+
time="time",
|
|
86
|
+
outcome="Y",
|
|
87
|
+
sizes=[1, 2],
|
|
88
|
+
max_per_size=15,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
res = rank_designs(
|
|
92
|
+
panel, # long format: unit, time, outcome
|
|
93
|
+
estimator=AugSynth(lambda_=1.0), # freeze the penalty for large grids
|
|
94
|
+
unit="location",
|
|
95
|
+
time="time",
|
|
96
|
+
outcome="Y",
|
|
97
|
+
candidates=candidates,
|
|
98
|
+
durations=[15, 30],
|
|
99
|
+
lookback_window=10,
|
|
100
|
+
permutation_type="iid",
|
|
101
|
+
rng=np.random.default_rng(7),
|
|
102
|
+
n_jobs=-1, # parallel across candidates
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
res.ranking # one row per candidate x duration, best first
|
|
106
|
+
res.power("chicago, portland", 15) # the underlying augsynth_py.PowerResults
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`res.ranking` carries `candidate`, `duration`, `n_units`, `mde`, `mde_grid`,
|
|
110
|
+
`power_at_target`, `empirical_size`, `rmspe_pre`, `null_bias`,
|
|
111
|
+
`h1_calibration_error`, `proportion_total_y`, `holdout`, `rank`, and
|
|
112
|
+
`investment` when `cpic` is given.
|
|
113
|
+
|
|
114
|
+
Two things worth knowing before you read the output:
|
|
115
|
+
|
|
116
|
+
- **`mde` is interpolated off the power curve**, not a grid point, because the
|
|
117
|
+
grid value leaves most candidates tied. It is a geoexp construct and is not
|
|
118
|
+
R GeoLift's `Average_MDE`. `mde_grid` carries the upstream grid point.
|
|
119
|
+
- **Rank order by `mde` is stable within an implementation, not across them.**
|
|
120
|
+
Small differences near ties carry no information. See
|
|
121
|
+
[`docs/methodology.md`](docs/methodology.md).
|
|
122
|
+
- **`null_bias` and `h1_calibration_error` are near-duplicates** on
|
|
123
|
+
well-behaved panels; they separate only when the estimator's bias depends on
|
|
124
|
+
the effect size.
|
|
125
|
+
|
|
126
|
+
## Scope
|
|
127
|
+
|
|
128
|
+
0.1.x is single-cell market selection: candidate enumeration by similarity,
|
|
129
|
+
power-based ranking, and cost per incremental conversion. Budget-constrained
|
|
130
|
+
selection is 0.2.x; multi-cell designs are 0.3.x+.
|
|
131
|
+
|
|
132
|
+
`enumerate_candidates` is a convenience, not a gate — `rank_designs` takes any
|
|
133
|
+
list of sets, so bring your own candidates if you prefer.
|
|
134
|
+
|
|
135
|
+
## Validation
|
|
136
|
+
|
|
137
|
+
Every feature with a reproducible R `GeoLiftMarketSelection` counterpart is
|
|
138
|
+
asserted against it via rpy2 on the `GeoLift_PreTest` fixture. Parity is
|
|
139
|
+
established on the power curve at 240 cells (40 candidates x 6 effect sizes):
|
|
140
|
+
the two implementations agree with each other as well as either agrees with
|
|
141
|
+
itself, with no systematic bias. Features with no reproducible counterpart are
|
|
142
|
+
listed with their reason in
|
|
143
|
+
[`docs/geolift-oracle-characterization.md`](docs/geolift-oracle-characterization.md).
|
|
144
|
+
|
|
145
|
+
Clean-room: the R `GeoLift` package is a numerical oracle only, driven via
|
|
146
|
+
rpy2. Its source is never read for implementation and never translated.
|
|
147
|
+
|
|
148
|
+
## Development and AI use
|
|
149
|
+
|
|
150
|
+
This package was written by its maintainer working with
|
|
151
|
+
[Claude Code](https://claude.com/claude-code), Anthropic's coding agent. That
|
|
152
|
+
is disclosed here because it should shape how you read the rest of this page,
|
|
153
|
+
not as a novelty.
|
|
154
|
+
|
|
155
|
+
The working rule throughout was that nothing gets asserted that was not
|
|
156
|
+
measured. The parity figures above come from real runs against the R oracle and
|
|
157
|
+
are reproducible from `tests/validation_against_r/`; the noise floor they are
|
|
158
|
+
judged against was measured rather than assumed, after fixed thresholds
|
|
159
|
+
calibrated on a single draw turned out to flake. Where a quantity could not be
|
|
160
|
+
validated — GeoLift's `Average_MDE` and most of its ranking columns — that is
|
|
161
|
+
recorded as a limitation in
|
|
162
|
+
[`docs/geolift-oracle-characterization.md`](docs/geolift-oracle-characterization.md)
|
|
163
|
+
rather than papered over, and a cross-package benchmark that did not work is
|
|
164
|
+
written up there as a negative result instead of being quietly dropped. The
|
|
165
|
+
public API was designed and signed off before implementation, the
|
|
166
|
+
implementation was written test-first, and the clean-room rule stated above
|
|
167
|
+
held throughout: the R GeoLift source was never read.
|
|
168
|
+
|
|
169
|
+
## Install
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
pip install geoexp
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Requires `augsynth-py>=0.5.0` (the release that froze the power-API contract
|
|
176
|
+
this package is written against).
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT. No association with Meta's GeoLift is implied; the R package is used
|
|
181
|
+
strictly as a numerical validation oracle.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
geoexp/__init__.py,sha256=LVs1XJZD_d7qX10caj3FautDU-32ZAoUrR5K_kLMbv0,610
|
|
2
|
+
geoexp/_version.py,sha256=-UYTpK8lRuzcw9BEWkdBHyJiS_vjvg0P06Wo2xs--us,77
|
|
3
|
+
geoexp/exceptions.py,sha256=MEWF2k7owxp3e5Xd3oUqmYOFegqianVjJ7VzWTconYY,311
|
|
4
|
+
geoexp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
geoexp/selection.py,sha256=AYyq6dodPw31cGxz6g95RKTMiCy_Q7qZ8aIduMskb0c,24534
|
|
6
|
+
geoexp-0.1.0.dist-info/METADATA,sha256=ly1OROV495tBdKn8N2qNbWnS7zKktt0-GoBwWwFb948,7553
|
|
7
|
+
geoexp-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
geoexp-0.1.0.dist-info/licenses/LICENSE,sha256=TZhXXyGQvWhYducuemaE2sMNNqbhB6kxhi6EBzsIZS8,1076
|
|
9
|
+
geoexp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 geoexp contributors
|
|
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.
|