packages feed

hanalyze-0.2.0.0: src/Hanalyze/Optim/Adam.hs

-- |
-- Module      : Hanalyze.Optim.Adam
-- Description : Adam 一次勾配法オプティマイザ (Kingma & Ba 2014)
-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
-- License     : BSD-3-Clause
--
-- Adam first-order optimizer (Kingma & Ba 2014).
--
-- A general-purpose gradient-based optimizer used for ELBO maximization,
-- neural-network training, acquisition-function optimization, and similar
-- tasks. Originally embedded in @Hanalyze.Stat.VI@; extracted here as a shared
-- foundation.
--
-- 使い方:
--
-- @
-- let cfg = defaultAdamConfig { adamLearningRate = 0.01, adamIterations = 1000 }
--     gradFn x = ...                            -- 勾配 (上昇方向)
--     (xFinal, history) = runAdam cfg gradFn x0
-- @
--
-- 'adamStep' 単体は 1 ステップだけ進める低レベル API で、`Hanalyze.Stat.VI` などが
-- 内部で利用する。
{-# LANGUAGE OverloadedStrings #-}
module Hanalyze.Optim.Adam
  ( -- * 設定
    AdamConfig (..)
  , defaultAdamConfig
    -- * Single-step update (low-level)
  , adamStep
    -- * High-level loop
  , runAdam
  , runAdamMaximize
  , runAdamMinimize
  ) where

import Control.DeepSeq (force)
import Data.IORef
import Control.Monad (forM_)
import System.IO.Unsafe (unsafePerformIO)

-- | Adam configuration.
data AdamConfig = AdamConfig
  { adamIterations   :: Int     -- ^ Number of iterations.
  , adamLearningRate :: Double  -- ^ Learning rate @α@.
  , adamBeta1        :: Double  -- ^ First-moment decay (default 0.9).
  , adamBeta2        :: Double  -- ^ Second-moment decay (default 0.999).
  , adamEpsilon      :: Double  -- ^ Numerical stabilizer (default 1e-8).
  } deriving (Show)

-- | Default Adam configuration: 1000 iterations, @α = 0.01@,
-- @β₁ = 0.9@, @β₂ = 0.999@, @ε = 1e-8@.
defaultAdamConfig :: AdamConfig
defaultAdamConfig = AdamConfig
  { adamIterations   = 1000
  , adamLearningRate = 0.01
  , adamBeta1        = 0.9
  , adamBeta2        = 0.999
  , adamEpsilon      = 1e-8
  }

-- | Single Adam update.
--
-- Arguments:
--
--   * @β1@, @β2@, @ε@, @α@ — Adam hyperparameters.
--   * @t@ — iteration count (1-based; needed for bias correction).
--   * @m1@, @m2@ — previous first- and second-moment estimates.
--   * @g@ — current gradient.
--
-- Returns @(m1', m2', dx)@: the updated moments and the step direction
-- (in the @+gradient@ direction). Callers do @x ← x + dx@ for ascent or
-- @x ← x − dx@ for descent.
adamStep
  :: Double -> Double -> Double -> Double -> Int
  -> [Double] -> [Double] -> [Double]
  -> ([Double], [Double], [Double])
adamStep b1 b2 eps alpha t m1 m2 g =
  let m1' = zipWith (\m gi -> b1 * m + (1 - b1) * gi)      m1 g
      m2' = zipWith (\v gi -> b2 * v + (1 - b2) * gi * gi)  m2 g
      mH  = map (/ (1 - b1 ^ t)) m1'
      vH  = map (/ (1 - b2 ^ t)) m2'
      dx  = zipWith (\m_ v -> alpha * m_ / (sqrt v + eps))   mH vH
  in (m1', m2', dx)

-- | Gradient-ascent loop. @gradFn@ returns the gradient of the objective.
-- The update @x ← x + Δx@ moves in the @+gradient@ direction, so pass the
-- gradient of the quantity to maximize.
--
-- Returns @(x_final, x_history)@; the per-iteration trajectory is kept
-- for debugging and visualization.
runAdamMaximize :: AdamConfig
                -> ([Double] -> [Double])  -- ^ Gradient function.
                -> [Double]                -- ^ Initial point.
                -> ([Double], [[Double]])
runAdamMaximize cfg gradFn x0 = unsafePerformIO $ do
  let n = length x0
  xRef  <- newIORef x0
  m1Ref <- newIORef (replicate n 0.0)
  m2Ref <- newIORef (replicate n 0.0)
  histRef <- newIORef []
  forM_ [1 .. adamIterations cfg] $ \t -> do
    x  <- readIORef xRef
    m1 <- readIORef m1Ref
    m2 <- readIORef m2Ref
    let g            = gradFn x
        (m1', m2', dx) = adamStep
                          (adamBeta1 cfg) (adamBeta2 cfg) (adamEpsilon cfg)
                          (adamLearningRate cfg) t m1 m2 g
        x'           = zipWith (+) x dx
    -- Phase Q3 (2026-05-14): force lists before storing in IORef. Without
    -- this each iter writes a thunk that reads the previous IORef contents
    -- and chains a fresh @zipWith@ on top — after T iters the chain holds
    -- O(T) closures. See Stat.VI for the same fix and BenchMemVI numbers.
    let !x''  = force x'
        !m1'' = force m1'
        !m2'' = force m2'
    writeIORef xRef x''
    writeIORef m1Ref m1''
    writeIORef m2Ref m2''
    modifyIORef' histRef (x'' :)
  xF   <- readIORef xRef
  hist <- fmap reverse (readIORef histRef)
  return (xF, hist)

-- | Gradient-descent variant: negates @gradFn@ and delegates to
-- 'runAdamMaximize'.
runAdamMinimize :: AdamConfig -> ([Double] -> [Double]) -> [Double]
                -> ([Double], [[Double]])
runAdamMinimize cfg gradFn x0 =
  runAdamMaximize cfg (map negate . gradFn) x0

-- | Alias for 'runAdamMaximize' (the default convention is ascent).
runAdam :: AdamConfig -> ([Double] -> [Double]) -> [Double]
        -> ([Double], [[Double]])
runAdam = runAdamMaximize