Skip to content

SAFFRON: Adaptive Online FDR Control

SAFFRON (Serial estimate of the Alpha Fraction that is Futilely Rationed On true Nulls) is an adaptive algorithm for online FDR control that estimates the proportion of true null hypotheses to set more powerful rejection thresholds.

Original Paper

Ramdas, A., T. Zrnic, M. J. Wainwright, and M. I. Jordan. "SAFFRON: an adaptive algorithm for online control of the false discovery rate." Proceedings of the 35th International Conference on Machine Learning (ICML), Proceedings of Machine Learning Research, vol. 80, pp. 4286-4294, PMLR, 2018. [ArXiv] [ICML]

Overview

The Innovation

SAFFRON can be seen as an online analogue of the famous offline Storey-BH adaptive procedure. Just as Storey-BH typically achieves higher power than Benjamini-Hochberg by estimating the proportion of true nulls, SAFFRON typically achieves higher power than non-adaptive online methods like LORD.

Key Features

  1. Adaptive null proportion estimation - Uses candidate fraction to estimate alpha-wealth allocated to true nulls
  2. Alpha-wealth dynamics - Intelligently allocates wealth to different tests over time
  3. Proven FDR control - Provably controls FDR for independent p-values
  4. Superior power - Typically more powerful than non-adaptive counterparts

Algorithm Details

Core Mechanism

SAFFRON starts with alpha-wealth that it allocates to tests over time, earning back wealth on discoveries. Unlike older alpha-investing methods, SAFFRON's thresholds are based on estimating the alpha fraction allocated to true null hypotheses using the candidate threshold λ.

Wealth Dynamics

The algorithm maintains wealth that: - Starts at initial value W₀
- Is spent to purchase rejection thresholds - Is earned back from discoveries - Adapts based on estimated proportion of true nulls via candidates

Class Reference

online_fdr.investing.saffron.saffron.Saffron

Bases: AbstractSequentialTest

SAFFRON: Serial estimate of the Alpha Fraction that is Futilely Rationed On true Nulls.

SAFFRON is an adaptive algorithm for online FDR control that estimates the proportion of true null hypotheses and uses this estimate to set more powerful rejection thresholds. It can be seen as an online analogue of the famous offline Storey-BH adaptive procedure.

Like alpha-investing algorithms, SAFFRON starts with alpha-wealth that it intelligently allocates to different tests over time, earning back wealth on discoveries. However, unlike older methods, SAFFRON's threshold sequence is based on a novel estimate of the alpha fraction allocated to true null hypotheses.

SAFFRON typically achieves higher power than non-adaptive methods like LORD under independence, similar to how Storey-BH typically outperforms Benjamini-Hochberg.

Parameters:

Name Type Description Default
alpha float

Target FDR level (e.g., 0.05 for 5% FDR). Must be in (0, 1).

required
wealth float

Initial alpha-wealth for purchasing rejection thresholds. Must satisfy 0 ≤ wealth ≤ alpha.

required
lambda_ float

Candidate threshold for estimating the proportion of nulls. P-values ≤ lambda_ are considered "candidates". Must be in (0, 1).

required

Attributes:

Name Type Description
alpha0 float

Original target FDR level.

wealth0 float

Initial wealth allocation.

lambda_ float

Candidate threshold parameter.

num_test int

Number of hypotheses tested so far.

candidates list[bool]

Boolean list indicating which tests were candidates.

reject_idx list[int]

Indices of rejected hypotheses.

Examples:

>>> # Basic usage with recommended parameters
>>> saffron = Saffron(alpha=0.05, wealth=0.025, lambda_=0.5)
>>> decision = saffron.test_one(0.01)  # Test a small p-value
>>> print(f"Rejected: {decision}")
>>> # Sequential testing
>>> p_values = [0.001, 0.3, 0.02, 0.8, 0.005]
>>> decisions = [saffron.test_one(p) for p in p_values]
>>> discoveries = sum(decisions)
References

Ramdas, A., T. Zrnic, M. J. Wainwright, and M. I. Jordan (2018). "SAFFRON: an adaptive algorithm for online control of the FDR." Proceedings of the 35th International Conference on Machine Learning (ICML), Proceedings of Machine Learning Research, vol. 80, pp. 4286-4294, PMLR.

ArXiv: https://arxiv.org/abs/1802.09098 ICML: https://proceedings.mlr.press/v80/ramdas18a.html

