packages feed

hanalyze-0.2.0.0: src/Hanalyze/Stat/QuasiRandom.hs

-- |
-- Module      : Hanalyze.Stat.QuasiRandom
-- Description : 低不一致準乱数列 (Halton 列・LHS) — ベイズ最適化の初期設計に利用
-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
-- License     : BSD-3-Clause
--
-- Quasi-random number sequences with low discrepancy.
--
-- These sequences cover a multi-dimensional unit hyper-cube more
-- evenly than independent uniform-random samples and are the
-- recommended way to seed Bayesian-optimization initial designs and
-- multi-start global optimizers.
--
-- The 'haltonSequence' implementation uses the first @d@ prime numbers
-- as bases. For @d ≤ 6@ (Branin, Hartmann6, etc.) it is essentially
-- as good as Sobol; for @d ≥ 10@ correlation between dimensions can
-- become visible and Sobol with scrambling is preferred (not
-- implemented here).
module Hanalyze.Stat.QuasiRandom
  ( haltonPoint
  , haltonSequence
  , haltonSequenceIn
  , haltonMatrix
  , radicalInverse
  , primes
    -- * Latin Hypercube Sampling
  , lhsSamples
  , lhsSamplesIn
  ) where

import           Control.Monad         (forM)
import qualified Data.Vector.Mutable   as MV
import qualified Data.Vector           as V
import qualified Data.Vector.Storable         as VS
import qualified Data.Vector.Storable.Mutable as MVS
import qualified Numeric.LinearAlgebra        as LA
import           System.Random.MWC     (GenIO, uniformR)

-- | Infinite list of prime numbers via a simple Sieve.
primes :: [Int]
primes = sieve [2 ..]
  where
    sieve (p : xs) = p : sieve [x | x <- xs, x `mod` p /= 0]
    sieve []       = []

