eproc

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

commit e168b262bac7bd5ae011314dab46c37ffee12055
parent f60ba344661745c8829cf46c4539694086c473c2
Author: Jared Tobin <jared@jtobin.io>
Date:   Tue,  2 Jun 2026 22:03:57 -0230

docs: enrich haddocks with e-process framework context

Expand the module-, type-, and function-level haddocks to give
readers enough domain context to make sense of the API without
needing the WSR paper in hand.

  * Bettor: module header now describes the wealth-process
    construction and Ville's-inequality justification; per-constructor
    docs explain the strategy (Fixed = baseline, Agrapa = Kelly
    plug-in with mu/sigma^2, Ons = online Newton on per-step
    log-wealth loss) and the role of lambda_max.

  * Mean: module header explains the two-direction (positive /
    negative) Bonferroni decomposition; Config and State get per-field
    haddocks (cfg_lam_max_pos / cfg_lam_max_neg / st_log_w_pos / ...
    so the reader knows what each field is for); 'config' derives the
    safe-bet ceilings from sample bounds explicitly; 'update'
    documents the wealth-update equation; 'decide' explains the
    anytime-valid stopping guarantee; 'log_wealth' framed as the
    test statistic.

  * TwoSample: module header explains the reduction (paired diffs
    feed the bounded-mean test); per-function docs refer back to the
    underlying mean test.

No code or API changes; tests pass, haddock builds 14/14 in each
module.

Diffstat:
Mlib/Statistics/EProcess/Bettor.hs | 52++++++++++++++++++++++++++++++++--------------------
Mlib/Statistics/EProcess/Mean.hs | 128++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mlib/Statistics/EProcess/TwoSample.hs | 38++++++++++++++++++++++++++++++--------
3 files changed, 163 insertions(+), 55 deletions(-)

