Skip to content

feat: add power_analysis() with sigmoid fitting strategy (#820)#989

Open
genrichez wants to merge 5 commits into
pymc-labs:mainfrom
genrichez:feature/power-analysis-sigmoid
Open

feat: add power_analysis() with sigmoid fitting strategy (#820)#989
genrichez wants to merge 5 commits into
pymc-labs:mainfrom
genrichez:feature/power-analysis-sigmoid

Conversation

@genrichez

Copy link
Copy Markdown

Summary

Implements efficient power curve estimation for geo-experiment design, as requested in #820.

What's New

power_analysis() function

Computes detection probability as a function of true effect size, using the learned null distribution from a completed PlaceboInTime check. Two strategies:

  • grid — evaluate at every user-supplied effect size (brute-force Monte Carlo)
  • sigmoid — evaluate at ~5 points, fit a two-parameter logistic, extract MDE analytically

New public API

  • causalpy.checks.power_analysis(pit_result, ...) — main entry point
  • PowerCurveResult — dataclass with .plot() method
  • LogisticFit — fitted logistic with .predict() and .mde() methods

Usage

import causalpy as cp

# After running a PlaceboInTime check:
# result = pipeline.run()
# pit_check = result.sensitivity_results[0]

# Grid strategy (brute-force)
curve = cp.checks.power_analysis(
    pit_check,
    effect_sizes=[0, 50, 100, 150, 200, 300],
    n_simulations=200,
    strategy="grid",
)
curve.plot()

# Sigmoid strategy (efficient)
curve = cp.checks.power_analysis(
    pit_check,
    effect_sizes=[0, 300],  # range bounds
    n_simulations=200,
    strategy="sigmoid",
    n_evaluation_points=5,
)
print(f"MDE at 80% power: {curve.mde:.2f}")
curve.plot()

Validation

Compared 5-point sigmoid vs 20-point grid (ground truth):

Metric Value
Max absolute error 1.4%
Mean absolute error 0.6%
Speedup (5 pts vs 20 pts) 3.8x
Computation reduction 74%

Robust across different null parameter regimes (null_std 20–200, rope 10–50).

Tests

42 tests total (27 pytest + 15 edge case/business logic), covering:

  • Logistic function properties and LogisticFit predict/MDE extraction
  • Detection rate simulation (monotonicity, bounds)
  • Grid strategy (structure, reproducibility, increasing detection)
  • Sigmoid strategy (fitting, MDE, smooth curve, default ranges)
  • Error handling (missing metadata, invalid strategy)
  • Plotting (grid, sigmoid, custom axes)
  • Edge cases: all-zero/all-saturated detection, very few null samples, negative effects, low n_simulations, heterogeneous fold SDs, degenerate ROPE (rope=0), biased null, single effect size, very large effects (1e9, no overflow), partial transition sigmoid, threshold sensitivity

Design Notes

  • The function operates on a completed PlaceboInTime CheckResult, leveraging the learned null distribution (null_samples, fold_sds) to simulate detection at hypothetical effect sizes using the same ROPE-based Bayesian decision rule.
  • Detection is one-sided (P(posterior > rope) >= threshold), consistent with how PlaceboInTime assurance works. Users wanting two-sided power should use |effect_size|.
  • The sigmoid model is P(detect | x) = 1 / (1 + exp(-k*(x - x0))). MDE is extracted analytically: x0 + (1/k) * ln(tau / (1 - tau)).
  • When sigmoid fitting fails (e.g., flat data), a warning is emitted and raw evaluation points are returned without a fitted curve.

Closes #820

Add efficient power curve estimation for geo-experiment design.
Two strategies are supported:

- 'grid': brute-force Monte Carlo at every user-supplied effect size
- 'sigmoid': evaluate at ~5 points, fit a two-parameter logistic,
  and extract the MDE analytically (74% computation reduction)

The function operates on a completed PlaceboInTime CheckResult,
leveraging the learned null distribution to simulate detection
probability at hypothetical effect sizes using the same ROPE-based
Bayesian decision rule.

New public API:
- power_analysis() function
- PowerCurveResult dataclass (with .plot() method)
- LogisticFit dataclass (with .predict() and .mde() methods)

Validation (5-point sigmoid vs 20-point grid):
- Max absolute error: 1.4%
- Mean absolute error: 0.6%
- Speedup: 3.8x (74% computation reduction)

Closes pymc-labs#820
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

👋 Welcome to CausalPy, @genrichez!

Thank you for opening your first pull request! We're excited to have you contribute to the project. 🎉

Here are a few tips to help your PR get merged smoothly:

  • ✅ Make sure all CI checks pass (tests, linting, type checking)
  • 📝 Run prek run --all-files locally before pushing
  • 📖 Check our Contributing Guide for more details

A maintainer will review your changes soon. Thanks for helping make CausalPy better! 🚀


💼 LinkedIn Shoutout: Once your PR is merged, we'd love to give you a shoutout on LinkedIn to thank you for your contribution! If you're interested, just drop your LinkedIn profile URL in a comment below.

@github-actions github-actions Bot added the enhancement New feature or request label Jul 9, 2026
@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.33333% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.48%. Comparing base (642f177) to head (0ef5031).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
causalpy/checks/power_analysis.py 97.91% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #989      +/-   ##
==========================================
+ Coverage   95.41%   95.48%   +0.07%     
==========================================
  Files          97       99       +2     
  Lines       15667    15967     +300     
  Branches      920      936      +16     
==========================================
+ Hits        14948    15246     +298     
- Misses        507      509       +2     
  Partials      212      212              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

… branch coverage tests

- Moved test_power_analysis.py to causalpy/tests/ (where CI discovers tests)
- Added pragma no cover to plot() method (standard for visualization code)
- Added 5 new tests covering: fold_results fallback, empty fold_results error,
  sigmoid with >2 effect sizes, MDE outside range warning, fitting failure
- Patch coverage now 98% (was 24.59% due to test file in wrong location)

@daimon-pymclabs daimon-pymclabs left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the sigmoid idea is well-motivated and the module is clean, well-documented, and comes with a genuinely thorough test suite (nice that the tests are pure-numpy against a hand-built CheckResult, so CI stays fast with no MCMC). I verified the metadata contract: PlaceboInTime.run() does emit null_samples, fold_sds, rope_half_width, and threshold, so the function wires up correctly against real output. A few things worth discussing before merge, one design-level and a handful of code-level.

Design: is this the API we want?

The biggest question is scope alignment. Issue #820 framed this as adding a strategy="sigmoid" parameter to the existing SyntheticControl.power_analysis() method introduced in #819, and #819 is still open. This PR instead ships a standalone free function cp.checks.power_analysis(pit_result, ...) that operates on a completed PlaceboInTime result rather than a SyntheticControl experiment. So we'd end up with two parallel power-analysis surfaces that don't share code or signature:

  • SyntheticControl.power_analysis(...) (method, from #819)
  • causalpy.checks.power_analysis(pit_result, ...) (function, this PR)

That's worth reconciling explicitly. Options I see: (a) land the sigmoid strategy inside the #819 method as the issue proposed; (b) keep the free function but factor the shared sigmoid-fitting + LogisticFit + PowerCurveResult into one place both call; (c) decide the two are genuinely different tools (SC dress-rehearsal vs PlaceboInTime learned-null) and document why. Right now #820's acceptance criteria aren't literally met, so I'd want @juanitorduz to confirm which direction is intended before investing more in either.

Statistical: null-component sampling (shared with _compute_assurance)

The detection simulation draws the null component as null_samples[i % len(null_samples)] (deterministic prefix), so with the typical n_simulations=200 against ~4000 theta_new draws you only ever use the first 200 samples. Those draws come from posterior_predictive["theta_new"].stack(sample=("chain","draw")), which orders chain-slowest — so the first 200 are all from chain 0. random_seed doesn't change which null draws are used (it only affects the sigma pick and the normal noise). This is a real (mild) bias and it's shared with the existing PlaceboInTime._compute_assurance, which uses the identical i % len idiom. Suggest fixing it centrally with rng.choice(null_samples, size=n_simulations, replace=True) and reusing that in both places. See inline note.

Correctness note (in the PR's favor)

The analytic MDE in code is x0 + (1/k)·ln(tau/(1-tau)), which is the correct inversion (MDE > x0 for tau>0.5). Note the issue text wrote ln((1-tau)/tau), which has the sign flipped — the code is right, the issue was wrong, good catch by whoever implemented it.

Smaller items (details inline)

  • Decision rule is re-implemented inline instead of reusing PlaceboInTime.bayesian_rope_decision; they can drift.
  • except RuntimeError around curve_fit is too narrow (scipy raises ValueError/OptimizeWarning on degenerate data too), and LogisticFit.mde divides by k with no guard for k → 0.
  • Docstring defaults for effect_sizes don't match the code (4*tau vs max(4*tau, 3*rope)).
  • The fold_resultsfr.fold_sd fallback is effectively dead against real output (whenever null_samples exists, fold_sds does too), only reachable via hand-built metadata.
  • No end-to-end smoke test running an actual tiny PlaceboInTime.run() into power_analysis — all tests use a synthetic CheckResult, so metadata-shape drift wouldn't be caught.

None of the code-level items are blockers; the design question is the one I'd resolve first.

Comment thread causalpy/checks/power_analysis.py Outdated
detections = 0
for i in range(n_simulations):
# Draw null component (structural noise)
null_component = float(null_samples[i % len(null_samples)])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Null draw is a deterministic prefix, not seeded. null_samples[i % len(null_samples)] walks the first n_simulations entries in order. With n_simulations=200 and ~4000 theta_new draws, only the first 200 are ever used, and because theta_new is stack(sample=("chain","draw")) (chain-slowest) those 200 all come from chain 0. random_seed never touches this draw. Prefer rng.choice(null_samples, size=n_simulations, replace=True).

This exact idiom also lives in PlaceboInTime._compute_assurance, so ideally fix once and share (a small module-level _draw_null_components(rng, null_samples, n) helper both call) rather than duplicating the biased version here.

loc=true_effect, scale=sigma, size=n_posterior_samples
)
# Apply ROPE decision
prob_positive = float((simulated_posterior > rope_half_width).mean())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ROPE decision is re-implemented inline (prob_positive >= threshold) instead of calling the existing PlaceboInTime.bayesian_rope_decision(...) == "positive". It's numerically equivalent to the one-sided "positive" branch today, but if the shared decision rule ever changes (e.g. ROPE semantics), this copy silently drifts. Reusing the static method keeps power analysis and assurance consistent by construction.

Comment thread causalpy/checks/power_analysis.py Outdated
fold_results = meta.get("fold_results", [])
if not fold_results:
raise ValueError("pit_result has no fold_sds or fold_results in metadata.")
fold_sds_raw = [fr.fold_sd for fr in fold_results]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fold_resultsfr.fold_sd fallback is effectively unreachable against real PlaceboInTime output: whenever null_samples is present (required a few lines up), the run also stored fold_sds. The 0-folds-completed path that lacks fold_sds also lacks null_samples, so it fails the earlier check first. It's only exercised by hand-built metadata in the tests. Not harmful, but worth a comment noting it's a defensive path, or drop it.

Comment thread causalpy/checks/power_analysis.py Outdated
# Set up effect sizes based on strategy
if strategy == "grid":
if effect_sizes is None:
effect_sizes_arr = np.linspace(0, max(4.0 * tau, 3.0 * rope_half_width), 8)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstring drift: the docstring says the default is np.linspace(0, 4 * tau, 8) (grid) and [0, 4 * tau] (sigmoid), but the code uses max(4.0 * tau, 3.0 * rope_half_width) as the upper bound in both. Sync the docstring so users know the ROPE term participates in the default range.

Comment thread causalpy/checks/power_analysis.py Outdated
f"Consider widening the effect_sizes range.",
stacklevel=2,
)
except RuntimeError as e:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

except RuntimeError is too narrow. scipy.optimize.curve_fit raises ValueError on degenerate inputs (NaNs, inconsistent bounds/p0) and can emit OptimizeWarning while returning a junk fit rather than raising at all. The test_sigmoid_fitting_failure_warns case happens to land on a RuntimeError, but flat/constant detection rates can surface as ValueError. Suggest except (RuntimeError, ValueError).

Related: LogisticFit.mde computes (1.0 / self.k) * ... with k bounded [0, inf]. If the fit returns k == 0, this yields inf (numpy) or raises (python float); either way mde is meaningless. Consider guarding k <= 0 and emitting the same 'fitting failed' warning path.

@juanitorduz juanitorduz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @genrichez ! Thank you vary much for your contribution. I left some innitial comments on the code. I will take a detailed look into the logic next week :)

Comment thread causalpy/tests/test_power_analysis.py Outdated
Comment thread causalpy/tests/test_power_analysis.py Outdated
Comment thread causalpy/tests/test_power_analysis.py Outdated
Comment on lines +95 to +97
# ---------------------------------------------------------------------------
# Tests for LogisticFit
# ---------------------------------------------------------------------------

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and this one

Comment thread causalpy/tests/test_power_analysis.py Outdated
Comment thread causalpy/tests/test_power_analysis.py Outdated
Comment thread causalpy/checks/power_analysis.py Outdated
Comment thread causalpy/checks/power_analysis.py Outdated
def plot( # pragma: no cover
self,
power_threshold: float = 0.80,
ax: Any = None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we have better type hints? Something like pllt.Axes | None = None

Comment thread causalpy/checks/power_analysis.py Outdated
Comment thread causalpy/checks/power_analysis.py
Comment thread causalpy/checks/power_analysis.py
@juanitorduz

Copy link
Copy Markdown
Collaborator

by the way daimon-pymclabs in an internal agent reviewer ;)

@genrichez genrichez requested a review from juanitorduz July 13, 2026 10:41
@genrichez

Copy link
Copy Markdown
Author

by the way daimon-pymclabs in an internal agent reviewer ;)

