exchangealgebra-0.5.0.0: src/ExchangeAlgebra/Optimize/Annealing.hs
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE BangPatterns #-}
{- |
Module : ExchangeAlgebra.Optimize.Annealing
Copyright : (c) Kaya Akagi. 2026
License : OtherLicense
Simulated annealing over an arbitrary candidate type.
The candidate type is free (@a@ in @t'Annealing' a@): investment
schedules, parameter records, vectors, ... — anything the
'acNeighbor' move can perturb. The objective may be stateful
(e.g. run an @ST s@ simulation); see "ExchangeAlgebra.Optimize"
for the interface contract.
== Example
Minimize @(x - 3)^2@ over 'Double':
> import ExchangeAlgebra.Optimize
> import ExchangeAlgebra.Optimize.Annealing
> import System.Random (uniformR)
> import Data.Functor.Identity (runIdentity)
>
> cfg :: AnnealingConfig Double
> cfg = AnnealingConfig
> { acDirection = Minimize
> , acSteps = 2000
> , acSchedule = geometricCooling 1.0 0.995
> , acNeighbor = \g x -> let (d, g') = uniformR (-0.5, 0.5) g
> in (x + d, g')
> , acAccept = metropolis
> , acSeed = 42
> }
>
> best :: (Double, Double)
> best = runIdentity (optimize Annealing cfg (\x -> pure ((x - 3)^2)) 0)
-}
module ExchangeAlgebra.Optimize.Annealing
( -- * Strategy
Annealing (..)
, AnnealingConfig (..)
-- * Standard building blocks
, geometricCooling
, metropolis
) where
import ExchangeAlgebra.Optimize
import System.Random (StdGen, mkStdGen, uniformR)
-- | Simulated-annealing strategy proxy. The phantom @a@ is the candidate
-- type; it is inferred from the t'AnnealingConfig' at the call site, so
-- plain @'optimize' Annealing cfg obj x0@ needs no annotation.
data Annealing a = Annealing
-- | Configuration of one annealing run.
--
-- Energies passed to 'acAccept' are /minimization-oriented/ ('orient'
-- applied), regardless of 'acDirection'.
data AnnealingConfig a = AnnealingConfig
{ acDirection :: Direction
-- ^ Whether the objective is maximized or minimized.
, acSteps :: Int
-- ^ Total number of proposal steps (@>= 0@; @0@ evaluates the
-- initial candidate once and returns it).
, acSchedule :: Int -> Double
-- ^ Cooling schedule: 1-based step number to temperature.
-- Each temperature must be finite and @>= 0@ (@0@ acts as greedy
-- acceptance); anything else is rejected with 'error'.
, acNeighbor :: StdGen -> a -> (a, StdGen)
-- ^ Proposal move: perturb the current candidate, threading the
-- solver's random generator.
, acAccept :: Double -> Double -> Double -> Double
-- ^ Acceptance rule: @temperature -> current energy -> proposed
-- energy -> probability@. The probability must be finite and in
-- @[0, 1]@ (validated at every step). See 'metropolis'.
, acSeed :: Int
-- ^ Seed of the solver's internal random generator.
}
-- | Geometric cooling: @geometricCooling t0 alpha step = t0 * alpha^(step-1)@.
--
-- Typical use: @'geometricCooling' 1.0 0.995@.
geometricCooling :: Double -- ^ initial temperature @t0@
-> Double -- ^ decay factor @alpha@ (usually just below 1)
-> Int -- ^ 1-based step
-> Double
geometricCooling t0 alpha step = t0 * alpha ^^ (step - 1)
-- | The Metropolis acceptance rule: always accept an improvement,
-- otherwise accept with probability @exp ((e - e') / t)@.
-- At @t = 0@ this degenerates to greedy acceptance.
metropolis :: Double -- ^ temperature
-> Double -- ^ current energy
-> Double -- ^ proposed energy
-> Double
metropolis t e e'
| e' <= e = 1
| t <= 0 = 0
| otherwise = exp ((e - e') / t)
instance Solver (Annealing a) where
type Candidate (Annealing a) = a
type Config (Annealing a) = AnnealingConfig a
optimize _ cfg obj x0
| acSteps cfg < 0
= error ("ExchangeAlgebra.Optimize.Annealing: acSteps must be >= 0, got "
++ show (acSteps cfg))
| otherwise
= do e0 <- energyOf dir "initial candidate" (obj x0)
go 1 (mkStdGen (acSeed cfg)) x0 e0 x0 e0
where
dir = acDirection cfg
go !step !g !cur !eCur !best !eBest
| step > acSteps cfg = return (best, orient dir eBest)
-- orient is self-inverse: energy back to the user's score.
| otherwise = do
let t = checkTemperature step (acSchedule cfg step)
(cand, g1) = acNeighbor cfg g cur
eCand <- energyOf dir ("step " ++ show step) (obj cand)
let p = checkProbability step (acAccept cfg t eCur eCand)
(u, g2) = uniformR (0 :: Double, 1) g1
accepted = p >= 1 || u < p
(cur', eCur') | accepted = (cand, eCand)
| otherwise = (cur, eCur)
(best', eBest') | eCand < eBest = (cand, eCand)
| otherwise = (best, eBest)
go (step + 1) g2 cur' eCur' best' eBest'
-- | Evaluate the objective and convert to minimization energy, rejecting
-- non-finite scores (see the contract in "ExchangeAlgebra.Optimize").
energyOf :: Monad m => Direction -> String -> m Double -> m Double
energyOf dir what mScore = do
s <- mScore
if isNaN s || isInfinite s
then error ("ExchangeAlgebra.Optimize.Annealing: objective returned "
++ "a non-finite score (" ++ show s ++ ") at " ++ what)
else return (orient dir s)
checkTemperature :: Int -> Double -> Double
checkTemperature step t
| isNaN t || isInfinite t || t < 0
= error ("ExchangeAlgebra.Optimize.Annealing: acSchedule returned an "
++ "invalid temperature (" ++ show t ++ ") at step " ++ show step)
| otherwise = t
checkProbability :: Int -> Double -> Double
checkProbability step p
| isNaN p || isInfinite p || p < 0 || p > 1
= error ("ExchangeAlgebra.Optimize.Annealing: acAccept returned an "
++ "invalid probability (" ++ show p ++ ") at step " ++ show step)
| otherwise = p