diff --git a/lib/Statistics/EProcess/Bettor.hs b/lib/Statistics/EProcess/Bettor.hs @@ -10,11 +10,17 @@ -- 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 process a --- nonnegative supermartingale under the null hypothesis, and hence --- anytime-valid via Ville's inequality. +-- @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 Statistics.EProcess.Bettor ( -- * Bettor strategies @@ -25,24 +31,30 @@ module Statistics.EProcess.Bettor ( -- | A predictable bettor. -- --- For 'Agrapa' and 'Ons', the safe bet bound @lambda_max@ is derived --- from the sample bounds supplied to the surrounding test --- configuration (e.g. 'Statistics.EProcess.Mean.config'). +-- 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. +-- 'Statistics.EProcess.Mean.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 @lambda@; useful for smoke --- testing the framework and as a numerical baseline. +-- * 'Fixed' always bets the supplied constant @lambda@. The wager +-- does not respond to observed data; this strategy is useful only +-- as a baseline or for smoke testing the framework. -- -- * '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 bettors under most --- signal regimes. +-- 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. -- -- >>> Fixed 0.5 -- Fixed 0.5 diff --git a/lib/Statistics/EProcess/Mean.hs b/lib/Statistics/EProcess/Mean.hs @@ -12,12 +12,20 @@ -- Two-sided bounded-mean anytime-valid test. -- -- For samples @x_t@ in @[lo, hi]@, tests @H_0: E[x] = m@ against --- @H_1: E[x] /= m@. Runs two e-processes simultaneously (one per --- direction) and combines them by Bonferroni: reject if either side's --- wealth crosses @2 \/ alpha@. +-- @H_1: E[x] /= m@. -- --- The test is anytime-valid: type-I error is controlled at @alpha@ --- regardless of when the user stops streaming samples. +-- Internally two one-sided e-processes are run in parallel: a +-- /positive-direction/ process betting against the alternative +-- @E[x] > m@ (using centred observations @z = x - m@), and a +-- /negative-direction/ process betting against @E[x] < m@ (using +-- @-z@). Each maintains its own log-wealth and bettor state. The +-- test rejects when either side's wealth crosses @2 \/ alpha@; the +-- factor of 2 is the Bonferroni adjustment for the two-sided union. +-- +-- The test is /anytime-valid/: under @H_0@ the wealth process is a +-- nonnegative supermartingale, so by Ville's inequality the +-- probability of ever crossing the threshold is at most @alpha@, +-- regardless of when the user decides to stop streaming samples. module Statistics.EProcess.Mean ( -- * Test configuration and state @@ -44,6 +52,12 @@ import Statistics.EProcess.Bettor -- types ---------------------------------------------------------------------- -- | Test outcome at the current sample count. +-- +-- 'Reject' means the wealth process has crossed the Bonferroni +-- threshold, so @H_0@ is rejected at level @alpha@. 'Continue' +-- means there is not yet enough evidence; collect more samples (or +-- stop and report no rejection -- the type-I error guarantee holds +-- for /any/ stopping rule). data Verdict = Reject | Continue @@ -55,31 +69,43 @@ data Verdict = data BetState = SFixed | SAgrapa - {-# UNPACK #-} !Double -- sum of z - {-# UNPACK #-} !Double -- sum of z^2 + {-# UNPACK #-} !Double -- sum of z (centred observation) + {-# UNPACK #-} !Double -- sum of z^2 (for online variance) {-# UNPACK #-} !Int -- count | SOns - {-# UNPACK #-} !Double -- lambda - {-# UNPACK #-} !Double -- acc (sum of squared gradients) + {-# UNPACK #-} !Double -- current bet lambda + {-# UNPACK #-} !Double -- running sum of per-step squared gradients -- | 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). data Config = Config { - cfg_bettor :: !Bettor - , cfg_lam_max_pos :: {-# UNPACK #-} !Double - , cfg_lam_max_neg :: {-# UNPACK #-} !Double - , cfg_null_mean :: {-# UNPACK #-} !Double - , cfg_alpha :: {-# UNPACK #-} !Double - , cfg_log_thresh :: {-# UNPACK #-} !Double + cfg_bettor :: !Bettor -- ^ bettor strategy + , cfg_lam_max_pos :: {-# UNPACK #-} !Double -- ^ pos-direction safe-bet ceiling + , cfg_lam_max_neg :: {-# UNPACK #-} !Double -- ^ neg-direction safe-bet ceiling + , cfg_null_mean :: {-# UNPACK #-} !Double -- ^ null mean @m@ + , cfg_alpha :: {-# UNPACK #-} !Double -- ^ significance level @alpha@ + , cfg_log_thresh :: {-# UNPACK #-} !Double -- ^ rejection threshold @log(2 \/ alpha)@ } -- | Streaming test state. Construct with 'initial' and fold -- observations through 'update'. +-- +-- The two log-wealth fields track the running log-wealth of the +-- positive- and negative-direction e-processes separately; +-- 'decide' compares each to the threshold and 'log_wealth' returns +-- the larger of the two. The per-direction bettor states carry +-- whatever the chosen 'Bettor' needs (running sums, current bet, +-- etc.). data State = State { - st_n :: {-# UNPACK #-} !Int - , st_log_w_pos :: {-# UNPACK #-} !Double - , st_log_w_neg :: {-# UNPACK #-} !Double - , st_bet_pos :: !BetState - , st_bet_neg :: !BetState + st_n :: {-# UNPACK #-} !Int -- ^ sample count + , st_log_w_pos :: {-# UNPACK #-} !Double -- ^ log-wealth, pos-direction process + , st_log_w_neg :: {-# UNPACK #-} !Double -- ^ log-wealth, neg-direction process + , st_bet_pos :: !BetState -- ^ bettor state, pos-direction + , st_bet_neg :: !BetState -- ^ bettor state, neg-direction } -- internal ------------------------------------------------------------------- @@ -99,11 +125,14 @@ init_bet :: Bettor -> BetState init_bet b = case b of Fixed _ -> SFixed Agrapa -> SAgrapa 0 0 0 - Ons -> SOns 0 1.0e-6 + Ons -> SOns 0 1.0e-6 -- small acc seed avoids div-by-zero on first step {-# INLINE init_bet #-} -- compute the next bet 'lambda' from the bettor and its current --- state; 'lam_max' is the direction-specific safety bound. +-- 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'). bet_lambda :: Bettor -> Double -> BetState -> Double bet_lambda b !lam_max !s = case b of Fixed lam -> lam @@ -124,7 +153,10 @@ bet_lambda b !lam_max !s = case b of _ -> 0 {-# INLINE bet_lambda #-} --- update bettor state with newly observed centred value 'z'. +-- 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)', +-- 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 @@ -146,6 +178,25 @@ step_bet b !lam_max !s !z = case b of -- | Build a 'Config' for the bounded-mean test. -- +-- Each per-direction safe-bet ceiling @lambda_max@ is set so that +-- the wealth factor stays nonnegative for every admissible +-- observation: +-- +-- * The positive-direction factor is @1 + lambda_p * (x - m)@. +-- Since @x@ can dip to @lo@, @x - m@ can reach @lo - m@ (the +-- most negative value), so we need +-- @lambda_p <= 1 \/ (m - lo)@. The ceiling stored is half this +-- to leave numerical margin -- the WSR safety recommendation. +-- +-- * The negative-direction factor is @1 - lambda_n * (x - m)@. +-- Since @x@ can rise to @hi@, @x - m@ can reach @hi - m@, so we +-- need @lambda_n <= 1 \/ (hi - m)@; again the ceiling is set to +-- 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. +-- -- >>> import qualified Statistics.EProcess.Bettor as B -- >>> let cfg = config 0.5 0.0 1.0 1.0e-3 B.Ons config @@ -163,13 +214,14 @@ config !m !lo !hi !alpha !b = Config { , cfg_alpha = alpha , cfg_log_thresh = log (2 / alpha) } --- NB. 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 #-} -- | The initial 'State' for a fresh streaming test. -- +-- Both directional log-wealths 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'. +-- -- >>> let s0 = initial cfg initial :: Config -> State initial Config{..} = @@ -187,6 +239,18 @@ initial Config{..} = -- | Fold one observation into the running 'State'. -- +-- Computes the centred observation @z = x - m@, queries the two +-- directional bettors for their predictable bets, accumulates +-- per-direction log-wealth via +-- +-- @log_w' = log_w + log (1 + lambda * z)@ +-- +-- (with the symmetric @-lambda@ for the negative direction), and +-- then steps the bettor states given the newly observed @z@. The +-- per-step wealth factor is floored at a tiny positive value to +-- keep the log finite when a marginal bet drives the factor to (or +-- below) zero. +-- -- >>> let s1 = update cfg s0 0.7 update :: Config -> State -> Double -> State update Config{..} State{..} !x = @@ -205,7 +269,13 @@ update Config{..} State{..} !x = -- | Compute the current 'Verdict' from the running 'State'. -- -- 'Reject' iff either directional log-wealth has crossed the --- Bonferroni-adjusted threshold @log(2 \/ alpha)@. +-- Bonferroni-adjusted threshold @log(2 \/ alpha)@; equivalently, +-- the wealth process on either side has exceeded @2 \/ alpha@. +-- 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 verdict as often as they like and stop on the first +-- 'Reject'. -- -- >>> decide cfg s0 -- Continue @@ -221,6 +291,10 @@ decide Config{..} State{..} -- | The current log-wealth, taken as the maximum of the two -- directional processes. -- +-- This is the natural \"test statistic\": it is monotone in the +-- evidence against @H_0@ accumulated so far, and the test rejects +-- exactly when it crosses @log(2 \/ alpha)@. +-- -- >>> log_wealth s0 -- 0.0 log_wealth :: State -> Double diff --git a/lib/Statistics/EProcess/TwoSample.hs b/lib/Statistics/EProcess/TwoSample.hs @@ -10,9 +10,19 @@ -- Paired two-sample anytime-valid mean-equality test. -- -- For paired observations @(a_t, b_t)@ where both samples lie in --- @[lo, hi]@, tests @H_0: E[a] = E[b]@ against @H_1: E[a] /= E[b]@ by --- running the bounded-mean test on the differences @d_t = a_t - b_t@ --- with null mean 0. +-- @[lo, hi]@, tests @H_0: E[a] = E[b]@ against +-- @H_1: E[a] /= E[b]@. +-- +-- The reduction is straightforward: under the null, the differences +-- @d_t = a_t - b_t@ have mean zero, and differences of @[lo, hi]@ +-- values lie in @[lo - hi, hi - lo]@. So the paired test is just +-- the bounded-mean test ("Statistics.EProcess.Mean") on @d_t@ with +-- null mean @0@ and sample bounds @[lo - hi, hi - lo]@. +-- +-- Pairing is required: independent two-sample testing without +-- alignment would need to bet against a richer alternative (the +-- joint distribution rather than the marginal difference) and is +-- beyond the scope of this module. module Statistics.EProcess.TwoSample ( -- * Test configuration and state @@ -39,11 +49,13 @@ import Statistics.EProcess.Bettor (Bettor) -- types ---------------------------------------------------------------------- --- | Paired two-sample test configuration. Build with 'config'. +-- | Paired two-sample test configuration. Build with 'config'. Wraps +-- a 'Statistics.EProcess.Mean.Config' for the underlying +-- difference test. newtype Config = Config M.Config -- | Streaming paired two-sample test state. Construct with 'initial' --- and fold observations through 'update'. +-- and fold paired observations through 'update'. newtype State = State M.State -- construction --------------------------------------------------------------- @@ -51,7 +63,9 @@ newtype State = State M.State -- | Build a 'Config' for the paired two-sample test. -- -- Bounds @lo@ and @hi@ are the (shared) bounds on the individual --- samples; differences then lie in @[lo - hi, hi - lo]@. +-- @a@ and @b@ samples; the underlying mean test is then configured +-- on the differences, which lie in @[lo - hi, hi - lo]@ with null +-- mean @0@. -- -- >>> import qualified Statistics.EProcess.Bettor as B -- >>> let cfg = config 0.0 1.0 1.0e-3 B.Ons @@ -77,6 +91,9 @@ initial (Config c) = State (M.initial c) -- | Fold one paired observation @(a, b)@ into the running 'State'. -- +-- Equivalent to feeding the difference @a - b@ into the underlying +-- bounded-mean test. +-- -- >>> let s1 = update cfg s0 (0.3, 0.7) update :: Config -> State -> (Double, Double) -> State update (Config c) (State s) (!a, !b) = @@ -85,6 +102,10 @@ update (Config c) (State s) (!a, !b) = -- | Compute the current 'Verdict' from the running 'State'. -- +-- 'Reject' iff either directional log-wealth of the underlying +-- bounded-mean test on the differences has crossed +-- @log(2 \/ alpha)@. +-- -- >>> decide cfg s0 -- Continue decide :: Config -> State -> Verdict @@ -93,7 +114,8 @@ decide (Config c) (State s) = M.decide c s -- inspection ----------------------------------------------------------------- --- | The current log-wealth. +-- | The current log-wealth of the underlying bounded-mean test on +-- the differences. -- -- >>> log_wealth s0 -- 0.0 @@ -101,7 +123,7 @@ log_wealth :: State -> Double log_wealth (State s) = M.log_wealth s {-# INLINE log_wealth #-} --- | The number of samples consumed so far. +-- | The number of paired observations consumed so far. -- -- >>> samples s0 -- 0