ppad-eproc-0.2.2: lib/Numeric/Eproc/Common.hs
{-# OPTIONS_HADDOCK prune #-}
{-# LANGUAGE BangPatterns #-}
-- |
-- Module: Numeric.Eproc.Common
-- Copyright: (c) 2026 Jared Tobin
-- License: MIT
-- Maintainer: Jared Tobin <jared@ppad.tech>
--
-- Shared vocabulary for the eproc tests: the predictable bettor
-- strategies, the test verdict type, and the configuration-error
-- type. Re-exported from each test module
-- ("Numeric.Eproc.Bounded", "Numeric.Eproc.Paired",
-- "Numeric.Eproc.Bernoulli"); import this module directly only if
-- you need the types without picking a particular test.
--
-- The 'BetState' type and its helpers are internal to the library:
-- they are exposed here so that 'Numeric.Eproc.Bounded' and
-- 'Numeric.Eproc.Bernoulli' can share one implementation, not for
-- direct use.
module Numeric.Eproc.Common (
Bettor(..)
, Verdict(..)
, ConfigError(..)
-- * Internal: shared bettor state
, BetState(..)
, init_bet
, bet_lambda
, step_bet
-- * Internal: helpers
, finite
, log_sum_exp
, log2_dbl
) where
import GHC.Float (log1p)
-- | A predictable bettor.
--
-- A bettor describes how, given the history of centred
-- observations @z_t@ (each test module specifies its own centring;
-- see the per-module documentation), 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@.
--
-- All three bettors enforce a safe-bet ceiling @lambda_max@
-- derived from the test's admissible-observation range by clipping
-- @lambda@ to @[0, lambda_max]@; this keeps the per-step wealth
-- factor nonnegative.
--
-- * 'Fixed' bets the supplied constant @lambda@ (clipped to
-- @[0, lambda_max]@). The wager does not respond to observed
-- data; this strategy is useful only as a baseline.
--
-- * '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.
--
-- * 'Newton' is the online Newton step (ONS) bettor of
-- Waudby-Smith & Ramdas (2024, Algorithm 2). 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 by a fixed learning
-- rate @2 \/ (2 - log 3)@. 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
| Adaptive
| Newton
deriving (Eq, Show)
-- | Test outcome at the current sample count.
--
-- 'Reject' means the wealth process has /ever/ crossed the
-- rejection threshold, so @H_0@ is rejected at level @alpha@.
-- Once a state has rejected it stays rejected, even if subsequent
-- observations drive the current wealth back below threshold;
-- this is the supremum-style guarantee that Ville's inequality
-- actually delivers. '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
deriving (Eq, Show)
-- | Reasons that a test-configuration smart constructor can reject
-- its inputs. Returned by 'Numeric.Eproc.Bounded.config',
-- 'Numeric.Eproc.Bernoulli.config', and
-- 'Numeric.Eproc.Paired.config'.
data ConfigError =
-- | significance level outside @(0, 1)@
InvalidAlpha {-# UNPACK #-} !Double
-- | sample bounds violate @lo < hi@
| InvalidBounds {-# UNPACK #-} !Double {-# UNPACK #-} !Double
-- | null mean outside @(lo, hi)@ (strict, to avoid div-by-zero
-- in the safe-bet ceilings)
| InvalidNullMean
{-# UNPACK #-} !Double -- m
{-# UNPACK #-} !Double -- lo
{-# UNPACK #-} !Double -- hi
-- | baseline rate outside @(0, 1)@
| InvalidBaselineRate {-# UNPACK #-} !Double
deriving (Eq, Show)
-- | True iff the argument is a finite IEEE-754 double (not NaN, not
-- @+\/-Infinity@). Used by the @config@ smart constructors to keep
-- the bounded-random-variable promise honest.
finite :: Double -> Bool
finite x = not (isNaN x) && not (isInfinite x)
{-# INLINE finite #-}
-- | @log(exp a + exp b)@, computed without intermediate overflow.
-- Used by the convex-hedge two-sided combinations to update the
-- running @log(K^+ + K^-)@ statistic from the two per-direction
-- log-wealths.
log_sum_exp :: Double -> Double -> Double
log_sum_exp !a !b
| a >= b = a + log1p (exp (b - a))
| otherwise = b + log1p (exp (a - b))
{-# INLINE log_sum_exp #-}
-- | @log 2@ as a shared constant. Used both as the initial value of
-- the two-sided running max-log-sum (since @K^+_0 + K^-_0 = 2@) and
-- as the tight upper-bound slack in the fast-path skip inside
-- 'Numeric.Eproc.Bounded.update' /
-- 'Numeric.Eproc.Bernoulli.TwoSided.update'.
log2_dbl :: Double
log2_dbl = log 2
{-# INLINE log2_dbl #-}
-- | Per-bettor state. One constructor per 'Bettor' alternative; the
-- constructor used in any given state matches the 'Bettor' chosen
-- in the enclosing 'Config'.
--
-- Internal: exposed only so that the per-test 'State' types in
-- "Numeric.Eproc.Bounded" and "Numeric.Eproc.Bernoulli" can share
-- one implementation.
data BetState =
SFixed
| SAdaptive
{-# UNPACK #-} !Double -- sum of z (centred observation)
{-# UNPACK #-} !Double -- sum of z^2 (for online variance)
{-# UNPACK #-} !Int -- count
| SNewton
{-# UNPACK #-} !Double -- current bet lambda
{-# UNPACK #-} !Double -- running sum of per-step squared gradients
-- | Per-bettor initial state.
init_bet :: Bettor -> BetState
init_bet b = case b of
Fixed _ -> SFixed
Adaptive -> SAdaptive 0 0 0
Newton -> SNewton 0 1.0e-6 -- small acc seed avoids div-by-zero
{-# INLINE init_bet #-}
-- | WSR (2024) Algorithm 2 ONS learning rate, @2 \/ (2 - log 3)@.
ons_lr :: Double
ons_lr = 2 / (2 - log 3)
{-# INLINE ons_lr #-}
-- | Compute the next bet 'lambda' from the bettor and its current
-- state; 'lam_max' is the direction-specific safety bound. All
-- strategies clip the result to @[0, lam_max]@ so the wealth
-- factor stays nonnegative.
bet_lambda :: Bettor -> Double -> BetState -> Double
bet_lambda b !lam_max !s = case b of
Fixed lam -> max 0 (min lam_max lam)
Adaptive -> case s of
SAdaptive !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 lam_max raw)
_ -> 0
Newton -> case s of
SNewton !lam _ -> lam
_ -> 0
{-# INLINE bet_lambda #-}
-- | Update bettor state with newly observed centred value 'z'. For
-- 'Adaptive' this is just accumulating sums; for 'Newton' we take
-- one online Newton step (with the WSR learning rate) 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
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 - ons_lr * g / acc'
!clp = max 0 (min lam_max lam')
in SNewton clp acc'
_ -> SNewton 0 1.0e-6
{-# INLINE step_bet #-}