eproc

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

commit a230e6558be4050da6ecb1a799d9993a9c957152
parent f1318f8b4511eae8fe5cbe9af2ab6b988c091dd2
Author: Jared Tobin <jared@jtobin.io>
Date:   Tue,  2 Jun 2026 21:05:41 -0230

perf: close Bettor sum, fix per-step Rational conversion

Refactor Bettor from a record of strategy functions (init/step/bet)
parameterised over an opaque state type to a closed sum type with
three constructors (Fixed Double, Agrapa, Ons). Mean.update now
case-dispatches on the bettor; with INLINE, GHC fully specialises
each strategy at the call site. Per-direction bettor state lives in a
separate closed BetState sum carried by Mean.State.

Mean.Config and Mean.State lose their phantom 's' parameter; the
re-exports in Statistics.EProcess drop AGRAPA/ONS/fixed/agrapa/ons in
favour of Bettor(..). Breaking change to the (brand-new) public API.

While here: replace 'tiny = 1.0e-300 :: Double' with the MagicHash
form 'D# 1.0e-300##'. The fractional literal compiles as
'fromRational (1.0e-300 :: Rational)' and GHC was not folding the
conversion, leaving a $wrationalToDouble call in every update step.
This was the dominant cost once the closed-sum refactor unlocked
inlining.

Combined effect (LLVM, 1000-sample fold):

  Mean fold/fixed  : 128.9 us ->  4.84 us  (27x)
  Mean fold/agrapa : 142.4 us -> 15.67 us  ( 9x)
  Mean fold/ons    : 133.2 us -> 14.43 us  ( 9x)
  TS fold/fixed    : 129.2 us ->  5.33 us  (24x)
  TS fold/agrapa   : 155.7 us ->  9.92 us  (16x)
  TS fold/ons      : 136.9 us -> 16.31 us  ( 8x)

Allocation per 1000-step fold drops from ~850 KB to <33 KB; the
fixed-bettor inner loop is fully fused (48 B total).

All 11 tests pass.

Diffstat:
Mbench/Main.hs | 38+++++++++++++-------------------------
Mbench/Weight.hs | 38+++++++++++++-------------------------
Mlib/Statistics/EProcess.hs | 39+++++++++++++++++----------------------
Mlib/Statistics/EProcess/Bettor.hs | 128++++++++++++++++---------------------------------------------------------------
Mlib/Statistics/EProcess/Mean.hs | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mlib/Statistics/EProcess/TwoSample.hs | 38++++++++++++++++++++++----------------
Mtest/Main.hs | 31++++++++++++++-----------------
7 files changed, 223 insertions(+), 246 deletions(-)

