packages feed

hanalyze-0.1.0.0: src/Hanalyze/Stat/VI.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
-- | Variational inference (ADVI — Automatic Differentiation Variational
-- Inference).
--
-- Implements the mean-field normal VI of Kucukelbir et al. (2017). Uses
-- the same unconstrained transform as HMC/NUTS and maximizes the ELBO
-- with Adam.
--
-- Approximating family: @q(u; φ) = Π_i Normal(u_i; μ_i, σ_i)@
--
-- @
-- ELBO = E_q[log p(θ,y) + log|J|] + Σ_i H[Normal(μ_i, σ_i)]
--      = E_q[logJointU(u)] + Σ_i ω_i + N/2 × (1 + log 2π)
-- @
--
-- Gradient (reparameterization trick):
--
-- @
-- u^s = μ + σ ⊙ ε^s,  ε^s ~ N(0, I)
-- ∂ELBO/∂μ_i ≈ (1/S) Σ_s ∂logJointU/∂u_i |_{u^s}
-- ∂ELBO/∂ω_i ≈ (1/S) Σ_s ε_i^s × σ_i × ∂logJointU/∂u_i |_{u^s} + 1
-- @
--
-- @
-- let cfg = defaultVIConfig { viIterations = 1000 }
-- result <- advi model cfg initParams gen
-- print (viPostMeans result)
-- @
module Hanalyze.Stat.VI
  ( VIConfig (..)
  , defaultVIConfig
  , VIResult (..)
  , advi
  ) where

import Control.DeepSeq (force)
import Control.Monad (forM, forM_, replicateM)
import Data.IORef
import qualified Data.Map.Strict as Map
import System.Random.MWC (GenIO)
import System.Random.MWC.Distributions (standard)

import Hanalyze.Model.HBM (ModelP, Params, sampleNames, getTransforms)
import Hanalyze.Optim.Adam (adamStep)
import Hanalyze.MCMC.HMC  ( logJointU, paramsToVec, vecToParams
                 , toUnconstrainedParams, fromUnconstrainedParams )

-- ---------------------------------------------------------------------------
-- 設定
-- ---------------------------------------------------------------------------

-- | ADVI configuration.
data VIConfig = VIConfig
  { viIterations   :: Int     -- ^ Number of Adam iterations.
  , viSamples      :: Int     -- ^ Monte Carlo samples per ELBO gradient (5–10 typical).
  , viLearningRate :: Double  -- ^ Adam learning rate @α@.
  , viBeta1        :: Double  -- ^ Adam @β₁@ (default 0.9).
  , viBeta2        :: Double  -- ^ Adam @β₂@ (default 0.999).
  , viEpsilon      :: Double  -- ^ Adam @ε@ (default 1e-8).
  , viNumDraws     :: Int     -- ^ Number of post-fit draws from @q@.
  , viGradStep     :: Double  -- ^ Finite-difference step for numeric gradients.
  } deriving (Show)

-- | Sensible defaults for ADVI: 1000 iterations, 5 MC samples, Adam at
-- @α = 0.1@.
defaultVIConfig :: VIConfig
defaultVIConfig = VIConfig
  { viIterations   = 1000
  , viSamples      = 5
  , viLearningRate = 0.1
  , viBeta1        = 0.9
  , viBeta2        = 0.999
  , viEpsilon      = 1e-8
  , viNumDraws     = 2000
  , viGradStep     = 1e-5
  }

-- ---------------------------------------------------------------------------
-- 結果
-- ---------------------------------------------------------------------------

-- | ADVI result.
data VIResult = VIResult
  { viPostMeans   :: Params    -- ^ Posterior means (constrained space, sample mean).
  , viPostSDs     :: Params    -- ^ Posterior SDs   (constrained space).
  , viMuU         :: [Double]  -- ^ Variational mean @μ@ (unconstrained).
  , viSigmaU      :: [Double]  -- ^ Variational SD   @σ@ (unconstrained).
  , viElboHistory :: [Double]  -- ^ ELBO trajectory (for convergence inspection).
  , viDraws       :: [Params]  -- ^ Posterior draws in the constrained space (length 'viNumDraws').
  } deriving (Show)

-- ---------------------------------------------------------------------------
-- ADVI
-- ---------------------------------------------------------------------------