-- | Radical-inverse function in base @b@. Maps an integer @i@ into
-- @[0, 1)@.
--
-- P41 inner-loop tweaks:
--
--   * @1 / fromIntegral base@ is computed once; subsequent iterations
--     multiply by @invB@ instead of dividing by @base@ each step.
--     Halton at n=10000 d=5 spends ~500K loop iterations here, each
--     previously paying a Double division.
--   * @divMod@ → @quot@ + @r = n - q*base@: avoids the @(q,r)@ tuple
--     pattern-match alloc, replaces a IDIV with an IMUL+SUB on x86.
radicalInverse :: Int -> Int -> Double
radicalInverse base i = go i invB 0
  where
    !invB = 1.0 / fromIntegral base
    go !n !f !acc
      | n == 0    = acc
      | otherwise =
          let !q = n `quot` base
              !r = n - q * base
          in go q (f * invB) (acc + fromIntegral r * f)
{-# INLINE radicalInverse #-}

-- | Single Halton point in @d@ dimensions: applies @radicalInverse@
-- with the first @d@ primes.
haltonPoint :: Int          -- ^ Dimension @d@.
            -> Int          -- ^ Index @i@ (1-based; @i = 0@ would yield the origin).
            -> [Double]
haltonPoint d i = take d [ radicalInverse p i | p <- primes ]

-- | First @n@ Halton points in @d@ dimensions, each in @[0, 1)^d@.
-- Indexed from 1 (skipping @i = 0@, which would be at the origin).
--
-- We tried @runST@ + flat Storable Vector + final list-comp slicing,
-- but the cost is dominated by the @n × d@ cons-cell allocations of
-- the @[[Double]]@ boundary representation, not by the kernel of
-- @radicalInverse@. The flat-vector path benchmarked the same as or
-- slightly slower than the direct list comprehension below — the
-- structural ceiling here is the @[[Double]]@ API. Internal-only
-- callers that want the table as a flat Storable can use a future
-- 'haltonMatrix' (TODO).
haltonSequence :: Int        -- ^ Number of points @n@.
               -> Int        -- ^ Dimension @d@.
               -> [[Double]]
haltonSequence n d =
  let bases = take d primes
  in [ map (\b -> radicalInverse b i) bases | i <- [1 .. n] ]

-- | First @n@ Halton points returned as a flat @n × d@ matrix
-- (row-major: row @i@ = the @i@-th Halton point in @[0, 1)^d@).
--
-- This is the same numerical sequence as 'haltonSequence', but
-- written into a Storable buffer with no @[[Double]]@ boxing — the
-- scipy.stats.qmc.Halton API returns an @ndarray@ of the same shape,
-- and the @[[Double]]@ form was a 2× allocation tax purely from the
-- API boundary (P41).
--
-- Internal-loop optimisations:
--
--   * Bases are loaded into an unboxed @VS.Vector Int@ once.
--   * Per-cell write goes through a hand-rolled ST loop (@outer@/
--     @inner@) so no @forM_ [0..k]@ list cells are allocated.
--   * @radicalInverse@ is the same kernel as before; the saving is
--     entirely in the boundary representation.
haltonMatrix :: Int        -- ^ Number of points @n@.
             -> Int        -- ^ Dimension @d@.
             -> LA.Matrix Double
haltonMatrix n d
  | n <= 0 || d <= 0 = LA.fromLists []
  | otherwise =
      let basesV = VS.fromList (take d primes) :: VS.Vector Int
          total  = n * d
          flat = VS.create $ do
            v <- MVS.unsafeNew total
            let outer !i
                  | i >= n    = pure ()
                  | otherwise = do
                      let !iOne   = i + 1   -- skip i=0 (origin)
                          !rowBeg = i * d
                          inner !k
                            | k >= d    = pure ()
                            | otherwise = do
                                let !b   = VS.unsafeIndex basesV k
                                    !val = radicalInverse b iOne
                                MVS.unsafeWrite v (rowBeg + k) val
                                inner (k + 1)
                      inner 0
                      outer (i + 1)
            outer 0
            pure v
      in LA.reshape d flat

-- | Halton sequence rescaled into a per-dimension box
-- @[lo_k, hi_k)@. @bounds@ must have length @d@.
haltonSequenceIn :: Int                       -- ^ @n@.
                 -> [(Double, Double)]        -- ^ @bounds@ (length @d@).
                 -> [[Double]]
haltonSequenceIn n bs =
  let d   = length bs
      pts = haltonSequence n d
  in [ zipWith (\u (lo, hi) -> lo + u * (hi - lo)) p bs | p <- pts ]

-- ---------------------------------------------------------------------------
-- Latin Hypercube Sampling
-- ---------------------------------------------------------------------------

-- | Generate @n@ Latin-Hypercube samples in @[0, 1)^d@.
--
-- Algorithm (McKay-Beckman-Conover 1979):
--
--   1. For each dimension @k@, partition @[0, 1)@ into @n@ equal cells
--      @[i/n, (i+1)/n)@ and pick one stratified-random point per cell:
--      @u_{i,k} = (i + r_{i,k}) / n@ where @r ~ U(0, 1)@.
--   2. Independently for each dimension, randomly permute the @n@ cells.
--   3. Stack the per-dim permutations into @n@ points of @d@ coords.
--
-- The result fills every per-dimension marginal cell exactly once,
-- giving much better coverage than @n@ iid uniform draws while still
-- being random.
lhsSamples :: Int -> Int -> GenIO -> IO [[Double]]
lhsSamples n d gen = do
  -- per-dim stratified samples (length n each)
  perDim <- forM [1 .. d] $ \_ -> do
    -- 1) one stratified sample per cell
    base <- forM [0 .. n - 1] $ \i -> do
      r <- uniformR (0, 1) gen :: IO Double
      pure ((fromIntegral i + r) / fromIntegral n)
    -- 2) random permutation (Fisher-Yates)
    mv <- V.thaw (V.fromList base)
    let nLast = n - 1
    mapM_ (\i -> do
              j <- uniformR (i, nLast) gen
              MV.swap mv i j) [0 .. nLast - 1]
    V.toList <$> V.unsafeFreeze mv
  -- transpose: perDim is d × n, want n × d
  pure [ [ (perDim !! k) !! i | k <- [0 .. d - 1] ] | i <- [0 .. n - 1] ]

-- | LHS samples rescaled into the per-dimension box @[lo_k, hi_k)@.
-- @bounds@ must have length @d@.
lhsSamplesIn :: Int -> [(Double, Double)] -> GenIO -> IO [[Double]]
lhsSamplesIn n bs gen = do
  let d = length bs
  pts <- lhsSamples n d gen
  pure [ zipWith (\u (lo, hi) -> lo + u * (hi - lo)) p bs | p <- pts ]