eproc

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit d2a8f8c31f770670a0c63ed51cc17e6fdd434b75
parent 0356542ed038b607762dd9cfb414e3d96851eb65
Author: Jared Tobin <jared@jtobin.io>
Date:   Mon, 29 Jun 2026 20:59:53 -0230

Bounded: switch two-sided combination to convex hedge

Replace the Bonferroni union over the two one-sided e-processes
(reject when max(K^+, K^-) >= 2/alpha) with the convex-hedge
construction from Waudby-Smith & Ramdas (2024) §4 (reject when
K^+ + K^- >= 2/alpha, monitored via the running supremum of the
log-sum).

Both are valid two-sided combinations under H_0, but the convex-
hedge rejection region strictly contains Bonferroni's: where
Bonferroni rejects (max >= 2/alpha), so does the hedge (since
K^+ + K^- >= max(K^+, K^-) >= 2/alpha). Where one side is moderately
large and the other contributes meaningfully, only the hedge fires.
Same alpha guarantee via Ville on the averaged e-process
K = (K^+ + K^-)/2 which has E[K_0] = 1.

For one-sided alternatives — the common case in practice — the gap
is small (the losing-direction bettor's wealth stays near 1). For
genuinely two-sided alternatives it can be substantial.

State is simplified: dropped the per-direction max fields, added a
single max-log-sum field tracked via log-sum-exp. The decide
threshold is unchanged at log(2/alpha). log_wealth now reports the
max-log-sum directly (starts at log 2 rather than 0 since
K^+_0 + K^-_0 = 2); documented in the haddock.

Behaviour change is monotone: any stream that rejected under the
old code also rejects under the new code, possibly with fewer
samples. Bernoulli (one-sided) is unaffected. Paired wraps Bounded
so inherits automatically.

Diffstat:
Mlib/Numeric/Eproc/Bounded.hs | 146+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
1 file changed, 84 insertions(+), 62 deletions(-)

diff --git a/lib/Numeric/Eproc/Bounded.hs b/lib/Numeric/Eproc/Bounded.hs @@ -23,13 +23,27 @@ -- about. -- -- Internally two one-sided e-processes are run in parallel: a --- /positive-direction/ process betting against the alternative --- @E[x_t | F_{t-1}] > m@ (using centred observations @z = x - m@), --- and a /negative-direction/ process betting against --- @E[x_t | F_{t-1}] < m@ (using @-z@). Each maintains its own --- log-wealth and bettor state. The test rejects when /either/ --- side's wealth has /ever/ crossed @2 \/ alpha@; the factor of 2 --- is the Bonferroni adjustment for the two-sided union. +-- /positive-direction/ process @K^+_t@ betting against the +-- alternative @E[x_t | F_{t-1}] > m@ (using centred observations +-- @z = x - m@), and a /negative-direction/ process @K^-_t@ betting +-- against @E[x_t | F_{t-1}] < m@ (using @-z@). Each maintains its +-- own log-wealth and bettor state. +-- +-- The two sides are combined via the /hedged capital process/ of +-- Waudby-Smith & Ramdas (2024) §4: their average +-- @K_t = (K^+_t + K^-_t) \/ 2@ is itself an e-process (convex +-- combinations preserve the supermartingale property), with +-- @E[K_0] = 1@. By Ville's inequality +-- @P(sup_t K_t >= 1 \/ alpha) <= alpha@, so the test rejects when +-- the supremum of @K^+_t + K^-_t@ has ever crossed @2 \/ alpha@. +-- +-- This is strictly more powerful than the naive Bonferroni union +-- (reject when @max(K^+_t, K^-_t) >= 2 \/ alpha@): the convex-hedge +-- rejection region contains Bonferroni's (since +-- @K^+ + K^- >= max(K^+, K^-)@), with the same alpha guarantee. +-- For one-sided alternatives the gap is small (the losing-direction +-- bettor stays near @1@); for genuinely two-sided alternatives it +-- can be substantial. -- -- The test is /anytime-valid/: under @H_0@ the wealth process is a -- nonnegative supermartingale, so by Ville's inequality the @@ -72,6 +86,7 @@ module Numeric.Eproc.Bounded ( , samples ) where +import GHC.Float (log1p) import Numeric.Eproc.Common ( Bettor(..), Verdict(..), ConfigError(..) , BetState, init_bet, bet_lambda, step_bet @@ -88,9 +103,9 @@ import Numeric.Eproc.Common ( -- | 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). +-- level, the precomputed convex-hedge 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 { -- ^ bettor strategy cfg_bettor :: !Bettor @@ -111,19 +126,18 @@ data Config = Config { -- -- The two log-wealth fields track the running log-wealth of the -- positive- and negative-direction e-processes separately; the --- two /maximum/ log-wealth fields latch the supremum so far on --- each side, so 'decide' tests the supremum-style event Ville's --- inequality actually bounds. The per-direction bettor states --- carry whatever the chosen 'Bettor' needs (running sums, current --- bet, etc.). +-- /max log-sum/ field latches the supremum so far of +-- @log(K^+_t + K^-_t)@, which is the test statistic the +-- convex-hedge construction actually monitors. The per-direction +-- bettor states carry whatever the chosen 'Bettor' needs (running +-- sums, current bet, etc.). data State = State { - st_n :: {-# UNPACK #-} !Int -- ^ sample count - , st_log_w_pos :: {-# UNPACK #-} !Double -- ^ log-wealth, pos - , st_log_w_neg :: {-# UNPACK #-} !Double -- ^ log-wealth, neg - , st_max_log_w_pos :: {-# UNPACK #-} !Double -- ^ sup log-wealth, pos - , st_max_log_w_neg :: {-# UNPACK #-} !Double -- ^ sup log-wealth, neg - , st_bet_pos :: !BetState -- ^ bettor state, pos - , st_bet_neg :: !BetState -- ^ bettor state, neg + st_n :: {-# UNPACK #-} !Int -- ^ sample count + , st_log_w_pos :: {-# UNPACK #-} !Double -- ^ log-wealth, pos + , st_log_w_neg :: {-# UNPACK #-} !Double -- ^ log-wealth, neg + , st_max_log_sum :: {-# UNPACK #-} !Double -- ^ sup log(K^+ + K^-) + , st_bet_pos :: !BetState -- ^ bettor state, pos + , st_bet_neg :: !BetState -- ^ bettor state, neg } -- construction --------------------------------------------------------------- @@ -146,8 +160,9 @@ data State = State { -- 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. +-- @log(2 \/ alpha)@; the 2 reflects that the convex-hedge test +-- monitors the sum @K^+_t + K^-_t@, whose initial value is @2@ +-- (each side starts at @K = 1@). -- -- Returns 'Left' with a 'ConfigError' on inputs that would leave -- the mathematical regime: any of @m@, @lo@, @hi@, @alpha@ @@ -182,22 +197,22 @@ config !m !lo !hi !alpha !b -- | The initial 'State' for a fresh streaming test. -- --- All four log-wealth fields 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'. +-- Both per-direction log-wealths start at @0@ (i.e., @K = 1@); +-- the max-log-sum starts at @log 2@ (since @K^+_0 + K^-_0 = 2@); +-- 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{..} = let !s0 = init_bet cfg_bettor in State { - st_n = 0 - , st_log_w_pos = 0 - , st_log_w_neg = 0 - , st_max_log_w_pos = 0 - , st_max_log_w_neg = 0 - , st_bet_pos = s0 - , st_bet_neg = s0 + st_n = 0 + , st_log_w_pos = 0 + , st_log_w_neg = 0 + , st_max_log_sum = log 2 + , st_bet_pos = s0 + , st_bet_neg = s0 } {-# INLINE initial #-} @@ -212,8 +227,9 @@ initial Config{..} = -- @log_w' = log_w + log (1 + lambda * z)@ -- -- (with the symmetric @-lambda@ for the negative direction), then --- updates the running supremum of log-wealth on each side and --- steps the bettor states given the newly observed @z@. +-- updates the running supremum of @log(K^+ + K^-)@ via +-- log-sum-exp and steps the bettor states given the newly +-- observed @z@. -- -- /Precondition/: @x@ must lie in the @[lo, hi]@ interval given -- to 'config'. The type-I error guarantee of the test depends on @@ -224,26 +240,33 @@ initial Config{..} = -- >>> let s1 = update cfg s0 0.7 update :: Config -> State -> Double -> State update Config{..} State{..} !x = - let !z = x - cfg_null_mean - !lam_p = bet_lambda cfg_bettor cfg_lam_max_pos st_bet_pos - !lam_n = bet_lambda cfg_bettor cfg_lam_max_neg st_bet_neg - !fac_p = 1 + lam_p * z - !fac_n = 1 - lam_n * z - !logw_p = st_log_w_pos + log fac_p - !logw_n = st_log_w_neg + log fac_n - !maxp = max st_max_log_w_pos logw_p - !maxn = max st_max_log_w_neg logw_n - !sp = step_bet cfg_bettor cfg_lam_max_pos st_bet_pos z - !sn = step_bet cfg_bettor cfg_lam_max_neg st_bet_neg (negate z) - in State (st_n + 1) logw_p logw_n maxp maxn sp sn + let !z = x - cfg_null_mean + !lam_p = bet_lambda cfg_bettor cfg_lam_max_pos st_bet_pos + !lam_n = bet_lambda cfg_bettor cfg_lam_max_neg st_bet_neg + !fac_p = 1 + lam_p * z + !fac_n = 1 - lam_n * z + !logw_p = st_log_w_pos + log fac_p + !logw_n = st_log_w_neg + log fac_n + !log_sum = log_sum_exp logw_p logw_n + !max_sum = max st_max_log_sum log_sum + !sp = step_bet cfg_bettor cfg_lam_max_pos st_bet_pos z + !sn = step_bet cfg_bettor cfg_lam_max_neg st_bet_neg (negate z) + in State (st_n + 1) logw_p logw_n max_sum sp sn {-# INLINE update #-} +-- | @log(exp a + exp b)@, computed without intermediate overflow. +log_sum_exp :: Double -> Double -> Double +log_sum_exp !a !b + | a >= b = a + log1p (exp (b - a)) + | otherwise = b + log1p (exp (a - b)) +{-# INLINE log_sum_exp #-} + -- | Compute the current 'Verdict' from the running 'State'. -- --- 'Reject' iff either directional log-wealth has /ever/ crossed --- the Bonferroni-adjusted threshold @log(2 \/ alpha)@; --- equivalently, the wealth process on either side has exceeded --- @2 \/ alpha@ at some point in the stream so far. Under @H_0@, +-- 'Reject' iff the supremum-so-far of @log(K^+_t + K^-_t)@ has +-- ever crossed the threshold @log(2 \/ alpha)@ — equivalently, +-- the convex-hedge e-process @(K^+ + K^-) \/ 2@ has exceeded +-- @1 \/ alpha@ at some point in the stream so far. 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 @@ -253,25 +276,24 @@ update Config{..} State{..} !x = -- Continue decide :: Config -> State -> Verdict decide Config{..} State{..} - | st_max_log_w_pos >= cfg_log_thresh = Reject - | st_max_log_w_neg >= cfg_log_thresh = Reject - | otherwise = Continue + | st_max_log_sum >= cfg_log_thresh = Reject + | otherwise = Continue {-# INLINE decide #-} -- inspection ----------------------------------------------------------------- --- | The supremum-so-far log-wealth, taken as the maximum across the --- two directional processes and across all sample counts up to --- the current one. --- --- This is the natural \"test statistic\": it is monotone +-- | The supremum-so-far of @log(K^+_t + K^-_t)@, taken across all +-- sample counts up to the current one. This is the test statistic +-- the convex-hedge construction actually monitors: it is monotone -- nondecreasing in the sample count, and 'decide' rejects exactly -- when it crosses @log(2 \/ alpha)@. -- +-- Starts at @log 2@ (since @K^+_0 + K^-_0 = 2@). +-- -- >>> log_wealth s0 --- 0.0 +-- 0.6931471805599453 log_wealth :: State -> Double -log_wealth State{..} = max st_max_log_w_pos st_max_log_w_neg +log_wealth State{..} = st_max_log_sum {-# INLINE log_wealth #-} -- | The number of samples consumed so far.