TwoSided.hs (5850B)
1 {-# OPTIONS_HADDOCK prune #-} 2 {-# LANGUAGE BangPatterns #-} 3 4 -- | 5 -- Module: Numeric.Eproc.Bernoulli.TwoSided 6 -- Copyright: (c) 2026 Jared Tobin 7 -- License: MIT 8 -- Maintainer: Jared Tobin <jared@ppad.tech> 9 -- 10 -- Two-sided Bernoulli rate anytime-valid test. Companion to 11 -- "Numeric.Eproc.Bernoulli", which handles the one-sided case; 12 -- reach for this module when you want to test 13 -- 14 -- @H_0: E[x_t | F_{t-1}] = p_0 for all t@ 15 -- 16 -- against the negation. The canonical case is the sign test at 17 -- @p_0 = 1\/2@. 18 -- 19 -- This is exactly the two-sided bounded-mean test on @[0, 1]@ with 20 -- null mean @p_0@, so the module is a thin newtype wrapper over 21 -- "Numeric.Eproc.Bounded" (much as "Numeric.Eproc.Paired" is a 22 -- wrapper for the paired difference case). See the Bounded module 23 -- for the mathematical detail: convex-hedge combination of two 24 -- per-direction e-processes, threshold @log(2 \/ alpha)@, latched 25 -- rejection, etc. 26 -- 27 -- == Example 28 -- 29 -- Sign test at @p_0 = 1\/2@ with a downward shift: 30 -- 31 -- >>> let Right cfg = config 0.5 1.0e-3 Newton 32 -- >>> let s0 = initial cfg 33 -- >>> let xs = take 500 (cycle [False, False, False, True]) 34 -- >>> decide cfg (foldl' (update cfg) s0 xs) 35 -- Reject 36 37 module Numeric.Eproc.Bernoulli.TwoSided ( 38 -- * Test configuration and state 39 Config 40 , State 41 , Verdict(..) 42 , ConfigError(..) 43 44 -- * Bettor strategies 45 , Bettor(..) 46 47 -- * Construction 48 , config 49 , initial 50 51 -- * Streaming 52 , update 53 , decide 54 55 -- * Inspection 56 , log_wealth 57 , log_wealth_sup 58 , log_evalue 59 , log_evalue_sup 60 , p_value 61 , samples 62 ) where 63 64 import qualified Numeric.Eproc.Bounded as Bounded 65 import Numeric.Eproc.Common (Bettor(..), Verdict(..), ConfigError(..)) 66 67 -- types ---------------------------------------------------------------------- 68 69 -- | Two-sided Bernoulli rate test configuration. Build with 'config'. 70 -- Wraps a 'Numeric.Eproc.Bounded.Config' on @[0, 1]@ with null 71 -- mean @p_0@. 72 newtype Config = Config Bounded.Config 73 74 -- | Streaming test state. Construct with 'initial' and fold 75 -- observations through 'update'. 76 newtype State = State Bounded.State 77 78 -- construction --------------------------------------------------------------- 79 80 -- | Build a 'Config' for the two-sided Bernoulli rate test. 81 -- 82 -- Returns 'Left' with a 'ConfigError' on inputs that would leave 83 -- the mathematical regime: @p_0@ outside @(0, 1)@ (or non-finite), 84 -- or @alpha@ outside @(0, 1)@ (or non-finite). 85 -- 86 -- >>> let Right cfg = config 0.5 1.0e-3 Newton 87 config 88 :: Double -- ^ baseline rate @p_0@, in @(0, 1)@ 89 -> Double -- ^ significance level @alpha@, in @(0, 1)@ 90 -> Bettor -- ^ bettor strategy 91 -> Either ConfigError Config 92 config !p0 !alpha b 93 -- NaN comparisons return False and (-Inf, +Inf) fail the range 94 -- check, so this catches non-finite p_0 without a separate guard. 95 | not (p0 > 0 && p0 < 1) = Left (InvalidBaselineRate p0) 96 | otherwise = fmap Config (Bounded.config p0 0 1 alpha b) 97 {-# INLINE config #-} 98 99 -- | The initial 'State' for a fresh streaming test. 100 -- 101 -- >>> let s0 = initial cfg 102 initial :: Config -> State 103 initial (Config c) = State (Bounded.initial c) 104 {-# INLINE initial #-} 105 106 -- streaming ------------------------------------------------------------------ 107 108 -- | Fold one observation into the running 'State'. Equivalent to 109 -- feeding the numeric @1@\/@0@ encoding of the observation into 110 -- the underlying bounded-mean test. 111 -- 112 -- >>> let s1 = update cfg s0 True 113 update :: Config -> State -> Bool -> State 114 update (Config c) (State s) !x = 115 State (Bounded.update c s (if x then 1 else 0)) 116 {-# INLINE update #-} 117 118 -- | Compute the current 'Verdict' from the running 'State'. 119 -- 120 -- >>> decide cfg s0 121 -- Continue 122 decide :: Config -> State -> Verdict 123 decide (Config c) (State s) = Bounded.decide c s 124 {-# INLINE decide #-} 125 126 -- inspection ----------------------------------------------------------------- 127 128 -- | The current @log(K^+_t + K^-_t)@ of the underlying bounded-mean 129 -- test. Not monotone; bounded above by 'log_wealth_sup'. Starts 130 -- at @log 2@. 131 -- 132 -- >>> log_wealth s0 133 -- 0.6931471805599453 134 log_wealth :: State -> Double 135 log_wealth (State s) = Bounded.log_wealth s 136 {-# INLINE log_wealth #-} 137 138 -- | The supremum-so-far of @log(K^+_t + K^-_t)@ from the underlying 139 -- bounded-mean test. Monotone nondecreasing; 'decide' rejects 140 -- exactly when it crosses @log(2 \/ alpha)@. Starts at @log 2@. 141 -- 142 -- >>> log_wealth_sup s0 143 -- 0.6931471805599453 144 log_wealth_sup :: State -> Double 145 log_wealth_sup (State s) = Bounded.log_wealth_sup s 146 {-# INLINE log_wealth_sup #-} 147 148 -- | The current log e-value of the underlying bounded-mean test: 149 -- 'log_wealth' minus @log 2@, normalized so a fresh state sits at 150 -- @0@. Not monotone; bounded above by 'log_evalue_sup'. 151 -- 152 -- >>> log_evalue s0 153 -- 0.0 154 log_evalue :: State -> Double 155 log_evalue (State s) = Bounded.log_evalue s 156 {-# INLINE log_evalue #-} 157 158 -- | The supremum-so-far of the log e-value: 'log_wealth_sup' minus 159 -- @log 2@. Monotone nondecreasing, starting at @0@; 'decide' 160 -- rejects exactly when it crosses @log(1 \/ alpha)@. 161 -- 162 -- >>> log_evalue_sup s0 163 -- 0.0 164 log_evalue_sup :: State -> Double 165 log_evalue_sup (State s) = Bounded.log_evalue_sup s 166 {-# INLINE log_evalue_sup #-} 167 168 -- | The anytime-valid p-value: the reciprocal of the largest 169 -- e-value attained so far. Monotone nonincreasing; under @H_0@, 170 -- @P(exists t: p_t <= alpha) <= alpha@ for every @alpha@ 171 -- simultaneously. 'decide' returns 'Reject' exactly when this 172 -- value has reached the configured @alpha@ or below. 173 -- 174 -- >>> p_value s0 175 -- 1.0 176 p_value :: State -> Double 177 p_value (State s) = Bounded.p_value s 178 {-# INLINE p_value #-} 179 180 -- | The number of samples consumed so far. 181 -- 182 -- >>> samples s0 183 -- 0 184 samples :: State -> Int 185 samples (State s) = Bounded.samples s 186 {-# INLINE samples #-}