Got it :)
Thanks for the feedback , implemented the changes, could you please re-review, thanks!

@daimon-pymclabs daimon-pymclabs left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review after the latest push (a600dfd). Thanks @genrichez — the split into _simulate_detection_rate / _extract_null_distribution / _build_effect_sizes / _fit_sigmoid reads much better. Two of my earlier points are confirmed resolved:

  • Null draw is now rng.choice(null_samples) (was the deterministic first-n prefix walk) — properly seeded and it samples the whole null distribution rather than a chain-slow prefix.
  • _fit_sigmoid now catches (RuntimeError, ValueError), so degenerate curve_fit inputs (NaNs, inconsistent bounds/p0) no longer escape.

One thing still outstanding, plus two optional nits:

  1. Docstring drift (please fix). The effect_sizes docstring still says the default is np.linspace(0, 4 * tau, 8) (grid) / [0, 4 * tau] (sigmoid), but _build_effect_sizes uses max(4.0 * tau, 3.0 * rope_half_width) as the upper bound in both branches. Worth syncing the docstring so users know the ROPE half-width participates in the default range.
  2. (optional) The ROPE decision is still inlined as (simulated_posterior > rope_half_width).mean() >= threshold rather than reusing PlaceboInTime.bayesian_rope_decision. Numerically equivalent to the one-sided "positive" branch today; only matters if the shared rule ever diverges.
  3. (optional) The fold_sdsfold_results/fr.fold_sd fallback in _extract_null_distribution is still effectively unreachable against real PlaceboInTime output — a result carrying null_samples always carries fold_sds too. Harmless as defensive code.

Nothing blocking from my side beyond the docstring. Deferring to @juanitorduz on the detailed logic pass.

@drbenvincent

Copy link
Copy Markdown
Collaborator

Automated triage

Recommendation: review:high — no decision gate identified.

Why:

  • New public API: causalpy.checks.power_analysis() function, PowerCurveResult dataclass, and LogisticFit class.
  • New module causalpy/checks/power_analysis.py (545 lines) with grid and sigmoid strategies.
  • 556 lines of tests; all CI checks pass; PR is blocked (merge conflict).
  • Review already has requested changes — on-going review.

Review focus:

  1. Verify the sigmoid fitting strategy is numerically robust (handles flat data, degenerate ROPE, edge cases).
  2. Confirm the public API integrates cleanly with the existing checks framework.
  3. Check that the PlaceboInTime dependency assumptions are documented.

Confidence: high

@drbenvincent drbenvincent added the review:high High-impact change requiring thorough human review label Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request review:high High-impact change requiring thorough human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Efficient power curve estimation via sigmoid fitting

4 participants