Source code in online_fdr/investing/saffron/saffron.py
class Saffron(AbstractSequentialTest):
    """SAFFRON: Serial estimate of the Alpha Fraction that is Futilely Rationed On true Nulls.

    SAFFRON is an adaptive algorithm for online FDR control that estimates the proportion 
    of true null hypotheses and uses this estimate to set more powerful rejection thresholds. 
    It can be seen as an online analogue of the famous offline Storey-BH adaptive procedure.

    Like alpha-investing algorithms, SAFFRON starts with alpha-wealth that it intelligently 
    allocates to different tests over time, earning back wealth on discoveries. However, 
    unlike older methods, SAFFRON's threshold sequence is based on a novel estimate of the 
    alpha fraction allocated to true null hypotheses.

    SAFFRON typically achieves higher power than non-adaptive methods like LORD under 
    independence, similar to how Storey-BH typically outperforms Benjamini-Hochberg.

    Args:
        alpha: Target FDR level (e.g., 0.05 for 5% FDR). Must be in (0, 1).
        wealth: Initial alpha-wealth for purchasing rejection thresholds.
                Must satisfy 0 ≤ wealth ≤ alpha.  
        lambda_: Candidate threshold for estimating the proportion of nulls.
                 P-values ≤ lambda_ are considered "candidates". Must be in (0, 1).

    Attributes:
        alpha0: Original target FDR level.
        wealth0: Initial wealth allocation.
        lambda_: Candidate threshold parameter.
        num_test: Number of hypotheses tested so far.
        candidates: Boolean list indicating which tests were candidates.
        reject_idx: Indices of rejected hypotheses.

    Examples:
        >>> # Basic usage with recommended parameters
        >>> saffron = Saffron(alpha=0.05, wealth=0.025, lambda_=0.5)
        >>> decision = saffron.test_one(0.01)  # Test a small p-value
        >>> print(f"Rejected: {decision}")

        >>> # Sequential testing
        >>> p_values = [0.001, 0.3, 0.02, 0.8, 0.005]
        >>> decisions = [saffron.test_one(p) for p in p_values]
        >>> discoveries = sum(decisions)

    References:
        Ramdas, A., T. Zrnic, M. J. Wainwright, and M. I. Jordan (2018). 
        "SAFFRON: an adaptive algorithm for online control of the FDR." 
        Proceedings of the 35th International Conference on Machine Learning (ICML), 
        Proceedings of Machine Learning Research, vol. 80, pp. 4286-4294, PMLR.

        ArXiv: https://arxiv.org/abs/1802.09098
        ICML: https://proceedings.mlr.press/v80/ramdas18a.html
    """

    def __init__(self, alpha: float, wealth: float, lambda_: float):
        super().__init__(alpha)
        self.alpha0: float = alpha
        self.wealth0: float = wealth
        self.lambda_: float = lambda_

        validity.check_initial_wealth(wealth, alpha)
        validity.check_candidate_threshold(lambda_)

        self.num_test: int = 0
        self.candidates: list[bool] = []
        self.reject_idx: list[int] = []

        self.seq = DefaultSaffronGammaSequence(gamma_exp=1.6, c=0.4374901658)

    def test_one(self, p_val: float) -> bool:
        """Test a single p-value using the SAFFRON procedure.

        The SAFFRON algorithm processes p-values sequentially:
        1. Calculate adaptive rejection threshold based on candidate estimate
        2. Determine if p-value is a candidate (≤ lambda_) 
        3. Reject if p-value ≤ adaptive threshold

        Args:
            p_val: P-value to test. Must be in [0, 1].

        Returns:
            True if the null hypothesis is rejected (discovery), False otherwise.

        Raises:
            ValueError: If p_val is not in [0, 1].

        Examples:
            >>> saffron = Saffron(alpha=0.05, wealth=0.025, lambda_=0.5)
            >>> saffron.test_one(0.01)  # Small p-value, likely rejected
            True
            >>> saffron.test_one(0.8)   # Large p-value, not rejected
            False
        """
        validity.check_p_val(p_val)
        self.num_test += 1
        self.alpha = self.calc_alpha_t()

        is_candidate = p_val <= self.lambda_  # candidate
        self.candidates.append(is_candidate)

        is_rejected = p_val <= self.alpha  # rejection
        self.reject_idx.append(self.num_test) if is_rejected else None
        return is_rejected

    def calc_alpha_t(self):
        """Calculate the adaptive rejection threshold for the current test.

        The SAFFRON threshold is based on estimating the alpha-wealth allocated to 
        true null hypotheses using the candidate fraction. The threshold adapts based on:
        1. Initial wealth allocation and candidate estimate (first test)
        2. Updated wealth based on discoveries and candidate history (subsequent tests)
        3. Compensation factor (1 - lambda_) for candidate selection

        Returns:
            The adaptive rejection threshold alpha_t, bounded by lambda_.

        Note:
            This is an internal method called by test_one(). The threshold formula
            follows the SAFFRON procedure in Ramdas et al. (2018).
        """

        if self.num_test == 1:
            alpha_t = (
                (1 - self.lambda_)
                * self.seq.calc_gamma(1, None)  # fmt: skip
                * self.wealth0
            )
        else:
            alpha_t = self.wealth0 * self.seq.calc_gamma(
                self.num_test - sum(self.candidates), None
            )
            if len(self.reject_idx) >= 1:
                tau_1 = self.reject_idx[0]
                c_1_plus = sum(self.candidates[tau_1:])
                alpha_t += (self.alpha0 - self.wealth0) * self.seq.calc_gamma(
                    (self.num_test - tau_1 - c_1_plus), None
                )
            if len(self.reject_idx) >= 2:
                alpha_t += self.alpha0 * sum(
                    self.seq.calc_gamma(
                        (self.num_test - idx - sum(self.candidates[idx:])),
                        None,
                    )
                    for idx in self.reject_idx[1:]
                )
            alpha_t *= 1 - self.lambda_
        return min(self.lambda_, alpha_t)

