hanalyze-0.2.0.0: src/Hanalyze/Optim/DifferentialEvolution.hs
-- |
-- Module : Hanalyze.Optim.DifferentialEvolution
-- Description : Differential Evolution (DE/rand/1/bin) — Storn & Price 1997
-- Copyright : (c) 2026 Aelysce Project (Toshiaki Honda)
-- License : BSD-3-Clause
--
-- Differential Evolution (DE/rand/1/bin) — Storn & Price 1997.
--
-- A gradient-free, global, simple-to-implement and empirically robust
-- evolutionary algorithm. Best suited to continuous non-convex problems,
-- typically effective in the 5-30 dimensional regime.
--
-- Algorithm (DE/rand/1/bin) — each generation, for every individual @i@:
--
-- 1. Pick three distinct indices @a, b, c@ from the population (all
-- different from @i@).
-- 2. Mutation: @v = a + F * (b - c)@ with mutation factor @F ∈ [0.5, 0.8]@
-- typical.
-- 3. Binomial crossover: @u_j = v_j@ with probability @CR ∈ [0.7, 0.9]@,
-- otherwise @x_j@; at least one dimension is forced from @v@.
-- 4. Selection: replace @x_i ← u@ if @f(u) ≤ f(x_i)@.
--
-- Cost: @N@ function evaluations per generation (population size). Easily
-- parallelizable, but this implementation is sequential.
{-# LANGUAGE StrictData #-}
module Hanalyze.Optim.DifferentialEvolution
( DEConfig (..)
, DEStrategy (..)
, defaultDEConfig
, runDE
, runDEWith
) where
import Data.List (minimumBy)
import Data.Ord (comparing)
import qualified System.Random.MWC as MWC
import qualified System.Random.MWC.Distributions as MWCD
import Control.Monad (forM, forM_)
import Data.IORef
import Control.Exception (SomeException, try, evaluate)
import Hanalyze.Optim.Common
import qualified Hanalyze.Optim.LBFGS as LB
-- | DE strategy.
--
-- * 'ClassicRand1Bin' — DE/rand/1/bin with fixed @F@ / @CR@ from
-- 'deF' / 'deCR' (the original Storn-Price 1997 formulation).
-- * 'JDE' — self-adaptive DE (Brest et al. 2006). Each individual
-- carries its own @F_i@ and @CR_i@; before each trial each is
-- re-sampled with probability @τ@ (defaults @τ_F = τ_CR = 0.1@):
--
-- @F_i ← F_l + r₁ · (F_u − F_l)@ (r₁ ~ U(0, 1))
-- @CR_i ← r₂@ (r₂ ~ U(0, 1))
--
-- where @F_l, F_u = 0.1, 0.9@. The new @(F_i, CR_i)@ are kept iff
-- the trial is accepted. Removes the manual @F@/@CR@ tuning that
-- classic DE is sensitive to.
data DEStrategy
= ClassicRand1Bin
| JDE
deriving (Show, Eq)
-- | DE configuration.
--
-- @F@ (mutation factor) and @CR@ (crossover rate) defaults are typical
-- values. The population size should be roughly @5×D@ to @10×D@.
data DEConfig = DEConfig
{ deStop :: !StopCriteria
, dePopSize :: !Int -- ^ Population size @N@ (5×D – 10×D typical).
, deF :: !Double -- ^ Mutation factor @F@ (initial value when 'JDE').
, deCR :: !Double -- ^ Crossover probability @CR@ (initial value when 'JDE').
, deBounds :: !Bounds -- ^ Per-dimension @(lo, hi)@; used for both
-- initialization and boundary reflection.
, deStrategy :: !DEStrategy -- ^ Trial-generation strategy.
, deDir :: !Direction
, dePolish :: !Bool
-- ^ When 'True' (default), run a final L-BFGS-B (numeric gradient)
-- refinement on @x_best@ at termination. Mirrors scipy's
-- @differential_evolution(polish=True)@. Brings smooth landscapes
-- (Sphere, Levy etc.) to near-machine precision after DE has
-- localised the basin.
} deriving (Show, Eq)
-- | Default configuration: 200 iterations, population @max(20, 10×D)@,
-- @F = 0.5@, @CR = 0.9@, **'JDE' self-adaptive** strategy, minimization.
--
-- 'JDE' is the recommended default because the classic @F = 0.7@ /
-- @CR = 0.9@ is brittle on diverse problem types (Sphere, Rastrigin
-- and Rosenbrock all want different settings). Switch to
-- 'ClassicRand1Bin' to recover the previous behaviour.
defaultDEConfig :: [(Double, Double)] -> DEConfig
defaultDEConfig bs = DEConfig
{ deStop = defaultStopCriteria { stMaxIter = 200 }
, dePopSize = max 20 (10 * length bs)
, deF = 0.5
, deCR = 0.9
, deBounds = bs
, deStrategy = JDE
, deDir = Minimize
, dePolish = True
}
-- | Run DE with the default configuration built from @bounds@.
runDE :: [(Double, Double)] -- ^ Per-dimension bounds.
-> ([Double] -> Double) -- ^ Objective.
-> MWC.GenIO
-> IO OptimResult
runDE bounds f gen = runDEWith (defaultDEConfig bounds) f gen
-- | Run DE with a user-supplied configuration.
runDEWith :: DEConfig
-> ([Double] -> Double)
-> MWC.GenIO
-> IO OptimResult
runDEWith cfg fUser gen = do
let f = flipFor (deDir cfg) fUser
n = dePopSize cfg
-- 初期集団: 各次元 (lo, hi) 一様乱数。
-- 各個体に (F_i, CR_i) を持たせる (Classic では未使用、jDE では更新)。
pop0 <- forM [1 .. n] $ \_ -> sampleUniformIn (deBounds cfg) gen
let fPop0 = map f pop0
pop0' = [ (x, fx, deF cfg, deCR cfg) | (x, fx) <- zip pop0 fPop0 ]
popRef <- newIORef pop0'
histRef <- newIORef [minimum fPop0]
iterRef <- newIORef 0
convRef <- newIORef False
let stop = deStop cfg
maxI = stMaxIter stop
let loop = do
i <- readIORef iterRef
if i >= maxI
then return ()
else do
pop <- readIORef popRef
let fs = map (\(_, ff, _, _) -> ff) pop
bestF = minimum fs
worstF = maximum fs
if abs (worstF - bestF) < stTolFun stop
then writeIORef convRef True
else do
pop' <- stepDE cfg f gen pop
writeIORef popRef pop'
let bestF' = minimum (map (\(_, ff, _, _) -> ff) pop')
modifyIORef histRef (bestF' :)
writeIORef iterRef (i + 1)
loop
loop
popFinal <- readIORef popRef
iters <- readIORef iterRef
conv <- readIORef convRef
histR <- readIORef histRef
let (xb, vb, _, _) = minimumBy (comparing (\(_, ff, _, _) -> ff)) popFinal
-- Optional final L-BFGS-B polish on x_best (scipy parity).
-- Numeric gradient because the user's f is opaque. Bounds stay
-- within deBounds. If polish improves, replace; otherwise keep.
(xPol, vPol) <-
if dePolish cfg
then do
let polCfg = LB.defaultLBFGSConfig
{ LB.lbStop = defaultStopCriteria
{ stMaxIter = 100
, stTolFun = 1e-12
, stTolX = 1e-12 }
, LB.lbBounds = Just (deBounds cfg)
}
-- Polish can fail (numeric grad → linearSolveSVDR etc. for
-- objectives that internally invert near-singular matrices).
-- Catch any exception and fall back to the unpolished best.
eR <- try (LB.runLBFGSNumeric polCfg f xb) :: IO (Either SomeException OptimResult)
case eR of
Left _ -> pure (xb, vb)
Right r ->
let xR = clipToBounds (deBounds cfg) (orBest r)
in do
evR <- try (evaluate (f xR)) :: IO (Either SomeException Double)
case evR of
Right vR | vR < vb -> pure (xR, vR)
_ -> pure (xb, vb)
else pure (xb, vb)
let vUser = case deDir cfg of { Minimize -> vPol; Maximize -> negate vPol }
histUser = case deDir cfg of
Minimize -> reverse histR
Maximize -> map negate (reverse histR)
return $ OptimResult xPol vUser histUser iters conv
-- | jDE re-sampling probabilities (Brest 2006 standard values).
jdeTau :: Double
jdeTau = 0.1
jdeFLo, jdeFHi :: Double
jdeFLo = 0.1
jdeFHi = 0.9
-- | 1 世代の更新。'DEStrategy' によって @F_i@/@CR_i@ の扱いが分かれる:
--
-- * 'ClassicRand1Bin': @F_i = deF cfg@, @CR_i = deCR cfg@ (固定)。
-- * 'JDE' : 各 trial 前に確率 'jdeTau' で再サンプリング、
-- trial が採用された場合のみ新値を保持。
stepDE :: DEConfig
-> ([Double] -> Double)
-> MWC.GenIO
-> [([Double], Double, Double, Double)]
-> IO [([Double], Double, Double, Double)]
stepDE cfg f gen pop = do
let n = length pop
d = length (deBounds cfg)
bs = deBounds cfg
newPop <- forM [0 .. n - 1] $ \i -> do
let (xi, fi, fOld, crOld) = pop !! i
-- jDE: confirm or refresh F_i / CR_i for this trial
(fTrial, crTrial) <- case deStrategy cfg of
ClassicRand1Bin -> return (deF cfg, deCR cfg)
JDE -> do
u1 <- MWC.uniformR (0, 1) gen :: IO Double
u2 <- MWC.uniformR (0, 1) gen :: IO Double
u3 <- MWC.uniformR (0, 1) gen :: IO Double
u4 <- MWC.uniformR (0, 1) gen :: IO Double
let f' = if u1 < jdeTau then jdeFLo + u2 * (jdeFHi - jdeFLo) else fOld
cr' = if u3 < jdeTau then u4 else crOld
return (f', cr')
-- mutation 用に i と異なる 3 個体をランダム選択
[a, b, c] <- pickThree n i gen
let xa = let (x, _, _, _) = pop !! a in x
xb' = let (x, _, _, _) = pop !! b in x
xc' = let (x, _, _, _) = pop !! c in x
v = zipWith3 (\xai xbi xci -> xai + fTrial * (xbi - xci)) xa xb' xc'
v' = clipToBounds bs v
-- crossover (binomial)
jRand <- MWC.uniformR (0, d - 1) gen
u <- forM (zip3 [0..] xi v') $ \(j, xj, vj) -> do
r <- MWC.uniformR (0, 1) gen
return $ if (r :: Double) < crTrial || j == jRand then vj else xj
let fu = f u
if fu <= fi
then return (u, fu, fTrial, crTrial)
else return (xi, fi, fOld, crOld)
return newPop
-- | i と異なる 3 つの相異なるインデックスを集団 [0, n) から選ぶ。
pickThree :: Int -> Int -> MWC.GenIO -> IO [Int]
pickThree n i gen = do
let pickOne avoid = do
k <- MWC.uniformR (0, n - 1) gen
if k `elem` avoid then pickOne avoid else return k
a <- pickOne [i]
b <- pickOne [i, a]
c <- pickOne [i, a, b]
return [a, b, c]
-- | (`sampleUniform` and `clipBound` are now provided by `Hanalyze.Optim.Common`
-- as `sampleUniformIn` / `clipToBounds`.)