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 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
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version."""
2
+
3
+ __version__ = "0.1.0"
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.