Functions

test_one(p_val)

Test a single p-value using the SAFFRON procedure.

The SAFFRON algorithm processes p-values sequentially: 1. Calculate adaptive rejection threshold based on candidate estimate 2. Determine if p-value is a candidate (≤ lambda_) 3. Reject if p-value ≤ adaptive threshold

Parameters:

Name Type Description Default
p_val float

P-value to test. Must be in [0, 1].

required

Returns:

Type Description
bool

True if the null hypothesis is rejected (discovery), False otherwise.

Raises:

Type Description
ValueError

If p_val is not in [0, 1].

Examples:

>>> saffron = Saffron(alpha=0.05, wealth=0.025, lambda_=0.5)
>>> saffron.test_one(0.01)  # Small p-value, likely rejected
True
>>> saffron.test_one(0.8)   # Large p-value, not rejected
False
Source code in online_fdr/investing/saffron/saffron.py
def test_one(self, p_val: float) -> bool:
    """Test a single p-value using the SAFFRON procedure.

    The SAFFRON algorithm processes p-values sequentially:
    1. Calculate adaptive rejection threshold based on candidate estimate
    2. Determine if p-value is a candidate (≤ lambda_) 
    3. Reject if p-value ≤ adaptive threshold

    Args:
        p_val: P-value to test. Must be in [0, 1].

    Returns:
        True if the null hypothesis is rejected (discovery), False otherwise.

    Raises:
        ValueError: If p_val is not in [0, 1].

    Examples:
        >>> saffron = Saffron(alpha=0.05, wealth=0.025, lambda_=0.5)
        >>> saffron.test_one(0.01)  # Small p-value, likely rejected
        True
        >>> saffron.test_one(0.8)   # Large p-value, not rejected
        False
    """
    validity.check_p_val(p_val)
    self.num_test += 1
    self.alpha = self.calc_alpha_t()

    is_candidate = p_val <= self.lambda_  # candidate
    self.candidates.append(is_candidate)

    is_rejected = p_val <= self.alpha  # rejection
    self.reject_idx.append(self.num_test) if is_rejected else None
    return is_rejected

calc_alpha_t()

Calculate the adaptive rejection threshold for the current test.

The SAFFRON threshold is based on estimating the alpha-wealth allocated to true null hypotheses using the candidate fraction. The threshold adapts based on: 1. Initial wealth allocation and candidate estimate (first test) 2. Updated wealth based on discoveries and candidate history (subsequent tests) 3. Compensation factor (1 - lambda_) for candidate selection

Returns:

Type Description

The adaptive rejection threshold alpha_t, bounded by lambda_.

Note

This is an internal method called by test_one(). The threshold formula follows the SAFFRON procedure in Ramdas et al. (2018).

