Skip to content

Binomial Proportion Intervals

See the fixed-sample guide for assumptions, precision planning and optional indicator summary metadata.

trade_study.ProportionInterval(k, n, estimate, low, high, confidence, method) dataclass

Two-sided, marginal interval for a fixed-sample Bernoulli probability.

Wilson coverage is approximate; Clopper-Pearson is conservative under independent identically distributed Bernoulli sampling at a fixed n. Neither method supplies a sequential or post-selection guarantee.

Attributes:

Name Type Description
k int

Number of successes.

n int

Number of trials.

estimate float

Observed proportion k / n, not the Wilson interval center.

low float

Lower probability bound.

high float

Upper probability bound.

confidence float

Nominal two-sided confidence level.

method str

Wilson without continuity correction, or Clopper-Pearson.

trade_study.proportion_interval(k, n, *, confidence=0.95, method='wilson')

Estimate a binomial proportion and its two-sided fixed-sample interval.

Trials must be independent Bernoulli outcomes with a common probability and a prespecified sample size. Callers establish these assumptions; counts alone cannot verify them. Intervals are marginal, without a multiplicity adjustment, and are not valid for arbitrary optional stopping.

Parameters:

Name Type Description Default
k int

Integer successes, including NumPy integer scalars; 0 <= k <= n.

required
n int

Positive integer trial count, at most 2**53 for float64 arithmetic.

required
confidence float

Nominal level in (0, 1), default 0.95.

0.95
method str

"wilson" without continuity correction (core), or "clopper-pearson" using the optional stats extra.

'wilson'

Returns:

Type Description
ProportionInterval

Observed estimate and interval with counts and method recorded.

Raises:

Type Description
ValueError

If counts, confidence or method are invalid or unsupported.

ImportError

If Clopper-Pearson is requested without the stats extra.

Examples:

>>> interval = proportion_interval(8, 10)
>>> interval.estimate, interval.n, interval.method
(0.8, 10, 'wilson')
>>> interval.low < interval.estimate < interval.high
True
Source code in src/trade_study/proportions.py
def proportion_interval(
    k: int,
    n: int,
    *,
    confidence: float = 0.95,
    method: str = "wilson",
) -> ProportionInterval:
    """Estimate a binomial proportion and its two-sided fixed-sample interval.

    Trials must be independent Bernoulli outcomes with a common probability
    and a prespecified sample size. Callers establish these assumptions;
    counts alone cannot verify them. Intervals are marginal, without a
    multiplicity adjustment, and are not valid for arbitrary optional stopping.

    Args:
        k: Integer successes, including NumPy integer scalars; 0 <= k <= n.
        n: Positive integer trial count, at most 2**53 for float64 arithmetic.
        confidence: Nominal level in (0, 1), default 0.95.
        method: "wilson" without continuity correction (core), or
            "clopper-pearson" using the optional stats extra.

    Returns:
        Observed estimate and interval with counts and method recorded.

    Raises:
        ValueError: If counts, confidence or method are invalid or unsupported.
        ImportError: If Clopper-Pearson is requested without the stats extra.

    Examples:
        >>> interval = proportion_interval(8, 10)
        >>> interval.estimate, interval.n, interval.method
        (0.8, 10, 'wilson')
        >>> interval.low < interval.estimate < interval.high
        True
    """
    successes, trials = _count(k, "k"), _count(n, "n")
    if not 0 < trials <= _MAX_TRIALS or successes > trials:
        msg = "counts must satisfy 0 <= k <= n and 1 <= n <= 2**53"
        raise ValueError(msg)
    tail = _tail(confidence, method)
    estimate = successes / trials
    if method == "clopper-pearson":
        try:
            from scipy.special import (  # type: ignore[import-untyped]
                betainccinv,
                betaincinv,
            )
        except ImportError as error:
            msg = "Clopper-Pearson intervals require trade-study[stats] (SciPy >=1.11)"
            raise ImportError(msg) from error
        low = (
            0.0
            if successes == 0
            else float(betaincinv(successes, trials - successes + 1, tail))
        )
        high = (
            1.0
            if successes == trials
            else float(betainccinv(successes + 1, trials - successes, tail))
        )
        if not 0 <= low <= high <= 1:
            msg = "Clopper-Pearson beta inversion produced invalid bounds"
            raise ValueError(msg)
    else:
        z = -NormalDist().inv_cdf(tail)
        correction = z * z / trials
        center = (estimate + correction / 2) / (1 + correction)
        radius = (
            z
            * sqrt((estimate * (1 - estimate) + correction / 4) / trials)
            / (1 + correction)
        )
        low = 0.0 if successes == 0 else max(0.0, center - radius)
        high = 1.0 if successes == trials else min(1.0, center + radius)
    return ProportionInterval(
        successes, trials, estimate, low, high, float(confidence), method
    )

