packages feed

hanalyze-0.1.0.0: bench/haskell/BenchMCMCDiag.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# OPTIONS_GHC -fno-full-laziness -fno-cse #-}
-- | Diagnostic runner for the B7 MCMC bench (Phase B10b investigation).
--
-- Runs hanalyze NUTS on the 8-schools model with progressively
-- different configurations to localise the cause of poor ESS:
--
--   * default          : nutsStepSize=0.08, adapt=on (current)
--   * smaller-step     : nutsStepSize=0.02, adapt=off
--   * longer-warmup    : burnin 2000 (more dual-averaging)
--   * deeper-tree      : nutsMaxDepth=12 (default 10)
--
-- For each, prints accept rate, ESS(mu), ESS(tau), n samples > 1
-- distinct.
module Main where

import qualified Data.Map.Strict      as Map
import qualified Data.Text            as T
import qualified System.Random.MWC    as MWC
import qualified Data.Time.Clock      as Time
import           System.IO            (hSetBuffering, stdout, BufferMode (..))
import           Text.Printf          (printf)

import           Hanalyze.Model.HBM            (Distribution (..), ModelP, sample,
                                       observe)
import           Hanalyze.MCMC.Core            (Chain, chainAccepted, chainTotal,
                                       chainVals, posteriorMean,
                                       posteriorSD)
import           Hanalyze.MCMC.NUTS            (NUTSConfig (..), defaultNUTSConfig,
                                       nuts)
import           Hanalyze.Stat.MCMC            (ess)

schoolData :: [[Double]]
schoolData =
  [ [72, 68, 75, 71]
  , [85, 88, 82, 90]
  , [61, 65, 58, 63]
  ]

sigmaY :: Double
sigmaY = 5.0

schoolModel :: ModelP ()
schoolModel = do
  mu  <- sample "mu"  (Normal 0 100)
  tau <- sample "tau" (Exponential 0.1)
  mapM_ (\(j, ys) -> do
    theta <- sample (T.pack ("theta_" ++ show (j :: Int)))
                    (Normal mu tau)
    observe (T.pack ("y_" ++ show j))
            (Normal theta (realToFrac sigmaY)) ys)
    (zip [1 ..] schoolData)

initParams :: Map.Map T.Text Double
initParams = Map.fromList
  [ ("mu",      73.0)
  , ("tau",     10.0)
  , ("theta_1", 71.5)
  , ("theta_2", 86.25)
  , ("theta_3", 61.75)
  ]

runOne :: String -> NUTSConfig -> IO ()
runOne label cfg = do
  g <- MWC.create
  t0 <- Time.getCurrentTime
  ch <- nuts schoolModel cfg initParams g
  t1 <- Time.getCurrentTime
  let dt = realToFrac (Time.diffUTCTime t1 t0) :: Double
  reportChain label dt ch

reportChain :: String -> Double -> Chain -> IO ()
reportChain label dt ch = do
  let muV    = chainVals "mu"  ch
      tauV   = chainVals "tau" ch
      muEss  = ess muV
      tauEss = ess tauV
      acc    = fromIntegral (chainAccepted ch)
             / max 1 (fromIntegral (chainTotal ch)) :: Double
      muMean = maybe 0 id (posteriorMean "mu" ch)
      muSD   = maybe 0 id (posteriorSD   "mu" ch)
      tauMean = maybe 0 id (posteriorMean "tau" ch)
      muDistinct  = length (uniq muV)
      tauDistinct = length (uniq tauV)
      uniq xs = go xs []
        where go [] acc' = reverse acc'
              go (x:xs') acc'
                | x `elem` acc' = go xs' acc'
                | otherwise     = go xs' (x:acc')
  printf "=== %s ===\n" label
  printf "  time         %.2f s\n" dt
  printf "  accept       %.3f\n" acc
  printf "  mu  mean=%.3f sd=%.3f ess=%.1f distinct=%d\n"
    muMean muSD muEss muDistinct
  printf "  tau mean=%.3f                ess=%.1f distinct=%d\n"
    tauMean tauEss tauDistinct
  putStrLn ""

main :: IO ()
main = do
  hSetBuffering stdout LineBuffering
  putStrLn "================================================="
  putStrLn "  NUTS on 8-schools — diagnostic (B10b)"
  putStrLn "  Goal: explain ESS(mu)=42 vs blackjax ess=810"
  putStrLn "================================================="
  putStrLn ""

  let baseCfg = defaultNUTSConfig
        { nutsIterations = 1000
        , nutsBurnIn     = 500
        , nutsStepSize   = 0.08
        , nutsMaxDepth   = 10
        , nutsAdaptStepSize = True
        , nutsTargetAccept  = 0.8
        }

  -- Reduced iterations for fast diagnostic. ESS scales linearly with
  -- iterations so ratios stay informative.
  let cfg = baseCfg { nutsIterations = 200, nutsBurnIn = 100 }

  -- 1. baseline
  runOne "baseline (eps=0.08, adapt=on)" cfg

  -- 2. small step, no adapt
  runOne "small-step (eps=0.02, adapt=off)"
    cfg { nutsStepSize = 0.02, nutsAdaptStepSize = False }

  -- 3. high target accept (forces smaller eps)
  runOne "high-target (eps=0.08, target=0.95)"
    cfg { nutsTargetAccept = 0.95 }

  -- 4. shallower tree (limit search space)
  runOne "shallow-tree (maxDepth=5)"
    cfg { nutsMaxDepth = 5 }

  -- 5. full-size 1000 samples WITH diagonal mass-matrix adaptation (B11)
  runOne "full-size (1000 samples, mass adapt ON)" baseCfg
    { nutsIterations = 1000, nutsBurnIn = 500, nutsAdaptMass = True }

  -- 6. full-size with mass adapt OFF (baseline for comparison)
  runOne "full-size (1000 samples, mass adapt OFF)" baseCfg
    { nutsIterations = 1000, nutsBurnIn = 500, nutsAdaptMass = False }

  -- 7. mass adapt ON, longer warmup (2000) — does multi-window
  -- adaptation eventually converge given enough budget?
  runOne "long-warmup (1000 samples, warmup=2000, mass ON)" baseCfg
    { nutsIterations = 1000, nutsBurnIn = 2000, nutsAdaptMass = True }