Source code in online_fdr/investing/saffron/saffron.py
def calc_alpha_t(self):
    """Calculate the adaptive rejection threshold for the current test.

    The SAFFRON threshold is based on estimating the alpha-wealth allocated to 
    true null hypotheses using the candidate fraction. The threshold adapts based on:
    1. Initial wealth allocation and candidate estimate (first test)
    2. Updated wealth based on discoveries and candidate history (subsequent tests)
    3. Compensation factor (1 - lambda_) for candidate selection

    Returns:
        The adaptive rejection threshold alpha_t, bounded by lambda_.

    Note:
        This is an internal method called by test_one(). The threshold formula
        follows the SAFFRON procedure in Ramdas et al. (2018).
    """

    if self.num_test == 1:
        alpha_t = (
            (1 - self.lambda_)
            * self.seq.calc_gamma(1, None)  # fmt: skip
            * self.wealth0
        )
    else:
        alpha_t = self.wealth0 * self.seq.calc_gamma(
            self.num_test - sum(self.candidates), None
        )
        if len(self.reject_idx) >= 1:
            tau_1 = self.reject_idx[0]
            c_1_plus = sum(self.candidates[tau_1:])
            alpha_t += (self.alpha0 - self.wealth0) * self.seq.calc_gamma(
                (self.num_test - tau_1 - c_1_plus), None
            )
        if len(self.reject_idx) >= 2:
            alpha_t += self.alpha0 * sum(
                self.seq.calc_gamma(
                    (self.num_test - idx - sum(self.candidates[idx:])),
                    None,
                )
                for idx in self.reject_idx[1:]
            )
        alpha_t *= 1 - self.lambda_
    return min(self.lambda_, alpha_t)

Usage Examples

Basic Usage

from online_fdr.investing.saffron.saffron import Saffron

# Create SAFFRON instance with recommended parameters
saffron = Saffron(alpha=0.05, wealth=0.025, lambda_=0.5)

# Test individual p-values
p_values = [0.001, 0.15, 0.03, 0.8, 0.02, 0.45, 0.006]

print("SAFFRON Online Testing:")
discoveries = []

for i, p_value in enumerate(p_values):
    decision = saffron.test_one(p_value)

    if decision:
        discoveries.append(i + 1)
        print(f"✓ Test {i+1}: p={p_value:.3f} → DISCOVERY!")
    else:
        print(f"  Test {i+1}: p={p_value:.3f} → no rejection")

print(f"\nTotal discoveries: {len(discoveries)}")
print(f"Discovery indices: {discoveries}")

Parameter Selection

# Conservative: Lower lambda for more selective candidate identification
conservative = Saffron(alpha=0.05, wealth=0.01, lambda_=0.25)

# Moderate: Balanced parameters (recommended starting point)  
moderate = Saffron(alpha=0.05, wealth=0.025, lambda_=0.5)

# Aggressive: Higher lambda for more candidates
aggressive = Saffron(alpha=0.05, wealth=0.05, lambda_=0.75)

# Test on same data for comparison
test_p_values = [0.001, 0.02, 0.15, 0.03, 0.8, 0.01]

for name, method in [("Conservative", conservative), 
                     ("Moderate", moderate), 
                     ("Aggressive", aggressive)]:
    decisions = [method.test_one(p) for p in test_p_values]
    print(f"{name}: {sum(decisions)} discoveries")

Working with Real Data Patterns

from online_fdr.utils.generation import DataGenerator, BetaMixtureModel

# Simulate realistic genomics-style data with conservative nulls
dgp = BetaMixtureModel(alt_alpha=0.3, alt_beta=5.0)  # Alternatives skewed toward 0
generator = DataGenerator(n=200, pi0=0.9, dgp=dgp)   # 90% true nulls

# Create SAFFRON instance
saffron = Saffron(alpha=0.1, wealth=0.05, lambda_=0.5)

print("SAFFRON on Realistic Data:")
print("=" * 35)

true_discoveries = 0
false_discoveries = 0

# Test first 50 hypotheses
for i in range(50):
    p_value, is_alternative = generator.sample_one()
    decision = saffron.test_one(p_value)

    if decision:
        if is_alternative:
            true_discoveries += 1
            result = "TRUE discovery ✓"
        else:
            false_discoveries += 1
            result = "FALSE discovery ✗"

        truth = "ALT" if is_alternative else "NULL"
        print(f"Test {i+1:2d}: p={p_value:.3f} ({truth}) → REJECT ({result})")

total_discoveries = true_discoveries + false_discoveries
empirical_fdr = false_discoveries / max(total_discoveries, 1)

