eproc

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

commit 347be5310855537b93d034bbd5338ce6b5afeca9
parent dcd9754da35f9ded3faeef878fbf234497ed11c2
Author: Jared Tobin <jared@jtobin.io>
Date:   Wed,  3 Jun 2026 11:40:29 -0230

fold Bettor into Bounded; drop Numeric.Eproc.Bettor

The Bettor module was thin -- just a three-constructor enum and its
documentation -- and the only module that actually case-analyzes on
Bettor is Bounded (Paired delegates to Bounded internally). Moving
the type to where it is used eliminates the thin module without
duplicating across the two test families.

  * 'data Bettor' now lives in Numeric.Eproc.Bounded and is exported
    alongside Config / State / Verdict.
  * Numeric.Eproc.Paired re-exports Bettor(..) the same way it
    re-exports Verdict(..); no API change at the Paired call site.
  * Numeric.Eproc.Bettor deleted; removed from cabal exposed-modules.
  * Tests, benches, and the README drop the 'B' import; bettor
    constructors are referenced as Bounded.Ons / Bounded.Agrapa /
    Bounded.Fixed throughout (and as the bare constructors inside the
    library's own haddock examples).

Tests pass.

Diffstat:
MREADME.md | 5++---
Mbench/Main.hs | 21++++++++++-----------
Mbench/Weight.hs | 21++++++++++-----------
Dlib/Numeric/Eproc/Bettor.hs | 62--------------------------------------------------------------
Mlib/Numeric/Eproc/Bounded.hs | 44+++++++++++++++++++++++++++++++++++++++++---
Mlib/Numeric/Eproc/Paired.hs | 9+++++----
Mppad-eproc.cabal | 1-
Mtest/Main.hs | 23+++++++++++------------
8 files changed, 79 insertions(+), 107 deletions(-)

diff --git a/README.md b/README.md @@ -17,12 +17,11 @@ A sample GHCi session: ``` > -- import qualified - > import qualified Numeric.Eproc.Bettor as B > import qualified Numeric.Eproc.Bounded as Bounded > > -- test H_0: E[X] = 0.5 for samples in [0, 1] at alpha = 1e-3, > -- with the ONS bettor - > let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons + > let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons > > -- streaming interface: 'initial' then fold observations through 'update' > let s0 = Bounded.initial cfg @@ -113,7 +112,7 @@ to get a REPL for the main library. ## References - Waudby-Smith & Ramdas (2024), "[Estimating means of bounded random - variables by betting][wsr24]." JRSS-B. + variables by betting][wsr24]." JRSS-Bounded. - Ramdas, Grunwald, Vovk, Shafer (2023), "[Game-theoretic statistics and safe anytime-valid inference][rgvs23]." Statistical Science. - Shafer (2021), "[Testing by betting][shafer21]." JRSS-A. diff --git a/bench/Main.hs b/bench/Main.hs @@ -4,7 +4,6 @@ module Main where import Control.DeepSeq -import qualified Numeric.Eproc.Bettor as B import qualified Numeric.Eproc.Bounded as Bounded import qualified Numeric.Eproc.Paired as P import Criterion.Main @@ -26,9 +25,9 @@ main = defaultMain [ update :: Benchmark update = - let !cfg_f = Bounded.config 0.5 0.0 1.0 1.0e-3 (B.Fixed 0.5) - !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Agrapa - !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons + let !cfg_f = Bounded.config 0.5 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5) + !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Agrapa + !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons !st_f = Bounded.initial cfg_f !st_a = Bounded.initial cfg_a !st_o = Bounded.initial cfg_o @@ -41,7 +40,7 @@ update = decide :: Benchmark decide = - let !cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons + let !cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons !st = Bounded.initial cfg in bgroup "Bounded.decide" [ bench "initial state" $ nf (Bounded.decide cfg) st @@ -50,9 +49,9 @@ decide = stream :: Benchmark stream = let !xs = force (take 1000 (cycle [0.3, 0.7])) - !cfg_f = Bounded.config 0.5 0.0 1.0 1.0e-3 (B.Fixed 0.5) - !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Agrapa - !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons + !cfg_f = Bounded.config 0.5 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5) + !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Agrapa + !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons run_m cfg = foldl' (Bounded.update cfg) (Bounded.initial cfg) in bgroup "Bounded.update (1000-sample fold)" [ bench "fixed" $ nf (run_m cfg_f) xs @@ -63,9 +62,9 @@ stream = twosample :: Benchmark twosample = let !ps = force (take 1000 (cycle [(0.3, 0.7), (0.7, 0.3)])) - !cfg_f = P.config 0.0 1.0 1.0e-3 (B.Fixed 0.5) - !cfg_a = P.config 0.0 1.0 1.0e-3 B.Agrapa - !cfg_o = P.config 0.0 1.0 1.0e-3 B.Ons + !cfg_f = P.config 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5) + !cfg_a = P.config 0.0 1.0 1.0e-3 Bounded.Agrapa + !cfg_o = P.config 0.0 1.0 1.0e-3 Bounded.Ons run_t cfg = foldl' (P.update cfg) (P.initial cfg) in bgroup "Paired.update (1000-sample fold)" [ bench "fixed" $ nf (run_t cfg_f) ps diff --git a/bench/Weight.hs b/bench/Weight.hs @@ -4,7 +4,6 @@ module Main where import Control.DeepSeq -import qualified Numeric.Eproc.Bettor as B import qualified Numeric.Eproc.Bounded as Bounded import qualified Numeric.Eproc.Paired as P import Weigh @@ -23,9 +22,9 @@ main = mainWith $ do update :: Weigh () update = - let !cfg_f = Bounded.config 0.5 0.0 1.0 1.0e-3 (B.Fixed 0.5) - !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Agrapa - !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons + let !cfg_f = Bounded.config 0.5 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5) + !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Agrapa + !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons !st_f = Bounded.initial cfg_f !st_a = Bounded.initial cfg_a !st_o = Bounded.initial cfg_o @@ -36,7 +35,7 @@ update = decide :: Weigh () decide = - let !cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons + let !cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons !st = Bounded.initial cfg in wgroup "Bounded.decide" $ do func "initial state" (Bounded.decide cfg) st @@ -44,9 +43,9 @@ decide = stream :: Weigh () stream = let !xs = force (take 1000 (cycle [0.3, 0.7])) - !cfg_f = Bounded.config 0.5 0.0 1.0 1.0e-3 (B.Fixed 0.5) - !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Agrapa - !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons + !cfg_f = Bounded.config 0.5 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5) + !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Agrapa + !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons run_m cfg = foldl' (Bounded.update cfg) (Bounded.initial cfg) in wgroup "Bounded.update (1000-sample fold)" $ do func "fixed" (run_m cfg_f) xs @@ -56,9 +55,9 @@ stream = twosample :: Weigh () twosample = let !ps = force (take 1000 (cycle [(0.3, 0.7), (0.7, 0.3)])) - !cfg_f = P.config 0.0 1.0 1.0e-3 (B.Fixed 0.5) - !cfg_a = P.config 0.0 1.0 1.0e-3 B.Agrapa - !cfg_o = P.config 0.0 1.0 1.0e-3 B.Ons + !cfg_f = P.config 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5) + !cfg_a = P.config 0.0 1.0 1.0e-3 Bounded.Agrapa + !cfg_o = P.config 0.0 1.0 1.0e-3 Bounded.Ons run_t cfg = foldl' (P.update cfg) (P.initial cfg) in wgroup "Paired.update (1000-sample fold)" $ do func "fixed" (run_t cfg_f) ps diff --git a/lib/Numeric/Eproc/Bettor.hs b/lib/Numeric/Eproc/Bettor.hs @@ -1,62 +0,0 @@ -{-# OPTIONS_HADDOCK prune #-} -{-# LANGUAGE BangPatterns #-} - --- | --- Module: Numeric.Eproc.Bettor --- Copyright: (c) 2026 Jared Tobin --- License: MIT --- Maintainer: Jared Tobin <jared@ppad.tech> --- --- Bettor strategies for the e-process framework. --- --- A bettor describes how, given the history of centred observations --- @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 Numeric.Eproc.Bettor ( - -- * Bettor strategies - Bettor(..) - ) where - --- bettor strategies ---------------------------------------------------------- - --- | A predictable bettor. --- --- 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. --- 'Numeric.Eproc.Bounded.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 constant @lambda@. The wager --- does not respond to observed data; this strategy is useful only --- as a baseline. --- --- * 'Agrapa' is the aGRAPA (approximate growth-rate adaptive --- 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. -data Bettor = - Fixed {-# UNPACK #-} !Double - | Agrapa - | Ons - deriving (Eq, Show) diff --git a/lib/Numeric/Eproc/Bounded.hs b/lib/Numeric/Eproc/Bounded.hs @@ -33,6 +33,9 @@ module Numeric.Eproc.Bounded ( , State , Verdict(..) + -- * Bettor strategies + , Bettor(..) + -- * Construction , config , initial @@ -47,10 +50,46 @@ module Numeric.Eproc.Bounded ( ) where import GHC.Exts (Double(D#)) -import Numeric.Eproc.Bettor -- types ---------------------------------------------------------------------- +-- | A predictable bettor. +-- +-- A bettor describes how, given the history of centred observations +-- @z_t = x_t - m@, the next predictable bet @lambda_t@ is chosen. +-- Predictability -- that is, @lambda_t@ depends only on data +-- observed strictly before step @t@ -- is what makes the resulting +-- wealth process a nonnegative supermartingale under @H_0@. +-- +-- For 'Agrapa' and 'Ons', a per-direction safe-bet ceiling +-- @lambda_max@ is derived from the sample bounds supplied to +-- '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 constant @lambda@. The wager +-- does not respond to observed data; this strategy is useful only +-- as a baseline. +-- +-- * 'Agrapa' is the aGRAPA (approximate growth-rate adaptive +-- 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. +data Bettor = + Fixed {-# UNPACK #-} !Double + | Agrapa + | Ons + deriving (Eq, Show) + -- | Test outcome at the current sample count. -- -- 'Reject' means the wealth process has crossed the Bonferroni @@ -197,8 +236,7 @@ step_bet b !lam_max !s !z = case b of -- @log(2 \/ alpha)@; the 2 is the Bonferroni union-bound -- adjustment for the two one-sided e-processes. -- --- >>> import qualified Numeric.Eproc.Bettor as B --- >>> let cfg = config 0.5 0.0 1.0 1.0e-3 B.Ons +-- >>> let cfg = config 0.5 0.0 1.0 1.0e-3 Ons config :: Double -- ^ null mean @m@ -> Double -- ^ sample lower bound @lo@ diff --git a/lib/Numeric/Eproc/Paired.hs b/lib/Numeric/Eproc/Paired.hs @@ -30,6 +30,9 @@ module Numeric.Eproc.Paired ( , State , Verdict(..) + -- * Bettor strategies + , Bettor(..) + -- * Construction , config , initial @@ -44,8 +47,7 @@ module Numeric.Eproc.Paired ( ) where import qualified Numeric.Eproc.Bounded as Bounded -import Numeric.Eproc.Bounded (Verdict(..)) -import Numeric.Eproc.Bettor (Bettor) +import Numeric.Eproc.Bounded (Verdict(..), Bettor(..)) -- types ---------------------------------------------------------------------- @@ -67,8 +69,7 @@ newtype State = State Bounded.State -- on the differences, which lie in @[lo - hi, hi - lo]@ with null -- mean @0@. -- --- >>> import qualified Numeric.Eproc.Bettor as B --- >>> let cfg = config 0.0 1.0 1.0e-3 B.Ons +-- >>> let cfg = config 0.0 1.0 1.0e-3 Ons config :: Double -- ^ sample lower bound @lo@ -> Double -- ^ sample upper bound @hi@ diff --git a/ppad-eproc.cabal b/ppad-eproc.cabal @@ -34,7 +34,6 @@ library if flag(llvm) ghc-options: -fllvm -O2 exposed-modules: - Numeric.Eproc.Bettor Numeric.Eproc.Bounded Numeric.Eproc.Paired build-depends: diff --git a/test/Main.hs b/test/Main.hs @@ -4,7 +4,6 @@ module Main where import Data.Bits import Data.Word -import qualified Numeric.Eproc.Bettor as B import qualified Numeric.Eproc.Bounded as Bounded import qualified Numeric.Eproc.Paired as P import Test.Tasty @@ -127,12 +126,12 @@ paired_avg_rate cfg pa pb budget trials seed = sanity_tests :: TestTree sanity_tests = testGroup "sanity" [ testCase "degenerate input never rejects" $ do - let cfg = Bounded.config 0.5 0.0 1.0 1.0e-6 B.Ons + let cfg = Bounded.config 0.5 0.0 1.0 1.0e-6 Bounded.Ons xs = replicate 5000 0.5 st = foldl' (Bounded.update cfg) (Bounded.initial cfg) xs Bounded.decide cfg st @?= Bounded.Continue , testCase "two-sided thresholds applied symmetrically" $ do - let cfg = Bounded.config 0.5 0.0 1.0 1.0e-6 B.Ons + let cfg = Bounded.config 0.5 0.0 1.0 1.0e-6 Bounded.Ons Bounded.decide cfg (Bounded.initial cfg) @?= Bounded.Continue ] @@ -144,14 +143,14 @@ sanity_tests = testGroup "sanity" [ calibration_tests :: TestTree calibration_tests = testGroup "null calibration" [ testCase "ONS, Bernoulli(0.5), m=0.5, alpha=0.05" $ do - let cfg = Bounded.config 0.5 0.0 1.0 0.05 B.Ons + let cfg = Bounded.config 0.5 0.0 1.0 0.05 Bounded.Ons rate = rejection_rate 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 = Bounded.config 0.5 0.0 1.0 0.05 B.Agrapa + let cfg = Bounded.config 0.5 0.0 1.0 0.05 Bounded.Agrapa rate = rejection_rate cfg 0.5 2000 200 67890 assertBool ("FPR " ++ show rate ++ " exceeded slack") $ rate <= 0.10 @@ -163,12 +162,12 @@ calibration_tests = testGroup "null calibration" [ power_tests :: TestTree power_tests = testGroup "power" [ testCase "ONS detects Bernoulli(0.7) vs m=0.5" $ do - let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons + let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons rate = rejection_rate 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 = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Agrapa + let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Agrapa rate = rejection_rate cfg 0.7 5000 100 22222 assertBool ("power " ++ show rate ++ " too low") $ rate >= 0.95 @@ -179,11 +178,11 @@ power_tests = testGroup "power" [ two_sample_tests :: TestTree two_sample_tests = testGroup "two-sample" [ testCase "identical distributions don't reject" $ do - let cfg = P.config 0.0 1.0 1.0e-3 B.Ons + let cfg = P.config 0.0 1.0 1.0e-3 Bounded.Ons rate = paired_avg_rate cfg 0.5 0.5 2000 100 33333 assertBool ("FPR " ++ show rate) $ rate <= 0.05 , testCase "different distributions reject" $ do - let cfg = P.config 0.0 1.0 1.0e-3 B.Ons + let cfg = P.config 0.0 1.0 1.0e-3 Bounded.Ons rate = paired_avg_rate cfg 0.3 0.7 5000 100 44444 assertBool ("power " ++ show rate) $ rate >= 0.95 ] @@ -195,17 +194,17 @@ two_sample_tests = testGroup "two-sample" [ bettor_smoke_tests :: TestTree bettor_smoke_tests = testGroup "bettor smoke" [ testCase "fixed bettor runs without error" $ do - let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 (B.Fixed 0.5) + let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 (Bounded.Fixed 0.5) xs = take 100 (cycle [0.0, 1.0]) st = foldl' (Bounded.update cfg) (Bounded.initial cfg) xs assertBool "samples advanced" (Bounded.samples st == 100) , testCase "ONS bettor runs without error" $ do - let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Ons + let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons xs = take 100 (cycle [0.0, 1.0]) st = foldl' (Bounded.update cfg) (Bounded.initial cfg) xs assertBool "samples advanced" (Bounded.samples st == 100) , testCase "aGRAPA bettor runs without error" $ do - let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 B.Agrapa + let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Agrapa xs = take 100 (cycle [0.0, 1.0]) st = foldl' (Bounded.update cfg) (Bounded.initial cfg) xs assertBool "samples advanced" (Bounded.samples st == 100)