packages feed

hbayesian-0.1.0.0: test/Test/CorrelatedGaussian.hs

{-# LANGUAGE DataKinds        #-}
{-# LANGUAGE TypeApplications #-}

module Test.CorrelatedGaussian (tests) where

import           Control.Monad       (forM_)
import           Data.List           (sort, transpose)
import           Test.Tasty
import           Test.Tasty.HUnit

import           CorrelatedGaussianHMC
import           HBayesian.Chain
import           HBayesian.Diagnostics

-----------------------------------------------------------------------------
-- Helpers
-----------------------------------------------------------------------------

-- | Sample mean of a list.
mean :: [Float] -> Float
mean xs = sum xs / fromIntegral (length xs)

-- | Sample variance (unbiased).
variance :: [Float] -> Float
variance xs =
    let m = mean xs
        n = fromIntegral (length xs)
    in if n <= 1 then 0.0
       else sum [ (x - m) ^ 2 | x <- xs ] / (n - 1)

-- | Standard deviation.
stdDev :: [Float] -> Float
stdDev xs = sqrt (variance xs)

-- | Normal CDF approximation (Abramowitz & Stegun, formula 26.2.17).
-- Accurate to ~1e-7.
normalCdf :: Float -> Float
normalCdf x =
    let z = realToFrac x :: Double
        b1 =  0.319381530
        b2 = -0.356563782
        b3 =  1.781477937
        b4 = -1.821255978
        b5 =  1.330274429
        p  =  0.2316419
        c  =  0.39894228
        t  = 1.0 / (1.0 + p * abs z)
        phi = c * exp (-0.5 * z * z)
        poly = t * (b1 + t * (b2 + t * (b3 + t * (b4 + t * b5))))
        y = 1.0 - phi * poly
    in realToFrac $ if z > 0 then y else 1.0 - y

-- | Empirical CDF at point x for sorted samples.
empiricalCdf :: Float -> [Float] -> Float
empiricalCdf x sortedXs =
    let n = fromIntegral (length sortedXs)
        count = fromIntegral (length (takeWhile (<= x) sortedXs))
    in count / n

-- | Kolmogorov-Smirnov statistic against a theoretical CDF.
ksStatistic :: (Float -> Float) -> [Float] -> Float
ksStatistic cdf xs =
    let sorted = sort xs
        n = fromIntegral (length sorted)
        diffs = [ max (abs (empiricalCdf x sorted - cdf x))
                        (abs (empiricalCdfPrev x sorted - cdf x))
                | x <- sorted ]
        empiricalCdfPrev x sortedXs =
            let count = fromIntegral (length (takeWhile (< x) sortedXs))
            in count / n
    in maximum diffs

-- | Critical value for KS test at α = 0.01 for large n.
-- D_crit ≈ 1.628 / sqrt(n)
ksCriticalValue :: Int -> Float
ksCriticalValue n = 1.628 / sqrt (fromIntegral n)

-- | Mahalanobis distance squared: (x - μ)^T Λ (x - μ)
mahalanobisSq :: [Float] -> [Float] -> [[Float]] -> Float
mahalanobisSq x mu lam =
    let diff = zipWith (-) x mu
        -- For tridiagonal Λ, compute efficiently
        n = length diff
    in sum [ diff !! i * lam !! i !! j * diff !! j
           | i <- [0..n-1], j <- [0..n-1] ]

-- | Gelman-Rubin R-hat for a single dimension across multiple chains.
rHatSamples :: [[Float]] -> Float
rHatSamples chains =
    let m = fromIntegral (length chains)          -- number of chains
        n = fromIntegral (length (head chains))     -- iterations per chain
        chainMeans = map mean chains
        chainVars  = map variance chains
        w = mean chainVars                           -- within-chain variance
        b = n * variance chainMeans                  -- between-chain variance
        vHat = ((n - 1) / n) * w + (1 / n) * b       -- pooled variance
    in if w <= 0 then 1.0 else sqrt (vHat / w)

-----------------------------------------------------------------------------
-- Tests
-----------------------------------------------------------------------------

-- | Number of chains for R-hat test.
numChains :: Int
numChains = 4

-- | Perturbation for dispersed initial values.
perturb :: [Float] -> [Float]
perturb = map (+ 0.5)

-- | Run a single chain and return samples.
runOneChain :: IO [Float]
runOneChain = do
    (samples, _diags) <- runChainV2
    return (map head samples)  -- just first dim for some tests

tests :: TestTree
tests = testGroup "CorrelatedGaussian"
    [ testCase "HMC returns correct number of samples" testSampleCount
    , testCase "Marginal means are close to ground truth" testMarginalMeans
    , testCase "Marginal variances are close to 1.0" testMarginalVars
    , testCase "Marginal KS tests pass (dim 0)" testKS0
    , testCase "Marginal KS tests pass (dim 1)" testKS1
    , testCase "Marginal KS tests pass (dim 2)" testKS2
    , testCase "Marginal KS tests pass (dim 3)" testKS3
    , testCase "Marginal KS tests pass (dim 4)" testKS4
    , testCase "Mahalanobis distances have correct mean" testMahalanobisMean
    , testCase "R-hat < 1.1 across 4 chains" testRhat
    ]

-- | Test that we get the expected number of samples.
testSampleCount :: Assertion
testSampleCount = do
    (samples, _diags) <- runChainV2
    length samples @?= 2000

-- | Test that each marginal mean is within 3 SE of the true mean.
-- For AR(1) with uniform variance, marginal SD = 1, so SE = 1/sqrt(2000) ≈ 0.022.
testMarginalMeans :: Assertion
testMarginalMeans = do
    (samples, _diags) <- runChainV2
    let n = fromIntegral (length samples) :: Float
        se = 1.0 / sqrt n   -- marginal SD = 1 for all dims
        thresh = 3.0 * se   -- ~0.067
    forM_ (zip [0..4] targetMean) $ \(i, muTrue) -> do
        let xs = map (!! i) samples
            muHat = mean xs
            err = abs (muHat - muTrue)
        assertBool ("dim " ++ show i ++ " mean off by " ++ show err)
                   (err < thresh)

-- | Test that each marginal variance is close to 1.0.
testMarginalVars :: Assertion
testMarginalVars = do
    (samples, _diags) <- runChainV2
    let thresh = 0.15   -- generous for 2000 samples
    forM_ [0..4] $ \i -> do
        let xs = map (!! i) samples
            varHat = variance xs
            err = abs (varHat - 1.0)
        assertBool ("dim " ++ show i ++ " variance off by " ++ show err)
                   (err < thresh)

-- | Marginal KS tests for each dimension.
-- The marginal of an AR(1) Gaussian is N(mu_i, 1).
testKS :: Int -> Assertion
testKS i = do
    (samples, _diags) <- runChainV2
    let xs = map (!! i) samples
        mu = targetMean !! i
        cdf x = normalCdf ((x - mu) / 1.0)
        stat = ksStatistic cdf xs
        crit = ksCriticalValue (length xs)
    assertBool ("dim " ++ show i ++ " KS stat " ++ show stat ++ " > crit " ++ show crit)
               (stat < crit)

testKS0, testKS1, testKS2, testKS3, testKS4 :: Assertion
testKS0 = testKS 0
testKS1 = testKS 1
testKS2 = testKS 2
testKS3 = testKS 3
testKS4 = testKS 4

-- | Test that mean of Mahalanobis distances ≈ k = 5.
-- E[d²] = k for χ²_k.
testMahalanobisMean :: Assertion
testMahalanobisMean = do
    (samples, _diags) <- runChainV2
    let dists = [ mahalanobisSq s targetMean targetPrecision | s <- samples ]
        meanD = mean dists
        -- For χ²_5, E[d²] = 5, Var[d²] = 10
        -- SE of mean = sqrt(10/n) ≈ 0.071
        se = sqrt (10.0 / fromIntegral (length samples))
        thresh = 3.0 * se   -- ~0.21
        err = abs (meanD - 5.0)
    assertBool ("Mahalanobis mean " ++ show meanD ++ " off by " ++ show err)
               (err < thresh)

-- | Test Gelman-Rubin across 4 chains.
testRhat :: Assertion
testRhat = do
    let config = defaultChainConfig { ccNumIterations = 500 }
        ck = compileHMC (makeKernel (HMCConfig 0.1 10))
                        gaussianLogPdf gaussianGrad
    results <- parallelChains numChains perturb ck
                    (replicate targetDim 0.0) config
    let chains = map fst results
        -- Transpose: get per-dimension chains
        perDimChains = [ [ map (!! i) c | c <- chains ] | i <- [0..4] ]
        rhats = map rHatSamples perDimChains
    forM_ (zip [0..4] rhats) $ \(i, r) -> do
        assertBool ("dim " ++ show i ++ " R-hat = " ++ show r ++ " >= 1.1")
                   (r < 1.1)