print(f"\nSummary:")
print(f"True discoveries: {true_discoveries}")
print(f"False discoveries: {false_discoveries}")
print(f"Empirical FDR: {empirical_fdr:.3f}")
print(f"Target FDR: {saffron.alpha0}")

Comparison with Non-Adaptive Methods

from online_fdr.investing.lord.three import LordThree

def compare_adaptive_vs_nonadaptive(p_values):
    """Compare SAFFRON (adaptive) with LORD 3 (non-adaptive)."""

    print("Adaptive vs Non-Adaptive Comparison:")
    print("=" * 40)

    # Create methods
    saffron = Saffron(alpha=0.05, wealth=0.025, lambda_=0.5)
    lord3 = LordThree(alpha=0.05, wealth=0.025, reward=0.025)

    # Test both methods
    saffron_decisions = [saffron.test_one(p) for p in p_values]
    lord3_decisions = [lord3.test_one(p) for p in p_values]

    saffron_discoveries = sum(saffron_decisions)
    lord3_discoveries = sum(lord3_decisions)

    print(f"SAFFRON (adaptive): {saffron_discoveries} discoveries")
    print(f"LORD 3 (non-adaptive): {lord3_discoveries} discoveries")
    print(f"Power advantage: {saffron_discoveries - lord3_discoveries}")

    # Show which additional discoveries SAFFRON made
    additional = []
    for i, (s_dec, l_dec) in enumerate(zip(saffron_decisions, lord3_decisions)):
        if s_dec and not l_dec:
            additional.append(f"p={p_values[i]:.3f}")

    if additional:
        print(f"Additional SAFFRON discoveries: {additional}")

    return saffron_discoveries, lord3_discoveries

# Test with mixed p-value scenario
mixed_p_values = [0.001, 0.25, 0.02, 0.7, 0.005, 0.9, 0.04, 0.3, 0.008, 0.6]
compare_adaptive_vs_nonadaptive(mixed_p_values)

Mathematical Foundation

Candidate-Based Estimation

SAFFRON estimates the proportion of alpha-wealth allocated to true nulls using:

\[\hat{\pi}_0^{(\text{SAFFRON})}(t) = \frac{\text{Number of non-candidates up to time } t}{\text{Total tests up to time } t}\]

where candidates are p-values ≤ λ.

Threshold Formula

The adaptive rejection threshold at time t is:

\[\alpha_t = \min\left(\lambda, \text{wealth-based threshold} \times (1-\lambda)\right)\]

The wealth-based component adapts based on: - Initial wealth allocation - Discoveries and candidate history
- Gamma sequence for proper spending

FDR Guarantee

Theorem (SAFFRON FDR Control): Under independence of p-values, SAFFRON controls FDR at level α.

Best Practices

Parameter Selection Guidelines

Lambda (λ) Selection

  • λ = 0.25: Conservative, fewer candidates, more selective
  • λ = 0.5: Moderate, balanced (recommended default)
  • λ = 0.75: Aggressive, more candidates, higher power potential

Wealth (W₀) Selection

  • Start with W₀ = α/2 (e.g., 0.025 for α = 0.05)
  • Increase for more initial power, decrease for more conservative start
  • Must satisfy 0 ≤ W₀ ≤ α

When to Use SAFFRON

  • Recommended for: Independent p-values with unknown π₀
  • Advantages: Higher power than non-adaptive methods, robust performance
  • Considerations: Requires tuning λ parameter, assumes independence

Troubleshooting

Common Issues

  • Low power: Try increasing λ or W₀
  • Too aggressive: Decrease λ or W₀
  • No early discoveries: SAFFRON needs some candidates to build momentum

References

  1. Ramdas, A., T. Zrnic, M. J. Wainwright, and M. I. Jordan (2018). "SAFFRON: an adaptive algorithm for online control of the false discovery rate." Proceedings of the 35th International Conference on Machine Learning (ICML), PMLR, 80:4286-4294.

  2. Storey, J. D. (2002). "A direct approach to false discovery rates." Journal of the Royal Statistical Society: Series B, 64(3):479-498.

  3. Foster, D. P., and R. A. Stine (2008). "α-investing: a procedure for sequential control of expected false discoveries." Journal of the Royal Statistical Society: Series B, 70(2):429-444.

See Also

  • ADDIS: Handles conservative nulls better than SAFFRON
  • LORD variants: Non-adaptive alternatives
  • Theory: Mathematical foundations of online FDR control