packages feed

hanalyze-0.2.0.0: bench/posteriordb/05-mh/Model.hs

{-# LANGUAGE OverloadedStrings #-}
-- | Mh_data-Mh_model (posteriordb) — hanalyze (ModelP) 実装。
--
-- Phase 89/90: posteriordb 横断ベンチマーク + Phase 90 A3 (vecIR ギャップ
-- 解消: 05-mh = capture-recapture・ZeroInflatedBinomial + 個体ごとの
-- ランダム効果)。
--
-- Stan 原典 (posteriordb `models/stan/Mh_model.stan`。 BPA本 Ch.6・
-- M=385個体・T=5回のサンプリング機会):
--   parameters { real<lower=0,upper=1> omega; real<lower=0,upper=1> mean_p;
--                real<lower=0,upper=5> sigma; vector[M] eps_raw; }
--   transformed parameters { vector[M] eps = logit(mean_p) + sigma*eps_raw; }
--   model {
--     eps_raw ~ normal(0, 1);
--     for (i in 1:M) {
--       if (y[i] > 0)
--         target += bernoulli_lpmf(1|omega) + binomial_logit_lpmf(y[i]|T,eps[i]);
--       else
--         target += log_sum_exp(bernoulli_lpmf(1|omega)+binomial_logit_lpmf(0|T,eps[i]),
--                                bernoulli_lpmf(0|omega));
--     }
--   }
--
-- 05-mh/README.md (Phase 89) で代数的に導出済みのとおり、この尤度は
-- hanalyze の `ZeroInflatedBinomial n ψ p` (ψ=1-omega, p=invlogit(eps)) と
-- 厳密に一致する。 reference_posterior_name = null (2者比較のみ)。
--
-- Phase 90 A3: `SDZIBinom`/`VGZIBinom`/`VOZIBinom` (新設 vecIR family) を
-- 使用。 eps_i (個体ごとのランダム効果) は `plateI` で M 個の latent を
-- 宣言し、 族 gather (`!!`) 経由で観測式に取り込む (`docs/api-guide` の
-- eight-schools 型パターンと同型)。 トイモデルで `synthVecIR` = `Just` を
-- 実測確認済み (個体ごとの logit-link random effect を含む形でも通る)。
module Main (main) where

import Data.Aeson (FromJSON (..), withObject, (.:), eitherDecodeFileStrict)
import qualified Data.Text as T
import qualified Data.Vector as V

import Hanalyze.Model.HBM (ModelP, Distribution (..), sample, observe,
                                    dataNamedObs, plateI, plateForM_, (.#), withData)
import Hanalyze.Model.HBM (gradPathLabel)
import Hanalyze.Plot (hbmModelSpec)
import Hanalyze.Plot (HBMConfig (..), defaultHBM, hbm, (|->),
                              dashboardOf, hbmChainsR)
import Hgg.Plot.Spec (ColData (..))
import Hgg.Plot.Frame (BoundPlot, (|>>))
import Hgg.Plot.Backend.Rasterific (savePNGBound)

import Common (summarize, printSummary, timeSamplingMs)
import Control.Monad (unless)
import Data.List (group, sort)
import System.Environment (getArgs)
import Text.Printf (printf)

import Hanalyze.MCMC.Core (chainTreeDepths)

-- | posteriordb の @Mh_data.json@ 形状 ({"M":385, "T":5, "y":[...]})。
data MhData = MhData { yObs :: [Double], tOccasions :: Int }

instance FromJSON MhData where
  parseJSON = withObject "MhData" $ \v ->
    MhData <$> v .: "y" <*> v .: "T"

noDf :: [(T.Text, ColData)]
noDf = []

dataPath :: FilePath
dataPath = "bench/posteriordb/05-mh/data/Mh_data.json"

figuresDir :: FilePath
figuresDir = "bench/posteriordb/05-mh/figures"

readData :: IO ([Double], Int)
readData = do
  d <- either fail pure =<< eitherDecodeFileStrict dataPath
  pure (yObs d, tOccasions d)

-- | capture-recapture (Mh モデル)。 T (サンプリング機会数) はデータ由来の
-- 固定定数として closure で渡す (ctor 引数化・GLM-Poisson 等と同じ流儀)。
mhModel :: Int -> ModelP ()
mhModel t = do
  -- Stan 原典は omega/mean_p ~ Uniform(0,1) (暗黙一様事前分布) だが、
  -- hanalyze の `Uniform` は制約変換が現状 unconstrained 扱い
  -- (`01-glm-poisson/README.md` に既知の課題として記載済) のため、
  -- vecIR probe (2点評価) で mean_p が (0,1) 域外の値を取り
  -- `log(meanP/(1-meanP))` が NaN 化して誤フォールバックする実害が
  -- M=385 (個体ごとの random effect) という多latentモデルで発覚した。
  -- Beta(1,1) は Uniform(0,1) と数学的に同一の分布で、hanalyze では
  -- 実際に (0,1) へ写す変換を持つため確率的に等価かつ probe 安全。
  omega  <- sample "omega"  (Beta 1 1)
  meanP  <- sample "mean_p" (Beta 1 1)
  sigma  <- sample "sigma"  (Uniform 0 5)
  ys     <- dataNamedObs "y" []
  epsRaws <- plateI "ind" (length ys) $ \i -> sample ("eps_raw" .# i) (Normal 0 1)
  let logitMeanP = log (meanP / (1 - meanP))
  plateForM_ "obs" (zip [0 ..] ys) $ \(i, yi) ->
    let eps = logitMeanP + sigma * (epsRaws !! i)
        p   = 1 / (1 + exp (negate eps))
    in observe "y" (ZeroInflatedBinomial t (1 - omega) p) [yi]

main :: IO ()
main = do
  (ys, t) <- readData
  -- Phase 96 A1: `prof` 引数で図出力 skip (Phase 102 A1 の dugongs/radon と
  -- 同型。Rasterific が cost centre を汚さないため)。サンプリング設定は
  -- 本番のまま据え置く。
  args <- getArgs
  let profRun = elem "prof" args
  let df = [ ("y", NumData (V.fromList ys)) ] :: [(T.Text, ColData)]
      -- PyMC 側 (model.py) と同じ設定を定数で揃える。
      -- Phase 96 A5: M=I 期間 (init buffer + 第1 window) の deep tree 抑制。
      -- 無効時は init 期が 61.9 steps/iter (avg depth≈6・ε 中央値 0.107 vs
      -- 収束後 0.242 の鋸歯) で warmup の 32% を浪費する。Just 4 で warmup
      -- evals −25〜28% (116-121k → 85-91k)・seed 1/2/3 で posterior 統計一致・
      -- ess/s 分散も縮小 (実測 root: experiments/phase96-mh-reconfirm/)。
      cfg = defaultHBM { hbmChains = 4, hbmSamples = 1000
                        , hbmWarmup = 1000, hbmSeed = Just 1
                        , hbmWarmupInitMaxDepth = Just 4 }
      m = df |-> hbm cfg (mhModel t)

  -- 勾配経路 = compileGradUV が実際に選ぶ経路 (束縛済 hbmModelSpec で判定・
  -- Phase 91 A4: 生モデルを synthVecIR に渡すと data 空で誤表示するため差替)。
  putStrLn $ "勾配経路 = " ++ gradPathLabel (hbmModelSpec m)

  (_, samplingMs) <- timeSamplingMs (hbmChainsR m)
  printf "sampling wall = %.1f ms (draws only, no dashboard/startup)\n" samplingMs

  -- Phase 96 A4: draws 区間の軌道長を nutpie の n_steps(draws) と同一区間で
  -- 突合するための tree depth 統計 (leapfrog 数 ≈ 2^depth − 1)。
  let depths = concatMap chainTreeDepths (hbmChainsR m)
      hist   = map (\g -> (head g, length g)) (group (sort depths))
      nLeap  = sum [ (2 :: Int) ^ d - 1 | d <- depths ]
  printf "tree depth (draws): hist=%s  leapfrog~=%d  steps/draw~=%.1f\n"
         (show hist) nLeap
         (fromIntegral nLeap / fromIntegral (max 1 (length depths)) :: Double)

  -- M=385 個体分の eps_raw latent を含むため 'dashboardFullOf' (全 param の
  -- [事後分布|trace] グリッド) は 52MB 超の巨大画像になり非実用的 (実測)。
  -- 健全性 2x2 パネル (DAG/forest/PPC/energy) のみの 'dashboardOf' を使う
  -- (forest には全 latent が出るが 1 行/param のコンパクト表示なので実用的)。
  unless profRun $
    savePNGBound (figuresDir ++ "/hs_dashboard_full.png") $
      (noDf |>> dashboardOf m "y" :: BoundPlot)

  printSummary $ summarize ["omega", "mean_p", "sigma"] (hbmChainsR m)