trade_study.replicates_for_half_width(half_width, *, confidence=0.95)

Plan a fixed trial count from the worst-case Wilson interval half-width.

The largest continuous Wilson half-width occurs at observed proportion 1/2 and equals z / (2 * sqrt(n + z**2)). Inverting this expression gives the smallest positive integer satisfying that conservative bound, even when n is odd. This is interval-precision planning, not power analysis, a guarantee of Wilson coverage, or a rule for stopping after each trial.

Parameters:

Name Type Description Default
half_width float

Target (high - low) / 2 in (0, 0.5]. This is not the largest distance from the observed estimate to a bound.

required
confidence float

Nominal two-sided level in (0, 1).

0.95

Returns:

Type Description
int

Planned integer n, at most 2**53. Fix n before collecting outcomes.

Raises:

Type Description
ValueError

If inputs are invalid or the count exceeds float64 support.

Examples:

>>> replicates_for_half_width(0.05)
381
>>> replicates_for_half_width(0.10) < replicates_for_half_width(0.05)
True
Source code in src/trade_study/proportions.py
def replicates_for_half_width(half_width: float, *, confidence: float = 0.95) -> int:
    """Plan a fixed trial count from the worst-case Wilson interval half-width.

    The largest continuous Wilson half-width occurs at observed proportion
    1/2 and equals z / (2 * sqrt(n + z**2)). Inverting this expression gives
    the smallest positive integer satisfying that conservative bound, even
    when n is odd. This is interval-precision planning, not power analysis,
    a guarantee of Wilson coverage, or a rule for stopping after each trial.

    Args:
        half_width: Target (high - low) / 2 in (0, 0.5]. This is not the
            largest distance from the observed estimate to a bound.
        confidence: Nominal two-sided level in (0, 1).

    Returns:
        Planned integer n, at most 2**53. Fix n before collecting outcomes.

    Raises:
        ValueError: If inputs are invalid or the count exceeds float64 support.

    Examples:
        >>> replicates_for_half_width(0.05)
        381
        >>> replicates_for_half_width(0.10) < replicates_for_half_width(0.05)
        True
    """
    tail = _tail(confidence, "wilson")
    if (
        isinstance(half_width, bool)
        or not isinstance(half_width, Real)
        or not isfinite(half_width)
        or not 0 < half_width <= 0.5
    ):
        msg = "half_width must be finite and lie in (0, 0.5]"
        raise ValueError(msg)
    z = -NormalDist().inv_cdf(tail)
    if half_width < z / (2 * sqrt(_MAX_TRIALS + z * z)):
        msg = "Requested half_width needs more than 2**53 trials"
        raise ValueError(msg)
    count = min(_MAX_TRIALS, max(1, ceil((z / (2 * half_width)) ** 2 - z * z)))
    # Correct the integer rounding at an exactly representable target width.
    while count < _MAX_TRIALS and z / (2 * sqrt(count + z * z)) > half_width:
        count += 1
    while count > 1 and z / (2 * sqrt(count - 1 + z * z)) <= half_width:
        count -= 1
    return count