-- | Run mean-field normal ADVI.
--
-- Optimization happens in unconstrained space; samples are mapped back
-- to the constrained space on the way out. Constrained parameters
-- (e.g. @Exponential → PositiveT@) are transformed automatically.
advi :: ModelP r -> VIConfig -> Params -> GenIO -> IO VIResult
advi model cfg initP gen = do
  let names      = sampleNames model
      transforms = getTransforms model
      n          = length names
      initU      = paramsToVec names (toUnconstrainedParams transforms initP)

      -- unconstrained 空間での log p(θ,y) + log|J| (Jacobian 補正済み)
      logJ :: [Double] -> Double
      logJ uVec = logJointU model transforms (vecToParams names uVec)

      -- 有限差分勾配 ∂logJ/∂u
      h = viGradStep cfg
      numGrad :: [Double] -> [Double]
      numGrad uVec =
        [ let ui  = uVec !! i
              lp  = logJ (replaceAt i (ui + h) uVec)
              lm  = logJ (replaceAt i (ui - h) uVec)
              raw = (lp - lm) / (2 * h)
          in if isNaN raw || isInfinite raw then 0 else raw
        | i <- [0 .. n-1]
        ]

  -- 変分パラメータ: μ (unconstrained 平均), ω = log(σ) (log 標準偏差)
  muRef    <- newIORef initU
  omegaRef <- newIORef (replicate n 0.0)  -- σ = exp(0) = 1 で初期化

  -- Adam の 1次/2次モーメント
  m1MuRef <- newIORef (replicate n 0.0)
  m2MuRef <- newIORef (replicate n 0.0)
  m1OmRef <- newIORef (replicate n 0.0)
  m2OmRef <- newIORef (replicate n 0.0)

  elboRef <- newIORef []

  let b1    = viBeta1        cfg
      b2    = viBeta2        cfg
      eps_  = viEpsilon      cfg
      alpha = viLearningRate cfg
      sNum  = viSamples      cfg

  -- Adam ループ
  forM_ [1 .. viIterations cfg] $ \t -> do
    mu    <- readIORef muRef
    omega <- readIORef omegaRef
    let sigma = map exp omega

    -- MC 勾配推定
    mcResults <- forM [1 .. sNum] $ \_ -> do
      epsilons <- replicateM n (standard gen)
      let -- u^s = μ + σ ⊙ ε  (reparameterization)
          uVec = zipWith3 (\m s e -> m + s * e) mu sigma epsilons
          lj   = logJ uVec
          g    = numGrad uVec
          -- ∂ELBO/∂μ_i = ∂logJ/∂u_i
          dMu  = g
          -- ∂ELBO/∂ω_i = ε_i × σ_i × ∂logJ/∂u_i + 1  (+1 はエントロピー項)
          dOm  = zipWith3 (\e s gi -> e * s * gi + 1) epsilons sigma g
      return (lj, dMu, dOm)

    let sD    = fromIntegral sNum :: Double
        !ljMC = sum (map (\(l,_,_) -> l) mcResults) / sD
        -- ELBO = E[logJointU] + Σω + N/2×(1+log2π)
        !elboV = ljMC + sum omega + fromIntegral n * 0.5 * (1 + log (2*pi))
        !gMu   = force (map (/ sD) $ foldr1 (zipWith (+)) (map (\(_,g,_) -> g) mcResults))
        !gOm   = force (map (/ sD) $ foldr1 (zipWith (+)) (map (\(_,_,g) -> g) mcResults))

    modifyIORef' elboRef (elboV :)

    -- Adam で μ を更新
    m1Mu <- readIORef m1MuRef
    m2Mu <- readIORef m2MuRef
    let (m1Mu', m2Mu', dxMu) = adamStep b1 b2 eps_ alpha t m1Mu m2Mu gMu
    -- Phase Q3 (2026-05-14): 'zipWith (+)' / Adam の各リストは lazy で、
    -- IORef に書き戻すとそのまま thunk のまま積まれ、次イテレーションで
    -- 読み出されると `zipWith (+) thunk_{t-1} ...` が再帰的に重なる。
    -- iter=10000 K=20 で max residency 85 MB / 総 alloc 222 GB を観測。
    -- 'force' で spine + 各要素を NF にし、t 階層の thunk チェーンを断つ。
    writeIORef m1MuRef (force m1Mu')
    writeIORef m2MuRef (force m2Mu')
    writeIORef muRef   (force (zipWith (+) mu dxMu))

    -- Adam で ω を更新
    m1Om <- readIORef m1OmRef
    m2Om <- readIORef m2OmRef
    let (m1Om', m2Om', dxOm) = adamStep b1 b2 eps_ alpha t m1Om m2Om gOm
    writeIORef m1OmRef (force m1Om')
    writeIORef m2OmRef (force m2Om')
    writeIORef omegaRef (force (zipWith (+) omega dxOm))

  -- 収束後: q(u; φ*) からサンプリングして constrained 空間に変換
  muFinal    <- readIORef muRef
  omegaFinal <- readIORef omegaRef
  let sigmaFinal = map exp omegaFinal

  draws <- forM [1 .. viNumDraws cfg] $ \_ -> do
    epsilons <- replicateM n (standard gen)
    let uVec = zipWith3 (\m s e -> m + s * e) muFinal sigmaFinal epsilons
    return (fromUnconstrainedParams transforms (vecToParams names uVec))

  -- サンプルから事後平均・SD を計算
  let nD        = fromIntegral (viNumDraws cfg) :: Double
      getVals p = map (Map.findWithDefault 0 p) draws
      muP     p = let vs = getVals p in sum vs / nD
      sdP     p = let vs = getVals p
                      mu = muP p
                  in sqrt (sum (map (\v -> (v - mu) ^ (2::Int)) vs) / nD)
      postMeans = Map.fromList [(nm, muP nm) | nm <- names]
      postSDs   = Map.fromList [(nm, sdP nm) | nm <- names]

  elboHistory <- fmap reverse (readIORef elboRef)

  return VIResult
    { viPostMeans   = postMeans
    , viPostSDs     = postSDs
    , viMuU         = muFinal
    , viSigmaU      = sigmaFinal
    , viElboHistory = elboHistory
    , viDraws       = draws
    }

-- ---------------------------------------------------------------------------
-- 補助関数
-- ---------------------------------------------------------------------------

-- adamStep は Hanalyze.Optim.Adam に集約 (Phase R0)。
-- 再 export することで既存の利用箇所はそのまま動く。

-- | リストの i 番目要素を x で置換する。
replaceAt :: Int -> Double -> [Double] -> [Double]
replaceAt i x xs = take i xs ++ [x] ++ drop (i + 1) xs