diff --git a/bench/Main.hs b/bench/Main.hs @@ -5,7 +5,6 @@ module Main where import Control.DeepSeq import qualified Statistics.EProcess as E -import qualified Statistics.EProcess.Bettor as B import qualified Statistics.EProcess.Mean as M import qualified Statistics.EProcess.TwoSample as TS import Criterion.Main @@ -13,11 +12,9 @@ import Criterion.Main -- all relevant fields are strict (and UNPACK'd for the doubles), so -- WHNF == NF for these types. orphan instances keep the library API -- untouched. -instance NFData B.AGRAPA where rnf !_ = () -instance NFData B.ONS where rnf !_ = () -instance NFData (M.State s) where rnf !_ = () -instance NFData (TS.State s) where rnf !_ = () -instance NFData M.Verdict where rnf !_ = () +instance NFData M.State where rnf !_ = () +instance NFData TS.State where rnf !_ = () +instance NFData M.Verdict where rnf !_ = () main :: IO () main = defaultMain [ @@ -29,12 +26,9 @@ main = defaultMain [ update :: Benchmark update = - let !cfgF = M.config 0.5 0.0 1.0 1.0e-3 (const (E.fixed 0.5)) - :: M.Config () - !cfgA = M.config 0.5 0.0 1.0 1.0e-3 E.agrapa - :: M.Config B.AGRAPA - !cfgO = M.config 0.5 0.0 1.0 1.0e-3 E.ons - :: M.Config B.ONS + let !cfgF = M.config 0.5 0.0 1.0 1.0e-3 (E.Fixed 0.5) + !cfgA = M.config 0.5 0.0 1.0 1.0e-3 E.Agrapa + !cfgO = M.config 0.5 0.0 1.0 1.0e-3 E.Ons !stF = M.initial cfgF !stA = M.initial cfgA !stO = M.initial cfgO @@ -47,7 +41,7 @@ update = decide :: Benchmark decide = - let !cfg = M.config 0.5 0.0 1.0 1.0e-3 E.ons :: M.Config B.ONS + let !cfg = M.config 0.5 0.0 1.0 1.0e-3 E.Ons !st = M.initial cfg in bgroup "Mean.decide" [ bench "initial state" $ nf (M.decide cfg) st @@ -56,12 +50,9 @@ decide = stream :: Benchmark stream = let !xs = force (take 1000 (cycle [0.3, 0.7])) - !cfgF = M.config 0.5 0.0 1.0 1.0e-3 (const (E.fixed 0.5)) - :: M.Config () - !cfgA = M.config 0.5 0.0 1.0 1.0e-3 E.agrapa - :: M.Config B.AGRAPA - !cfgO = M.config 0.5 0.0 1.0 1.0e-3 E.ons - :: M.Config B.ONS + !cfgF = M.config 0.5 0.0 1.0 1.0e-3 (E.Fixed 0.5) + !cfgA = M.config 0.5 0.0 1.0 1.0e-3 E.Agrapa + !cfgO = M.config 0.5 0.0 1.0 1.0e-3 E.Ons runM cfg = foldl' (M.update cfg) (M.initial cfg) in bgroup "Mean.update (1000-sample fold)" [ bench "fixed" $ nf (runM cfgF) xs @@ -72,12 +63,9 @@ stream = twosample :: Benchmark twosample = let !ps = force (take 1000 (cycle [(0.3, 0.7), (0.7, 0.3)])) - !cfgF = TS.config 0.0 1.0 1.0e-3 (const (E.fixed 0.5)) - :: TS.Config () - !cfgA = TS.config 0.0 1.0 1.0e-3 E.agrapa - :: TS.Config B.AGRAPA - !cfgO = TS.config 0.0 1.0 1.0e-3 E.ons - :: TS.Config B.ONS + !cfgF = TS.config 0.0 1.0 1.0e-3 (E.Fixed 0.5) + !cfgA = TS.config 0.0 1.0 1.0e-3 E.Agrapa + !cfgO = TS.config 0.0 1.0 1.0e-3 E.Ons runT cfg = foldl' (TS.update cfg) (TS.initial cfg) in bgroup "TwoSample.update (1000-sample fold)" [ bench "fixed" $ nf (runT cfgF) ps diff --git a/bench/Weight.hs b/bench/Weight.hs @@ -5,16 +5,13 @@ module Main where import Control.DeepSeq import qualified Statistics.EProcess as E -import qualified Statistics.EProcess.Bettor as B import qualified Statistics.EProcess.Mean as M import qualified Statistics.EProcess.TwoSample as TS import Weigh -instance NFData B.AGRAPA where rnf !_ = () -instance NFData B.ONS where rnf !_ = () -instance NFData (M.State s) where rnf !_ = () -instance NFData (TS.State s) where rnf !_ = () -instance NFData M.Verdict where rnf !_ = () +instance NFData M.State where rnf !_ = () +instance NFData TS.State where rnf !_ = () +instance NFData M.Verdict where rnf !_ = () -- note that 'weigh' doesn't work properly in a repl main :: IO () @@ -26,12 +23,9 @@ main = mainWith $ do update :: Weigh () update = - let !cfgF = M.config 0.5 0.0 1.0 1.0e-3 (const (E.fixed 0.5)) - :: M.Config () - !cfgA = M.config 0.5 0.0 1.0 1.0e-3 E.agrapa - :: M.Config B.AGRAPA - !cfgO = M.config 0.5 0.0 1.0 1.0e-3 E.ons - :: M.Config B.ONS + let !cfgF = M.config 0.5 0.0 1.0 1.0e-3 (E.Fixed 0.5) + !cfgA = M.config 0.5 0.0 1.0 1.0e-3 E.Agrapa + !cfgO = M.config 0.5 0.0 1.0 1.0e-3 E.Ons !stF = M.initial cfgF !stA = M.initial cfgA !stO = M.initial cfgO @@ -42,7 +36,7 @@ update = decide :: Weigh () decide = - let !cfg = M.config 0.5 0.0 1.0 1.0e-3 E.ons :: M.Config B.ONS + let !cfg = M.config 0.5 0.0 1.0 1.0e-3 E.Ons !st = M.initial cfg in wgroup "Mean.decide" $ do func "initial state" (M.decide cfg) st @@ -50,12 +44,9 @@ decide = stream :: Weigh () stream = let !xs = force (take 1000 (cycle [0.3, 0.7])) - !cfgF = M.config 0.5 0.0 1.0 1.0e-3 (const (E.fixed 0.5)) - :: M.Config () - !cfgA = M.config 0.5 0.0 1.0 1.0e-3 E.agrapa - :: M.Config B.AGRAPA - !cfgO = M.config 0.5 0.0 1.0 1.0e-3 E.ons - :: M.Config B.ONS + !cfgF = M.config 0.5 0.0 1.0 1.0e-3 (E.Fixed 0.5) + !cfgA = M.config 0.5 0.0 1.0 1.0e-3 E.Agrapa + !cfgO = M.config 0.5 0.0 1.0 1.0e-3 E.Ons runM cfg = foldl' (M.update cfg) (M.initial cfg) in wgroup "Mean.update (1000-sample fold)" $ do func "fixed" (runM cfgF) xs @@ -65,12 +56,9 @@ stream = twosample :: Weigh () twosample = let !ps = force (take 1000 (cycle [(0.3, 0.7), (0.7, 0.3)])) - !cfgF = TS.config 0.0 1.0 1.0e-3 (const (E.fixed 0.5)) - :: TS.Config () - !cfgA = TS.config 0.0 1.0 1.0e-3 E.agrapa - :: TS.Config B.AGRAPA - !cfgO = TS.config 0.0 1.0 1.0e-3 E.ons - :: TS.Config B.ONS + !cfgF = TS.config 0.0 1.0 1.0e-3 (E.Fixed 0.5) + !cfgA = TS.config 0.0 1.0 1.0e-3 E.Agrapa + !cfgO = TS.config 0.0 1.0 1.0e-3 E.Ons runT cfg = foldl' (TS.update cfg) (TS.initial cfg) in wgroup "TwoSample.update (1000-sample fold)" $ do func "fixed" (runT cfgF) ps diff --git a/lib/Statistics/EProcess.hs b/lib/Statistics/EProcess.hs @@ -28,12 +28,7 @@ module Statistics.EProcess ( -- * Bettors - Bettor - , AGRAPA - , ONS - , fixed - , agrapa - , ons + Bettor(..) -- * Bounded-mean test -- @@ -67,24 +62,24 @@ import qualified Statistics.EProcess.TwoSample as TS -- | See 'Mean.config'. meanConfig - :: Double -- ^ null mean @m@ - -> Double -- ^ sample lower bound - -> Double -- ^ sample upper bound - -> Double -- ^ significance level @alpha@ - -> (Double -> Bettor s) - -> Mean.Config s + :: Double -- ^ null mean @m@ + -> Double -- ^ sample lower bound + -> Double -- ^ sample upper bound + -> Double -- ^ significance level @alpha@ + -> Bettor + -> Mean.Config meanConfig = Mean.config -- | See 'Mean.initial'. -initMeanState :: Mean.Config s -> Mean.State s +initMeanState :: Mean.Config -> Mean.State initMeanState = Mean.initial -- | See 'Mean.update'. -updateMean :: Mean.Config s -> Mean.State s -> Double -> Mean.State s +updateMean :: Mean.Config -> Mean.State -> Double -> Mean.State updateMean = Mean.update -- | See 'Mean.decide'. -decideMean :: Mean.Config s -> Mean.State s -> Mean.Verdict +decideMean :: Mean.Config -> Mean.State -> Mean.Verdict decideMean = Mean.decide -- $twosample @@ -97,27 +92,27 @@ twoSampleConfig :: Double -> Double -> Double - -> (Double -> Bettor s) - -> TS.Config s + -> Bettor + -> TS.Config twoSampleConfig = TS.config -- | See 'TS.initial'. -initTwoSampleState :: TS.Config s -> TS.State s +initTwoSampleState :: TS.Config -> TS.State initTwoSampleState = TS.initial -- | See 'TS.update'. updateTwoSample - :: TS.Config s -> TS.State s -> (Double, Double) -> TS.State s + :: TS.Config -> TS.State -> (Double, Double) -> TS.State updateTwoSample = TS.update -- | See 'TS.decide'. -decideTwoSample :: TS.Config s -> TS.State s -> TS.Verdict +decideTwoSample :: TS.Config -> TS.State -> TS.Verdict decideTwoSample = TS.decide -- | Current log-wealth of a 'Mean.State'. -logWealth :: Mean.State s -> Double +logWealth :: Mean.State -> Double logWealth = Mean.logWealth -- | Sample count consumed so far. -samples :: Mean.State s -> Int +samples :: Mean.State -> Int samples = Mean.samples diff --git a/lib/Statistics/EProcess/Bettor.hs b/lib/Statistics/EProcess/Bettor.hs @@ -1,6 +1,5 @@ {-# OPTIONS_HADDOCK prune #-} {-# LANGUAGE BangPatterns #-} -{-# LANGUAGE RecordWildCards #-} -- | -- Module: Statistics.EProcess.Bettor @@ -8,9 +7,9 @@ -- License: MIT -- Maintainer: Jared Tobin <jared@ppad.tech> -- --- Bettor strategies for the e-process framework. A bettor maintains --- internal state, consumes centred observations @z = x - m@, and --- produces a predictable bet @lambda@ for the next observation. +-- 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 @@ -18,106 +17,31 @@ -- and hence anytime-valid via Ville's inequality. module Statistics.EProcess.Bettor ( - -- * Bettor type + -- * Bettor strategies Bettor(..) - - -- * Strategies - , fixed - , agrapa - , ons - - -- * Strategy state types (opaque) - , AGRAPA - , ONS ) where -- | A predictable bettor. -- --- Parameterised over its internal state type @s@. --- --- * @bettorInit@: initial state. --- * @bettorStep@: update state with a newly observed centred --- value @z = x - m@. --- * @bettorBet@: bet @lambda@ to use for the /next/ observation, --- given the current state. -data Bettor s = Bettor - { bettorInit :: !s - , bettorStep :: !(s -> Double -> s) - , bettorBet :: !(s -> Double) - } - --- | Fixed-lambda bettor. --- --- Always bets the same value. Useful for smoke-testing the --- framework and as a numerical baseline. -fixed :: Double -> Bettor () -fixed !lam = Bettor - { bettorInit = () - , bettorStep = \_ _ -> () - , bettorBet = \_ -> lam - } - --- | aGRAPA bettor state (opaque). -data AGRAPA = AGRAPA - { aSum :: {-# UNPACK #-} !Double - , aSum2 :: {-# UNPACK #-} !Double - , aN :: {-# UNPACK #-} !Int - , aMax :: {-# UNPACK #-} !Double - } - --- | aGRAPA (approximate growth-rate adaptive predictable plug-in). --- --- Tracks empirical mean and variance of centred observations @z@, --- and bets the Kelly-optimal @lambda* = mu_z / (sigma_z^2 + mu_z^2)@ --- given the current point estimate, clipped to @[0, lambda_max]@. --- --- The argument is @lambda_max@, the largest safe bet. For --- observations @z = x - m@ where @x@ lies in @[lo, hi]@ and we are --- testing @E[x] <= m@, a safe choice is @lambda_max = 1 \/ (m - lo)@ --- (so that the wealth factor @1 + lambda * z@ stays nonnegative). -agrapa :: Double -> Bettor AGRAPA -agrapa !lamMax = Bettor - { bettorInit = AGRAPA 0 0 0 lamMax - , bettorStep = \AGRAPA{..} !z -> - AGRAPA (aSum + z) (aSum2 + z * z) (aN + 1) aMax - , bettorBet = \AGRAPA{..} -> - if aN == 0 - then 0 - else - let !n = fromIntegral aN - !mu = aSum / n - !mu2 = mu * mu - !var = max 0 (aSum2 / n - mu2) - !den = var + mu2 - !raw = if den == 0 then 0 else mu / den - in max 0 (min aMax raw) - } - --- | ONS bettor state (opaque). -data ONS = ONS - { onsLambda :: {-# UNPACK #-} !Double - , onsAcc :: {-# UNPACK #-} !Double - , onsMax :: {-# UNPACK #-} !Double - } - --- | ONS (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; in practice the strongest of the --- three bettors here under most signal regimes. --- --- The argument is @lambda_max@; see 'agrapa' for the sizing rule. -ons :: Double -> Bettor ONS -ons !lamMax = Bettor - { bettorInit = ONS 0 1.0e-6 lamMax - , bettorStep = \ONS{..} !z -> - let !denom = 1 + onsLambda * z - !g = if denom == 0 then 0 else negate z / denom - !acc' = onsAcc + g * g - !lam' = onsLambda - g / acc' - !clp = max 0 (min onsMax lam') - in ONS clp acc' onsMax - , bettorBet = onsLambda - } +-- * 'Fixed' always bets the supplied @lambda@; useful for +-- smoke-testing the framework and as a numerical baseline. +-- +-- * '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 under most signal +-- regimes. +-- +-- For 'Agrapa' and 'Ons', @lambda_max@ is derived from the sample +-- bounds supplied to the surrounding test 'Statistics.EProcess.Mean.config'. +data Bettor + = Fixed {-# UNPACK #-} !Double + | Agrapa + | Ons + deriving (Eq, Show) diff --git a/lib/Statistics/EProcess/Mean.hs b/lib/Statistics/EProcess/Mean.hs @@ -1,5 +1,6 @@ {-# OPTIONS_HADDOCK prune #-} {-# LANGUAGE BangPatterns #-} +{-# LANGUAGE MagicHash #-} {-# LANGUAGE RecordWildCards #-} -- | @@ -37,16 +38,31 @@ module Statistics.EProcess.Mean ( , samples ) where +import GHC.Exts (Double(D#)) import Statistics.EProcess.Bettor -- | Test outcome at the current sample count. data Verdict = Reject | Continue deriving (Eq, Show) +-- per-direction bettor state. one constructor per 'Bettor' +-- alternative; the constructor used in a given 'State' matches the +-- 'Bettor' chosen in the surrounding 'Config'. +data BetState + = SFixed + | SAgrapa + {-# UNPACK #-} !Double -- sum of z + {-# UNPACK #-} !Double -- sum of z^2 + {-# UNPACK #-} !Int -- count + | SOns + {-# UNPACK #-} !Double -- lambda + {-# UNPACK #-} !Double -- acc (sum of squared gradients) + -- | Test configuration. Constructed by 'config'. -data Config s = Config - { cfgBetPos :: !(Bettor s) - , cfgBetNeg :: !(Bettor s) +data Config = Config + { cfgBettor :: !Bettor + , cfgLamMaxPos :: {-# UNPACK #-} !Double + , cfgLamMaxNeg :: {-# UNPACK #-} !Double , cfgNullMean :: {-# UNPACK #-} !Double , cfgAlpha :: {-# UNPACK #-} !Double , cfgLogThresh :: {-# UNPACK #-} !Double @@ -54,78 +70,141 @@ data Config s = Config -- | Test state. Two log-wealth processes (one per direction) and -- per-direction bettor state. -data State s = State +data State = State { stN :: {-# UNPACK #-} !Int , stLogWPos :: {-# UNPACK #-} !Double , stLogWNeg :: {-# UNPACK #-} !Double - , stBetPos :: !s - , stBetNeg :: !s + , stBetPos :: !BetState + , stBetNeg :: !BetState } +-- floor for the wealth factor before taking a log; keeps the running +-- log-wealth finite when a step pushes the factor to (or below) zero. +-- NB. written via MagicHash because the fractional literal '1.0e-300' +-- compiles as 'fromRational (1.0e-300 :: Rational)', and GHC does +-- not constant-fold the conversion -- leaving a per-step +-- '$wrationalToDouble' call in the worker. +tiny :: Double +tiny = D# 1.0e-300## +{-# INLINE tiny #-} + -- | Build a test configuration. -- --- The bettor argument is a function from @lambda_max@ to a bettor; --- the same builder is used for both directions, with appropriate --- bounds computed from @lo@, @hi@, and @m@. --- -- >>> import qualified Statistics.EProcess.Bettor as B --- >>> let cfg = config 0.5 0.0 1.0 1.0e-6 B.ons +-- >>> let cfg = config 0.5 0.0 1.0 1.0e-6 B.Ons config - :: Double -- ^ null mean @m@ - -> Double -- ^ sample lower bound @lo@ - -> Double -- ^ sample upper bound @hi@ - -> Double -- ^ significance level @alpha@ - -> (Double -> Bettor s) -- ^ bettor builder, taking @lambda_max@ - -> Config s -config !m !lo !hi !alpha mk = Config - { cfgBetPos = mk (0.5 / (m - lo)) - , cfgBetNeg = mk (0.5 / (hi - m)) + :: Double -- ^ null mean @m@ + -> Double -- ^ sample lower bound @lo@ + -> Double -- ^ sample upper bound @hi@ + -> Double -- ^ significance level @alpha@ + -> Bettor -- ^ bettor strategy + -> Config +config !m !lo !hi !alpha !b = Config + { cfgBettor = b + , cfgLamMaxPos = 0.5 / (m - lo) + , cfgLamMaxNeg = 0.5 / (hi - m) , cfgNullMean = m , cfgAlpha = alpha , cfgLogThresh = log (2 / alpha) } --- NB. argument to @mk@ is half the geometric @lambda_max@; the 1/2 +-- NB. the 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 #-} + +-- per-bettor initial state. +initBet :: Bettor -> BetState +initBet b = case b of + Fixed _ -> SFixed + Agrapa -> SAgrapa 0 0 0 + Ons -> SOns 0 1.0e-6 +{-# INLINE initBet #-} -- | Initial state for streaming. -initial :: Config s -> State s -initial Config{..} = State - { stN = 0 - , stLogWPos = 0 - , stLogWNeg = 0 - , stBetPos = bettorInit cfgBetPos - , stBetNeg = bettorInit cfgBetNeg - } +initial :: Config -> State +initial Config{..} = + let !s0 = initBet cfgBettor + in State + { stN = 0 + , stLogWPos = 0 + , stLogWNeg = 0 + , stBetPos = s0 + , stBetNeg = s0 + } +{-# INLINE initial #-} + +-- compute the next bet @lambda@ from the bettor and its current +-- state. @lamMax@ is the direction-specific safety bound. +betLambda :: Bettor -> Double -> BetState -> Double +betLambda b !lamMax !s = case b of + Fixed lam -> lam + Agrapa -> case s of + SAgrapa !sm !sm2 !n + | n == 0 -> 0 + | otherwise -> + let !nd = fromIntegral n + !mu = sm / nd + !mu2 = mu * mu + !var = max 0 (sm2 / nd - mu2) + !den = var + mu2 + !raw = if den == 0 then 0 else mu / den + in max 0 (min lamMax raw) + _ -> 0 + Ons -> case s of + SOns !lam _ -> lam + _ -> 0 +{-# INLINE betLambda #-} + +-- update bettor state with newly observed centred value @z@. +stepBet :: Bettor -> Double -> BetState -> Double -> BetState +stepBet b !lamMax !s !z = case b of + Fixed _ -> SFixed + Agrapa -> case s of + SAgrapa !sm !sm2 !n -> SAgrapa (sm + z) (sm2 + z * z) (n + 1) + _ -> SAgrapa z (z * z) 1 + Ons -> case s of + SOns !lam !acc -> + let !denom = 1 + lam * z + !g = if denom == 0 then 0 else negate z / denom + !acc' = acc + g * g + !lam' = lam - g / acc' + !clp = max 0 (min lamMax lam') + in SOns clp acc' + _ -> SOns 0 1.0e-6 +{-# INLINE stepBet #-} -- | Fold one observation into the state. -update :: Config s -> State s -> Double -> State s +update :: Config -> State -> Double -> State update Config{..} State{..} !x = let !z = x - cfgNullMean - !lamP = bettorBet cfgBetPos stBetPos - !lamN = bettorBet cfgBetNeg stBetNeg + !lamP = betLambda cfgBettor cfgLamMaxPos stBetPos + !lamN = betLambda cfgBettor cfgLamMaxNeg stBetNeg !facP = 1 + lamP * z !facN = 1 - lamN * z - !logWP' = stLogWPos + log (max 1.0e-300 facP) - !logWN' = stLogWNeg + log (max 1.0e-300 facN) - !sP' = bettorStep cfgBetPos stBetPos z - !sN' = bettorStep cfgBetNeg stBetNeg (negate z) + !logWP' = stLogWPos + log (max tiny facP) + !logWN' = stLogWNeg + log (max tiny facN) + !sP' = stepBet cfgBettor cfgLamMaxPos stBetPos z + !sN' = stepBet cfgBettor cfgLamMaxNeg stBetNeg (negate z) in State (stN + 1) logWP' logWN' sP' sN' +{-# INLINE update #-} -- | Decide based on current wealth. -- -- 'Reject' iff either directional log-wealth has crossed the -- Bonferroni-adjusted threshold @log(2 \/ alpha)@. -decide :: Config s -> State s -> Verdict +decide :: Config -> State -> Verdict decide Config{..} State{..} | stLogWPos >= cfgLogThresh = Reject | stLogWNeg >= cfgLogThresh = Reject | otherwise = Continue +{-# INLINE decide #-} -- | Current log-wealth (the larger of the two directional processes). -logWealth :: State s -> Double +logWealth :: State -> Double logWealth State{..} = max stLogWPos stLogWNeg +{-# INLINE logWealth #-} -- | Sample count consumed so far. -samples :: State s -> Int +samples :: State -> Int samples = stN +{-# INLINE samples #-} diff --git a/lib/Statistics/EProcess/TwoSample.hs b/lib/Statistics/EProcess/TwoSample.hs @@ -38,10 +38,10 @@ import Statistics.EProcess.Mean (Verdict(..)) import Statistics.EProcess.Bettor (Bettor) -- | Test configuration. -newtype Config s = Config (M.Config s) +newtype Config = Config M.Config -- | Test state. -newtype State s = State (M.State s) +newtype State = State M.State -- | Build a paired two-sample test configuration. -- @@ -49,34 +49,40 @@ newtype State s = State (M.State s) -- samples; differences then lie in @[lo - hi, hi - lo]@. -- -- >>> import qualified Statistics.EProcess.Bettor as B --- >>> let cfg = config 0.0 1.0 1.0e-6 B.ons +-- >>> let cfg = config 0.0 1.0 1.0e-6 B.Ons config - :: Double -- ^ sample lower bound @lo@ - -> Double -- ^ sample upper bound @hi@ - -> Double -- ^ significance level @alpha@ - -> (Double -> Bettor s) -- ^ bettor builder - -> Config s -config !lo !hi !alpha mk = - let !b = hi - lo - in Config (M.config 0 (negate b) b alpha mk) + :: Double -- ^ sample lower bound @lo@ + -> Double -- ^ sample upper bound @hi@ + -> Double -- ^ significance level @alpha@ + -> Bettor -- ^ bettor strategy + -> Config +config !lo !hi !alpha b = + let !d = hi - lo + in Config (M.config 0 (negate d) d alpha b) +{-# INLINE config #-} -- | Initial state for streaming. -initial :: Config s -> State s +initial :: Config -> State initial (Config c) = State (M.initial c) +{-# INLINE initial #-} -- | Fold one paired observation @(a, b)@ into the state. -update :: Config s -> State s -> (Double, Double) -> State s +update :: Config -> State -> (Double, Double) -> State update (Config c) (State s) (!a, !b) = State (M.update c s (a - b)) +{-# INLINE update #-} -- | Decide based on current wealth. -decide :: Config s -> State s -> Verdict +decide :: Config -> State -> Verdict decide (Config c) (State s) = M.decide c s +{-# INLINE decide #-} -- | Current log-wealth. -logWealth :: State s -> Double +logWealth :: State -> Double logWealth (State s) = M.logWealth s +{-# INLINE logWealth #-} -- | Sample count consumed so far. -samples :: State s -> Int +samples :: State -> Int samples (State s) = M.samples s +{-# INLINE samples #-} diff --git a/test/Main.hs b/test/Main.hs @@ -48,7 +48,7 @@ bernoulli !p g = -- with the early-stopping rule built in. returns (verdict, samples -- consumed). runMeanBernoulli - :: M.Config s + :: M.Config -> Double -- ^ p -> Int -- ^ budget -> Gen @@ -66,7 +66,7 @@ runMeanBernoulli cfg p budget g0 = go 0 g0 (M.initial cfg) -- fraction of trials that rejected. rejectionRate - :: M.Config s + :: M.Config -> Double -- ^ true bernoulli p -> Int -- ^ budget per trial -> Int -- ^ number of trials @@ -88,12 +88,12 @@ genSeq g = let (_, g') = stepGen g in g : genSeq g' sanityTests :: TestTree sanityTests = testGroup "sanity" [ testCase "degenerate input never rejects" $ do - let cfg = M.config 0.5 0.0 1.0 1.0e-6 E.ons :: M.Config E.ONS + let cfg = M.config 0.5 0.0 1.0 1.0e-6 E.Ons xs = replicate 5000 0.5 st = foldl' (M.update cfg) (M.initial cfg) xs M.decide cfg st @?= M.Continue , testCase "two-sided thresholds applied symmetrically" $ do - let cfg = M.config 0.5 0.0 1.0 1.0e-6 E.ons :: M.Config E.ONS + let cfg = M.config 0.5 0.0 1.0 1.0e-6 E.Ons M.decide cfg (M.initial cfg) @?= M.Continue ] @@ -104,14 +104,14 @@ sanityTests = testGroup "sanity" [ calibrationTests :: TestTree calibrationTests = testGroup "null calibration" [ testCase "ONS, Bernoulli(0.5), m=0.5, alpha=0.05" $ do - let cfg = M.config 0.5 0.0 1.0 0.05 E.ons :: M.Config E.ONS + let cfg = M.config 0.5 0.0 1.0 0.05 E.Ons rate = rejectionRate cfg 0.5 2000 200 12345 -- expected rate ≤ 0.05; allow up to 0.10 slack for sampling -- variability over 200 trials. assertBool ("FPR " ++ show rate ++ " exceeded slack") $ rate <= 0.10 , testCase "aGRAPA, Bernoulli(0.5), m=0.5, alpha=0.05" $ do - let cfg = M.config 0.5 0.0 1.0 0.05 E.agrapa :: M.Config E.AGRAPA + let cfg = M.config 0.5 0.0 1.0 0.05 E.Agrapa rate = rejectionRate cfg 0.5 2000 200 67890 assertBool ("FPR " ++ show rate ++ " exceeded slack") $ rate <= 0.10 @@ -123,13 +123,12 @@ calibrationTests = testGroup "null calibration" [ powerTests :: TestTree powerTests = testGroup "power" [ testCase "ONS detects Bernoulli(0.7) vs m=0.5" $ do - let cfg = M.config 0.5 0.0 1.0 1.0e-3 E.ons :: M.Config E.ONS + let cfg = M.config 0.5 0.0 1.0 1.0e-3 E.Ons rate = rejectionRate cfg 0.7 5000 100 11111 assertBool ("power " ++ show rate ++ " too low") $ rate >= 0.95 , testCase "aGRAPA detects Bernoulli(0.7) vs m=0.5" $ do - let cfg = M.config 0.5 0.0 1.0 1.0e-3 E.agrapa - :: M.Config E.AGRAPA + let cfg = M.config 0.5 0.0 1.0 1.0e-3 E.Agrapa rate = rejectionRate cfg 0.7 5000 100 22222 assertBool ("power " ++ show rate ++ " too low") $ rate >= 0.95 @@ -138,7 +137,7 @@ powerTests = testGroup "power" [ -- two-sample paired test. runTSPaired - :: TS.Config s + :: TS.Config -> Double -> Double -- ^ p for A and B -> Int @@ -159,11 +158,11 @@ runTSPaired cfg pA pB budget g0 = go 0 g0 (TS.initial cfg) twoSampleTests :: TestTree twoSampleTests = testGroup "two-sample" [ testCase "identical distributions don't reject" $ do - let cfg = TS.config 0.0 1.0 1.0e-3 E.ons :: TS.Config E.ONS + let cfg = TS.config 0.0 1.0 1.0e-3 E.Ons rate = avgRate cfg 0.5 0.5 2000 100 33333 assertBool ("FPR " ++ show rate) $ rate <= 0.05 , testCase "different distributions reject" $ do - let cfg = TS.config 0.0 1.0 1.0e-3 E.ons :: TS.Config E.ONS + let cfg = TS.config 0.0 1.0 1.0e-3 E.Ons rate = avgRate cfg 0.3 0.7 5000 100 44444 assertBool ("power " ++ show rate) $ rate >= 0.95 ] @@ -182,19 +181,17 @@ twoSampleTests = testGroup "two-sample" [ bettorSmokeTests :: TestTree bettorSmokeTests = testGroup "bettor smoke" [ testCase "fixed bettor runs without error" $ do - let cfg = M.config 0.5 0.0 1.0 1.0e-3 - (const (E.fixed 0.5)) :: M.Config () + let cfg = M.config 0.5 0.0 1.0 1.0e-3 (E.Fixed 0.5) xs = take 100 (cycle [0.0, 1.0]) st = foldl' (M.update cfg) (M.initial cfg) xs assertBool "samples advanced" (M.samples st == 100) , testCase "ONS bettor runs without error" $ do - let cfg = M.config 0.5 0.0 1.0 1.0e-3 E.ons :: M.Config E.ONS + let cfg = M.config 0.5 0.0 1.0 1.0e-3 E.Ons xs = take 100 (cycle [0.0, 1.0]) st = foldl' (M.update cfg) (M.initial cfg) xs assertBool "samples advanced" (M.samples st == 100) , testCase "aGRAPA bettor runs without error" $ do - let cfg = M.config 0.5 0.0 1.0 1.0e-3 E.agrapa - :: M.Config E.AGRAPA + let cfg = M.config 0.5 0.0 1.0 1.0e-3 E.Agrapa xs = take 100 (cycle [0.0, 1.0]) st = foldl' (M.update cfg) (M.initial cfg) xs assertBool "samples advanced" (M.samples st == 100)