commit e168b262bac7bd5ae011314dab46c37ffee12055
parent f60ba344661745c8829cf46c4539694086c473c2
Author: Jared Tobin <jared@jtobin.io>
Date: Tue, 2 Jun 2026 22:03:57 -0230
docs: enrich haddocks with e-process framework context
Expand the module-, type-, and function-level haddocks to give
readers enough domain context to make sense of the API without
needing the WSR paper in hand.
* Bettor: module header now describes the wealth-process
construction and Ville's-inequality justification; per-constructor
docs explain the strategy (Fixed = baseline, Agrapa = Kelly
plug-in with mu/sigma^2, Ons = online Newton on per-step
log-wealth loss) and the role of lambda_max.
* Mean: module header explains the two-direction (positive /
negative) Bonferroni decomposition; Config and State get per-field
haddocks (cfg_lam_max_pos / cfg_lam_max_neg / st_log_w_pos / ...
so the reader knows what each field is for); 'config' derives the
safe-bet ceilings from sample bounds explicitly; 'update'
documents the wealth-update equation; 'decide' explains the
anytime-valid stopping guarantee; 'log_wealth' framed as the
test statistic.
* TwoSample: module header explains the reduction (paired diffs
feed the bounded-mean test); per-function docs refer back to the
underlying mean test.
No code or API changes; tests pass, haddock builds 14/14 in each
module.
Diffstat:
3 files changed, 163 insertions(+), 55 deletions(-)
diff --git a/lib/Statistics/EProcess/Bettor.hs b/lib/Statistics/EProcess/Bettor.hs
@@ -10,11 +10,17 @@
-- Bettor strategies for the e-process framework.
--
-- A bettor describes how, given the history of centred observations
--- @z = x - m@, the next predictable bet @lambda@ is chosen. The bet
--- placed at step @t@ depends only on data observed through step @t-1@;
--- this predictability is what makes the resulting wealth process a
--- nonnegative supermartingale under the null hypothesis, and hence
--- anytime-valid via Ville's inequality.
+-- @z_t = x_t - m@ (where @x_t@ is the new observation and @m@ is the
+-- null mean), the next predictable bet @lambda_t@ is chosen. The
+-- wealth process is the running product of per-step factors
+--
+-- @W_t = prod_{s <= t} (1 + lambda_s * z_s)@
+--
+-- and the test rejects when @W_t@ crosses @1\/alpha@. Predictability
+-- -- that is, @lambda_t@ depends only on data observed strictly
+-- before step @t@ -- is what makes @W@ a nonnegative supermartingale
+-- under @H_0@, so that Ville's inequality applies and the resulting
+-- test is anytime-valid.
module Statistics.EProcess.Bettor (
-- * Bettor strategies
@@ -25,24 +31,30 @@ module Statistics.EProcess.Bettor (
-- | A predictable bettor.
--
--- For 'Agrapa' and 'Ons', the safe bet bound @lambda_max@ is derived
--- from the sample bounds supplied to the surrounding test
--- configuration (e.g. 'Statistics.EProcess.Mean.config').
+-- For 'Agrapa' and 'Ons', a per-direction safe-bet ceiling
+-- @lambda_max@ is derived from the sample bounds supplied to the
+-- surrounding test configuration (e.g.
+-- 'Statistics.EProcess.Mean.config') -- bets get clipped to
+-- @[0, lambda_max]@ so that the wealth factor @1 + lambda * z@
+-- stays nonnegative for every admissible observation.
--
--- * 'Fixed' always bets the supplied @lambda@; useful for smoke
--- testing the framework and as a numerical baseline.
+-- * 'Fixed' always bets the supplied constant @lambda@. The wager
+-- does not respond to observed data; this strategy is useful only
+-- as a baseline or for smoke testing the framework.
--
-- * 'Agrapa' is the aGRAPA (approximate growth-rate adaptive
--- predictable plug-in) bettor. Tracks empirical mean and variance
--- of centred observations and bets the Kelly-optimal value given
--- the current point estimate, clipped to @[0, lambda_max]@.
---
--- * 'Ons' is the online Newton step bettor. Maintains a running
--- sum of squared gradients of the per-step log-wealth loss and
--- updates @lambda@ by a Newton step at each observation; achieves
--- logarithmic regret against the best constant bet in hindsight,
--- and is in practice the strongest of the three bettors under most
--- signal regimes.
+-- predictable plug-in) bettor of Waudby-Smith & Ramdas (2024).
+-- It tracks the empirical mean @mu@ and variance @sigma^2@ of
+-- centred observations and bets the Kelly-optimal plug-in
+-- @lambda* = mu \/ (sigma^2 + mu^2)@ clipped to
+-- @[0, lambda_max]@. Fast to compute and competitive in practice.
+--
+-- * 'Ons' is the online Newton step bettor. The per-step log-wealth
+-- loss @-log(1 + lambda * z)@ is convex in @lambda@; ONS performs
+-- one Newton step per observation, accumulating squared gradients
+-- to scale the update. Achieves logarithmic regret against the
+-- best constant bet in hindsight and is in practice the strongest
+-- of the three bettors under most signal regimes.
--
-- >>> Fixed 0.5
-- Fixed 0.5
diff --git a/lib/Statistics/EProcess/Mean.hs b/lib/Statistics/EProcess/Mean.hs
@@ -12,12 +12,20 @@
-- Two-sided bounded-mean anytime-valid test.
--
-- For samples @x_t@ in @[lo, hi]@, tests @H_0: E[x] = m@ against
--- @H_1: E[x] /= m@. Runs two e-processes simultaneously (one per
--- direction) and combines them by Bonferroni: reject if either side's
--- wealth crosses @2 \/ alpha@.
+-- @H_1: E[x] /= m@.
--
--- The test is anytime-valid: type-I error is controlled at @alpha@
--- regardless of when the user stops streaming samples.
+-- Internally two one-sided e-processes are run in parallel: a
+-- /positive-direction/ process betting against the alternative
+-- @E[x] > m@ (using centred observations @z = x - m@), and a
+-- /negative-direction/ process betting against @E[x] < m@ (using
+-- @-z@). Each maintains its own log-wealth and bettor state. The
+-- test rejects when either side's wealth crosses @2 \/ alpha@; the
+-- factor of 2 is the Bonferroni adjustment for the two-sided union.
+--
+-- The test is /anytime-valid/: under @H_0@ the wealth process is a
+-- nonnegative supermartingale, so by Ville's inequality the
+-- probability of ever crossing the threshold is at most @alpha@,
+-- regardless of when the user decides to stop streaming samples.
module Statistics.EProcess.Mean (
-- * Test configuration and state
@@ -44,6 +52,12 @@ import Statistics.EProcess.Bettor
-- types ----------------------------------------------------------------------
-- | Test outcome at the current sample count.
+--
+-- 'Reject' means the wealth process has crossed the Bonferroni
+-- threshold, so @H_0@ is rejected at level @alpha@. 'Continue'
+-- means there is not yet enough evidence; collect more samples (or
+-- stop and report no rejection -- the type-I error guarantee holds
+-- for /any/ stopping rule).
data Verdict =
Reject
| Continue
@@ -55,31 +69,43 @@ data Verdict =
data BetState =
SFixed
| SAgrapa
- {-# UNPACK #-} !Double -- sum of z
- {-# UNPACK #-} !Double -- sum of z^2
+ {-# UNPACK #-} !Double -- sum of z (centred observation)
+ {-# UNPACK #-} !Double -- sum of z^2 (for online variance)
{-# UNPACK #-} !Int -- count
| SOns
- {-# UNPACK #-} !Double -- lambda
- {-# UNPACK #-} !Double -- acc (sum of squared gradients)
+ {-# UNPACK #-} !Double -- current bet lambda
+ {-# UNPACK #-} !Double -- running sum of per-step squared gradients
-- | Bounded-mean test configuration. Build with 'config'.
+--
+-- Carries the bettor strategy, the null mean, the significance
+-- level, the precomputed Bonferroni-adjusted log-wealth threshold,
+-- and the per-direction safe-bet ceilings (see 'config' for how
+-- the latter are derived from the sample bounds).
data Config = Config {
- cfg_bettor :: !Bettor
- , cfg_lam_max_pos :: {-# UNPACK #-} !Double
- , cfg_lam_max_neg :: {-# UNPACK #-} !Double
- , cfg_null_mean :: {-# UNPACK #-} !Double
- , cfg_alpha :: {-# UNPACK #-} !Double
- , cfg_log_thresh :: {-# UNPACK #-} !Double
+ cfg_bettor :: !Bettor -- ^ bettor strategy
+ , cfg_lam_max_pos :: {-# UNPACK #-} !Double -- ^ pos-direction safe-bet ceiling
+ , cfg_lam_max_neg :: {-# UNPACK #-} !Double -- ^ neg-direction safe-bet ceiling
+ , cfg_null_mean :: {-# UNPACK #-} !Double -- ^ null mean @m@
+ , cfg_alpha :: {-# UNPACK #-} !Double -- ^ significance level @alpha@
+ , cfg_log_thresh :: {-# UNPACK #-} !Double -- ^ rejection threshold @log(2 \/ alpha)@
}
-- | Streaming test state. Construct with 'initial' and fold
-- observations through 'update'.
+--
+-- The two log-wealth fields track the running log-wealth of the
+-- positive- and negative-direction e-processes separately;
+-- 'decide' compares each to the threshold and 'log_wealth' returns
+-- the larger of the two. The per-direction bettor states carry
+-- whatever the chosen 'Bettor' needs (running sums, current bet,
+-- etc.).
data State = State {
- st_n :: {-# UNPACK #-} !Int
- , st_log_w_pos :: {-# UNPACK #-} !Double
- , st_log_w_neg :: {-# UNPACK #-} !Double
- , st_bet_pos :: !BetState
- , st_bet_neg :: !BetState
+ st_n :: {-# UNPACK #-} !Int -- ^ sample count
+ , st_log_w_pos :: {-# UNPACK #-} !Double -- ^ log-wealth, pos-direction process
+ , st_log_w_neg :: {-# UNPACK #-} !Double -- ^ log-wealth, neg-direction process
+ , st_bet_pos :: !BetState -- ^ bettor state, pos-direction
+ , st_bet_neg :: !BetState -- ^ bettor state, neg-direction
}
-- internal -------------------------------------------------------------------
@@ -99,11 +125,14 @@ init_bet :: Bettor -> BetState
init_bet b = case b of
Fixed _ -> SFixed
Agrapa -> SAgrapa 0 0 0
- Ons -> SOns 0 1.0e-6
+ Ons -> SOns 0 1.0e-6 -- small acc seed avoids div-by-zero on first step
{-# INLINE init_bet #-}
-- compute the next bet 'lambda' from the bettor and its current
--- state; 'lam_max' is the direction-specific safety bound.
+-- state; 'lam_max' is the direction-specific safety bound. for
+-- Agrapa we form a Kelly-style plug-in from the running sample mean
+-- and variance; for Ons the bet is just the last lambda chosen by the
+-- Newton step (updated during 'step_bet').
bet_lambda :: Bettor -> Double -> BetState -> Double
bet_lambda b !lam_max !s = case b of
Fixed lam -> lam
@@ -124,7 +153,10 @@ bet_lambda b !lam_max !s = case b of
_ -> 0
{-# INLINE bet_lambda #-}
--- update bettor state with newly observed centred value 'z'.
+-- update bettor state with newly observed centred value 'z'. for
+-- Agrapa this is just accumulating sums; for Ons we take one Newton
+-- step on the per-step log-wealth loss '-log(1 + lambda * z)',
+-- accumulating squared gradients for adaptive scaling.
step_bet :: Bettor -> Double -> BetState -> Double -> BetState
step_bet b !lam_max !s !z = case b of
Fixed _ -> SFixed
@@ -146,6 +178,25 @@ step_bet b !lam_max !s !z = case b of
-- | Build a 'Config' for the bounded-mean test.
--
+-- Each per-direction safe-bet ceiling @lambda_max@ is set so that
+-- the wealth factor stays nonnegative for every admissible
+-- observation:
+--
+-- * The positive-direction factor is @1 + lambda_p * (x - m)@.
+-- Since @x@ can dip to @lo@, @x - m@ can reach @lo - m@ (the
+-- most negative value), so we need
+-- @lambda_p <= 1 \/ (m - lo)@. The ceiling stored is half this
+-- to leave numerical margin -- the WSR safety recommendation.
+--
+-- * The negative-direction factor is @1 - lambda_n * (x - m)@.
+-- Since @x@ can rise to @hi@, @x - m@ can reach @hi - m@, so we
+-- need @lambda_n <= 1 \/ (hi - m)@; again the ceiling is set to
+-- half this.
+--
+-- The log-wealth rejection threshold is precomputed as
+-- @log(2 \/ alpha)@; the 2 is the Bonferroni union-bound
+-- adjustment for the two one-sided e-processes.
+--
-- >>> import qualified Statistics.EProcess.Bettor as B
-- >>> let cfg = config 0.5 0.0 1.0 1.0e-3 B.Ons
config
@@ -163,13 +214,14 @@ config !m !lo !hi !alpha !b = Config {
, cfg_alpha = alpha
, cfg_log_thresh = log (2 / alpha)
}
--- NB. lambda_max values are half the geometric ceiling; the 1/2 margin
--- keeps the wealth factor bounded away from zero at the boundary,
--- which is the WSR safety recommendation.
{-# INLINE config #-}
-- | The initial 'State' for a fresh streaming test.
--
+-- Both directional log-wealths start at @0@ (i.e., wealth @1@) and
+-- both bettors start in the per-strategy initial state appropriate
+-- for the 'Bettor' chosen in the 'Config'.
+--
-- >>> let s0 = initial cfg
initial :: Config -> State
initial Config{..} =
@@ -187,6 +239,18 @@ initial Config{..} =
-- | Fold one observation into the running 'State'.
--
+-- Computes the centred observation @z = x - m@, queries the two
+-- directional bettors for their predictable bets, accumulates
+-- per-direction log-wealth via
+--
+-- @log_w' = log_w + log (1 + lambda * z)@
+--
+-- (with the symmetric @-lambda@ for the negative direction), and
+-- then steps the bettor states given the newly observed @z@. The
+-- per-step wealth factor is floored at a tiny positive value to
+-- keep the log finite when a marginal bet drives the factor to (or
+-- below) zero.
+--
-- >>> let s1 = update cfg s0 0.7
update :: Config -> State -> Double -> State
update Config{..} State{..} !x =
@@ -205,7 +269,13 @@ update Config{..} State{..} !x =
-- | Compute the current 'Verdict' from the running 'State'.
--
-- 'Reject' iff either directional log-wealth has crossed the
--- Bonferroni-adjusted threshold @log(2 \/ alpha)@.
+-- Bonferroni-adjusted threshold @log(2 \/ alpha)@; equivalently,
+-- the wealth process on either side has exceeded @2 \/ alpha@.
+-- Under @H_0@, by Ville's inequality, the probability of this ever
+-- happening is at most @alpha@ -- and crucially this bound holds
+-- at /every/ sample size simultaneously, so the user is free to
+-- peek at the verdict as often as they like and stop on the first
+-- 'Reject'.
--
-- >>> decide cfg s0
-- Continue
@@ -221,6 +291,10 @@ decide Config{..} State{..}
-- | The current log-wealth, taken as the maximum of the two
-- directional processes.
--
+-- This is the natural \"test statistic\": it is monotone in the
+-- evidence against @H_0@ accumulated so far, and the test rejects
+-- exactly when it crosses @log(2 \/ alpha)@.
+--
-- >>> log_wealth s0
-- 0.0
log_wealth :: State -> Double
diff --git a/lib/Statistics/EProcess/TwoSample.hs b/lib/Statistics/EProcess/TwoSample.hs
@@ -10,9 +10,19 @@
-- Paired two-sample anytime-valid mean-equality test.
--
-- For paired observations @(a_t, b_t)@ where both samples lie in
--- @[lo, hi]@, tests @H_0: E[a] = E[b]@ against @H_1: E[a] /= E[b]@ by
--- running the bounded-mean test on the differences @d_t = a_t - b_t@
--- with null mean 0.
+-- @[lo, hi]@, tests @H_0: E[a] = E[b]@ against
+-- @H_1: E[a] /= E[b]@.
+--
+-- The reduction is straightforward: under the null, the differences
+-- @d_t = a_t - b_t@ have mean zero, and differences of @[lo, hi]@
+-- values lie in @[lo - hi, hi - lo]@. So the paired test is just
+-- the bounded-mean test ("Statistics.EProcess.Mean") on @d_t@ with
+-- null mean @0@ and sample bounds @[lo - hi, hi - lo]@.
+--
+-- Pairing is required: independent two-sample testing without
+-- alignment would need to bet against a richer alternative (the
+-- joint distribution rather than the marginal difference) and is
+-- beyond the scope of this module.
module Statistics.EProcess.TwoSample (
-- * Test configuration and state
@@ -39,11 +49,13 @@ import Statistics.EProcess.Bettor (Bettor)
-- types ----------------------------------------------------------------------
--- | Paired two-sample test configuration. Build with 'config'.
+-- | Paired two-sample test configuration. Build with 'config'. Wraps
+-- a 'Statistics.EProcess.Mean.Config' for the underlying
+-- difference test.
newtype Config = Config M.Config
-- | Streaming paired two-sample test state. Construct with 'initial'
--- and fold observations through 'update'.
+-- and fold paired observations through 'update'.
newtype State = State M.State
-- construction ---------------------------------------------------------------
@@ -51,7 +63,9 @@ newtype State = State M.State
-- | Build a 'Config' for the paired two-sample test.
--
-- Bounds @lo@ and @hi@ are the (shared) bounds on the individual
--- samples; differences then lie in @[lo - hi, hi - lo]@.
+-- @a@ and @b@ samples; the underlying mean test is then configured
+-- on the differences, which lie in @[lo - hi, hi - lo]@ with null
+-- mean @0@.
--
-- >>> import qualified Statistics.EProcess.Bettor as B
-- >>> let cfg = config 0.0 1.0 1.0e-3 B.Ons
@@ -77,6 +91,9 @@ initial (Config c) = State (M.initial c)
-- | Fold one paired observation @(a, b)@ into the running 'State'.
--
+-- Equivalent to feeding the difference @a - b@ into the underlying
+-- bounded-mean test.
+--
-- >>> let s1 = update cfg s0 (0.3, 0.7)
update :: Config -> State -> (Double, Double) -> State
update (Config c) (State s) (!a, !b) =
@@ -85,6 +102,10 @@ update (Config c) (State s) (!a, !b) =
-- | Compute the current 'Verdict' from the running 'State'.
--
+-- 'Reject' iff either directional log-wealth of the underlying
+-- bounded-mean test on the differences has crossed
+-- @log(2 \/ alpha)@.
+--
-- >>> decide cfg s0
-- Continue
decide :: Config -> State -> Verdict
@@ -93,7 +114,8 @@ decide (Config c) (State s) = M.decide c s
-- inspection -----------------------------------------------------------------
--- | The current log-wealth.
+-- | The current log-wealth of the underlying bounded-mean test on
+-- the differences.
--
-- >>> log_wealth s0
-- 0.0
@@ -101,7 +123,7 @@ log_wealth :: State -> Double
log_wealth (State s) = M.log_wealth s
{-# INLINE log_wealth #-}
--- | The number of samples consumed so far.
+-- | The number of paired observations consumed so far.
--
-- >>> samples s0
-- 0