eproc

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

commit 01225b5275cdae0215412fc0704c88bfc2128dd6
parent 55bab648d1e9a7b46ef8d51ff2b506dbb08dcc0c
Author: Jared Tobin <jared@jtobin.io>
Date:   Thu,  4 Jun 2026 14:56:18 -0230

rename Agrapa to Adaptive, Ons to Newton

retains the aGRAPA acronym expansion in the Adaptive haddock.

Diffstat:
MREADME.md | 10+++++-----
Mbench/Main.hs | 26+++++++++++++-------------
Mbench/Weight.hs | 26+++++++++++++-------------
Mlib/Numeric/Eproc/Bounded.hs | 67++++++++++++++++++++++++++++++++++---------------------------------
Mlib/Numeric/Eproc/Paired.hs | 2+-
Mtest/Main.hs | 32++++++++++++++++----------------
6 files changed, 82 insertions(+), 81 deletions(-)

diff --git a/README.md b/README.md @@ -16,8 +16,8 @@ A sample GHCi session: > import qualified Numeric.Eproc.Bounded as Bounded > > -- hypothesis: E[X] = 0.5 for samples in [0, 1] at alpha = 1e-3, tested - > -- with the ONS bettor - > let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons + > -- with the Newton bettor + > let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton > let s0 = Bounded.initial cfg > > -- ten observations (drifting from hypothesis), and state afterwards @@ -52,7 +52,7 @@ Current benchmark figures on an M4 Silicon MacBook Air look like (use `cabal bench` to run the benchmark suite): ``` - benchmarking Bounded.update (one step)/ons + benchmarking Bounded.update (one step)/newton time 13.05 ns (12.95 ns .. 13.17 ns) 1.000 R² (0.999 R² .. 1.000 R²) mean 13.03 ns (12.95 ns .. 13.15 ns) @@ -64,13 +64,13 @@ Current benchmark figures on an M4 Silicon MacBook Air look like (use mean 4.828 μs (4.817 μs .. 4.847 μs) std dev 44.90 ns (30.94 ns .. 61.54 ns) - benchmarking Bounded.update (1000-sample fold)/agrapa + benchmarking Bounded.update (1000-sample fold)/adaptive time 15.67 μs (15.66 μs .. 15.69 μs) 1.000 R² (1.000 R² .. 1.000 R²) mean 15.67 μs (15.65 μs .. 15.69 μs) std dev 63.74 ns (55.65 ns .. 75.07 ns) - benchmarking Bounded.update (1000-sample fold)/ons + benchmarking Bounded.update (1000-sample fold)/newton time 14.43 μs (14.42 μs .. 14.44 μs) 1.000 R² (1.000 R² .. 1.000 R²) mean 14.43 μs (14.42 μs .. 14.44 μs) diff --git a/bench/Main.hs b/bench/Main.hs @@ -26,21 +26,21 @@ main = defaultMain [ update :: Benchmark update = 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 + !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive + !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton !st_f = Bounded.initial cfg_f !st_a = Bounded.initial cfg_a !st_o = Bounded.initial cfg_o !x = 0.7 in bgroup "Bounded.update (one step)" [ bench "fixed" $ nf (Bounded.update cfg_f st_f) x - , bench "agrapa" $ nf (Bounded.update cfg_a st_a) x - , bench "ons" $ nf (Bounded.update cfg_o st_o) x + , bench "adaptive" $ nf (Bounded.update cfg_a st_a) x + , bench "newton" $ nf (Bounded.update cfg_o st_o) x ] decide :: Benchmark decide = - let !cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons + let !cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton !st = Bounded.initial cfg in bgroup "Bounded.decide" [ bench "initial state" $ nf (Bounded.decide cfg) st @@ -50,24 +50,24 @@ 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 (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 + !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive + !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton 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 - , bench "agrapa" $ nf (run_m cfg_a) xs - , bench "ons" $ nf (run_m cfg_o) xs + , bench "adaptive" $ nf (run_m cfg_a) xs + , bench "newton" $ nf (run_m cfg_o) xs ] 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 (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 + !cfg_a = P.config 0.0 1.0 1.0e-3 Bounded.Adaptive + !cfg_o = P.config 0.0 1.0 1.0e-3 Bounded.Newton 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 - , bench "agrapa" $ nf (run_t cfg_a) ps - , bench "ons" $ nf (run_t cfg_o) ps + , bench "adaptive" $ nf (run_t cfg_a) ps + , bench "newton" $ nf (run_t cfg_o) ps ] diff --git a/bench/Weight.hs b/bench/Weight.hs @@ -23,19 +23,19 @@ main = mainWith $ do update :: Weigh () update = 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 + !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive + !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton !st_f = Bounded.initial cfg_f !st_a = Bounded.initial cfg_a !st_o = Bounded.initial cfg_o in wgroup "Bounded.update (one step)" $ do func "fixed" (Bounded.update cfg_f st_f) 0.7 - func "agrapa" (Bounded.update cfg_a st_a) 0.7 - func "ons" (Bounded.update cfg_o st_o) 0.7 + func "adaptive" (Bounded.update cfg_a st_a) 0.7 + func "newton" (Bounded.update cfg_o st_o) 0.7 decide :: Weigh () decide = - let !cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Ons + let !cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton !st = Bounded.initial cfg in wgroup "Bounded.decide" $ do func "initial state" (Bounded.decide cfg) st @@ -44,22 +44,22 @@ 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 (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 + !cfg_a = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive + !cfg_o = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton 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 - func "agrapa" (run_m cfg_a) xs - func "ons" (run_m cfg_o) xs + func "adaptive" (run_m cfg_a) xs + func "newton" (run_m cfg_o) xs 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 (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 + !cfg_a = P.config 0.0 1.0 1.0e-3 Bounded.Adaptive + !cfg_o = P.config 0.0 1.0 1.0e-3 Bounded.Newton 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 - func "agrapa" (run_t cfg_a) ps - func "ons" (run_t cfg_o) ps + func "adaptive" (run_t cfg_a) ps + func "newton" (run_t cfg_o) ps diff --git a/lib/Numeric/Eproc/Bounded.hs b/lib/Numeric/Eproc/Bounded.hs @@ -61,7 +61,7 @@ import GHC.Exts (Double(D#)) -- 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 +-- For 'Adaptive' and 'Newton', 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 @@ -71,23 +71,24 @@ import GHC.Exts (Double(D#)) -- does not respond to observed data; this strategy is useful only -- as a baseline. -- --- * 'Agrapa' is the aGRAPA (approximate growth-rate adaptive +-- * 'Adaptive' 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. +-- * 'Newton' is the online Newton step (ONS) 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 + | Adaptive + | Newton deriving (Eq, Show) -- | Test outcome at the current sample count. @@ -107,11 +108,11 @@ data Verdict = -- in the enclosing 'Config'. data BetState = SFixed - | SAgrapa + | SAdaptive {-# UNPACK #-} !Double -- sum of z (centred observation) {-# UNPACK #-} !Double -- sum of z^2 (for online variance) {-# UNPACK #-} !Int -- count - | SOns + | SNewton {-# UNPACK #-} !Double -- current bet lambda {-# UNPACK #-} !Double -- running sum of per-step squared gradients @@ -168,21 +169,21 @@ tiny = D# 1.0e-300## -- per-bettor initial state. init_bet :: Bettor -> BetState init_bet b = case b of - Fixed _ -> SFixed - Agrapa -> SAgrapa 0 0 0 - Ons -> SOns 0 1.0e-6 -- small acc seed avoids div-by-zero on first step + Fixed _ -> SFixed + Adaptive -> SAdaptive 0 0 0 + Newton -> SNewton 0 1.0e-6 -- small acc seed avoids div-by-zero {-# INLINE init_bet #-} -- compute the next bet 'lambda' from the bettor and its current -- 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'). +-- Adaptive we form a Kelly-style plug-in from the running sample +-- mean and variance; for Newton 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 - Agrapa -> case s of - SAgrapa !sm !sm2 !n + Adaptive -> case s of + SAdaptive !sm !sm2 !n | n == 0 -> 0 | otherwise -> let !nd = fromIntegral n @@ -193,30 +194,30 @@ bet_lambda b !lam_max !s = case b of !raw = if den == 0 then 0 else mu / den in max 0 (min lam_max raw) _ -> 0 - Ons -> case s of - SOns !lam _ -> lam - _ -> 0 + Newton -> case s of + SNewton !lam _ -> lam + _ -> 0 {-# INLINE bet_lambda #-} -- 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)', +-- Adaptive this is just accumulating sums; for Newton 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 - 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 -> + Adaptive -> case s of + SAdaptive !sm !sm2 !n -> SAdaptive (sm + z) (sm2 + z * z) (n + 1) + _ -> SAdaptive z (z * z) 1 + Newton -> case s of + SNewton !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 lam_max lam') - in SOns clp acc' - _ -> SOns 0 1.0e-6 + in SNewton clp acc' + _ -> SNewton 0 1.0e-6 {-# INLINE step_bet #-} -- construction --------------------------------------------------------------- @@ -242,7 +243,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. -- --- >>> let cfg = config 0.5 0.0 1.0 1.0e-3 Ons +-- >>> let cfg = config 0.5 0.0 1.0 1.0e-3 Newton config :: Double -- ^ null mean @m@ -> Double -- ^ sample lower bound @lo@ diff --git a/lib/Numeric/Eproc/Paired.hs b/lib/Numeric/Eproc/Paired.hs @@ -69,7 +69,7 @@ newtype State = State Bounded.State -- on the differences, which lie in @[lo - hi, hi - lo]@ with null -- mean @0@. -- --- >>> let cfg = config 0.0 1.0 1.0e-3 Ons +-- >>> let cfg = config 0.0 1.0 1.0e-3 Newton config :: Double -- ^ sample lower bound @lo@ -> Double -- ^ sample upper bound @hi@ diff --git a/test/Main.hs b/test/Main.hs @@ -126,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 Bounded.Ons + let cfg = Bounded.config 0.5 0.0 1.0 1.0e-6 Bounded.Newton 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 Bounded.Ons + let cfg = Bounded.config 0.5 0.0 1.0 1.0e-6 Bounded.Newton Bounded.decide cfg (Bounded.initial cfg) @?= Bounded.Continue ] @@ -142,15 +142,15 @@ sanity_tests = testGroup "sanity" [ -- so the slack is small. 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 Bounded.Ons + testCase "Newton, Bernoulli(0.5), m=0.5, alpha=0.05" $ do + let cfg = Bounded.config 0.5 0.0 1.0 0.05 Bounded.Newton 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 Bounded.Agrapa + , testCase "Adaptive, Bernoulli(0.5), m=0.5, alpha=0.05" $ do + let cfg = Bounded.config 0.5 0.0 1.0 0.05 Bounded.Adaptive rate = rejection_rate cfg 0.5 2000 200 67890 assertBool ("FPR " ++ show rate ++ " exceeded slack") $ rate <= 0.10 @@ -161,13 +161,13 @@ calibration_tests = testGroup "null calibration" [ -- under a clear shift, all (or nearly all) trials reject within budget. 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 Bounded.Ons + testCase "Newton detects Bernoulli(0.7) vs m=0.5" $ do + let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton 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 Bounded.Agrapa + , testCase "Adaptive detects Bernoulli(0.7) vs m=0.5" $ do + let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive rate = rejection_rate cfg 0.7 5000 100 22222 assertBool ("power " ++ show rate ++ " too low") $ rate >= 0.95 @@ -178,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 Bounded.Ons + let cfg = P.config 0.0 1.0 1.0e-3 Bounded.Newton 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 Bounded.Ons + let cfg = P.config 0.0 1.0 1.0e-3 Bounded.Newton rate = paired_avg_rate cfg 0.3 0.7 5000 100 44444 assertBool ("power " ++ show rate) $ rate >= 0.95 ] @@ -198,13 +198,13 @@ bettor_smoke_tests = testGroup "bettor smoke" [ 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 Bounded.Ons + , testCase "Newton bettor runs without error" $ do + let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Newton 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 Bounded.Agrapa + , testCase "Adaptive bettor runs without error" $ do + let cfg = Bounded.config 0.5 0.0 1.0 1.0e-3 Bounded.Adaptive xs = take 100 (cycle [0.0, 1.0]) st = foldl' (Bounded.update cfg) (Bounded.initial cfg) xs assertBool "samples advanced" (Bounded.samples st == 100)