【问题标题】:Random number quality with given seed in haskellhaskell中给定种子的随机数质量
【发布时间】:2020-05-23 09:18:32
【问题描述】:

我在一个小型 ascii 游戏中玩程序生成,我在 haskell 中遇到了随机数的问题。基本思想是提供一个随机数,其中包含游戏世界某些部分的 (x,y),例如决定那里是否有一棵树 (this guy explains it nicely)

这是我在为每一代尝试不同的种子时得到的结果:

randomFromSeed :: Int -> Int -> Int
randomFromSeed max seed = fst (randomR (0, max - 1) (mkStdGen seed))

Prelude> map (randomFromSeed 10) [1..20]
[5,9,3,7,1,5,9,3,7,1,5,9,3,7,1,5,9,3,7,1]

它显然有一个周期 5,但另一方面,mkStdGen docs 它说:

函数 mkStdGen 提供了另一种生成初始生成器的方法,通过将 Int 映射到生成器。 同样,不同的参数应该可能产生不同的生成器。

那怎么会,似乎只有 5 个不同的生成器来了?

当给定不同的种子时,我怎样才能让它们真正随机?

编辑 由于某些奇怪的原因,使用更大的数字会更好:

Prelude> let mult = 1000000 in map (randomFromSeed 10) [0,mult .. 20*mult]
[3,7,0,6,9,2,8,1,4,0,3,9,2,5,1,4,7,3,6,9,5]

【问题讨论】:

  • 为什么您需要为每个随机数使用单独的种子?您无需每次都创建一个新的StdGenrandomR 已经在它的返回值的snd 处为您提供了一个新的StdGen,通常的做法是使用它来获取下一个随机值..
  • 我想要一个映射,它可以为游戏地图上的每个点 (x,y) 提供一些随机值,这样我就可以在无限的世界中移动,但能够回到我想去的地方已经去过了。我总是手头有可见点,所以我可以通过这种方式重新创建相同的随机地图,而无需记住任何内容。
  • @Redu 我看到的这个解决方案的其他问题是,如果我已经很远了,假设在 (1000,1000) 并且想要映射近距离的所有点,我将不得不遍历序列直到第 1000 个元素。我错过了什么吗?因此,离 (0,0) 越远,生成的速度就越慢(假设我们除了当前坐标之外不存储任何东西)。
  • 刚才说,你的玩家已经访问了点 (7,9) 并在那里发现了一棵橡树。如果稍后经过一些随机游走,玩家返回到 (7,9),那么关于看哪种树的决定必须与第一次访问时完全相同?而且这最好不必在某些数据结构中记住以前的决定?
  • 您可以通过使用 pcg-random 而不是 random 来避免您看到的意外行为。演示:github.com/dc25/…

标签: haskell


【解决方案1】:

您可以通过使用pcg-random 而不是random 来避免您看到的意外行为:

import System.Random.PCG 
import Control.Monad.ST

randomFromSeed :: Int -> Int -> Int
randomFromSeed max seed = runST $ do
  g <- initialize (fromIntegral seed) 0
  uniformR (0, max - 1) g

main :: IO ()
main = print $ map (randomFromSeed 10) [1..20]

pcg-random has other nice properties too.

【讨论】:

    【解决方案2】:
    那么怎么来,似乎只有 5 个不同的生成器来了?

    认为只有 5 个生成器是一种错觉。如果您打印每个序列的第二个数字而不是第一个数字,您会得到:

    random2ndFromSeed :: Int -> Int -> Int
    random2ndFromSeed max seed =
        let   g0       = mkStdGen seed
              (v1, g1) = randomR (0, max - 1) g0
              (v2, g2) = randomR (0, max - 1) g1
        in   v2
    
     λ> 
     λ> map  (random2ndFromSeed 10)  [1..40]
    [6,9,3,8,1,4,8,3,6,9,3,8,1,4,8,3,6,9,3,8,1,4,8,3,6,9,3,8,1,4,8,3,6,9,3,8,1,4,8,3]
     λ> 
    

    所以周期性似乎是 8 而不是 5!

    摆脱明显问题的一种方法是用Threefish 替换标准生成器,该生成器具有更新的设计并具有更好的统计特性。或者您也可以使用 Dave Compton 提到的 pcg-random

    import  System.Random.TF
    
    tfRandomFromSeed :: Int -> Int -> Int
    tfRandomFromSeed max seed = let   g0 = mkTFGen seed
                                in    fst $ randomR (0, max - 1) g0
    
     λ> 
     λ> map  (tfRandomFromSeed 10)  [1..40]
    [4,5,6,7,5,3,3,0,0,4,2,8,0,4,1,0,0,1,3,5,6,4,3,6,4,0,3,6,4,0,2,4,5,9,7,3,8,5,2,4]
     λ> 
    

    更一般地说,随机性的出现应该来自于生成器next 函数的重复应用。在这里,该函数仅对每个种子/序列应用一次,因此不主张随机性。

    如何创建持久的 2D 随机场

    从 cmets 来看,实际需要的是二维空间中某个点的“随机”函数。如果玩家在随机游走之后返回到某个已经访问过的点,则预计会找到与之前相同的随机值,并且无需记住之前的随机值。

    为了实现这一点,让我们对随机值的统计特性有一些保证,我们需要使用单个种子和单个随机序列来做到这一点;这就是我们的应用数学家testing

    我们需要两件事来产生这样一个持久的二维随机场:

    1. 一种将二维点映射到单个自然数的方法,用作随机序列的偏移量
    2. 一种随机数生成器,可有效访问每个序列的任意

    将二维点映射到自然数

    这可以通过利用基本集合论中的Cantor Pairing Function 来完成。

    我们可以使用这段代码:

    -- limited to first quadrant, x >= 0 and y >= 0:
    cantor1 :: Int -> Int -> Int
    cantor1 x y = y + (let s = x + y  in  div  (s * (s+1))  2)
    
    -- for all 4 quadrants:
    cantor :: (Int, Int) -> Int
    cantor (x,y) =
        let quadrant
              | x >= 0  &&  y >= 0   =  0
              | x <  0  &&  y >= 0   =  1
              | x <  0  &&  y  < 0   =  2  
              | x >= 0  &&  y <  0   =  3
              | otherwise            =  error  "cantor: internal error #1"
            cant1
              | x >= 0  &&  y >= 0   =  cantor1     x      y
              | x <  0  &&  y >= 0   =  cantor1  (-1-x)    y
              | x <  0  &&  y  < 0   =  cantor1  (-1-x)  (-1-y)
              | x >= 0  &&  y <  0   =  cantor1     x    (-1-y)
              | otherwise            =  error  "cantor: internal error #2"
        in
             4*cant1 + quadrant
    
    

    安排任意访问

    完成这个初步步骤后,我们必须认识到常规的 Haskell 随机数生成 API 不太适合手头的任务。

    API 通过next 函数提供对随机序列的顺序访问。但是没有 任意 访问,例如在 C++ 随机库中由 discard 函数提供。而使用MonadRandom 接口的经典单子样式完全是关于顺序访问。它基本上就像一个状态单子。

    此外,使用一些随机数生成器,根本不可能有效访问序列的任意点。在这种情况下,C++ discard 函数只需使用代价高昂的单步执行即可到达所需点。

    幸运的是,有一个Haskell implementation Pierre L'Ecuyer 等人的MRG32k3a 随机数生成器。

    使用 MRG32k3a,对随机序列的任意访问归结为对 2 个伽罗瓦域中的小矩阵求幂。感谢古老而受人尊敬的Indian exponentiation algorithm,这可以在 O(log n) 时间内完成。

    github 中的 MRG32k3a 代码没有提供完整的 Haskell 样式接口,例如 RandomGen 实例,因此我们必须在其周围添加一些包装代码。

    首先,我们需要一些导入子句:

    import  System.Random
    import  System.Random.TF
    import qualified  Data.List           as  L
    import qualified  Text.Printf         as  TP
    import qualified  Data.Text           as  TL
    import qualified  Data.ByteString     as  BS
    import qualified  Data.Text.Encoding  as  TSE
    import qualified  Crypto.Hash.SHA256  as  SHA
    import qualified  System.Random.MRG32K3A.Simple as MRG
    

    然后是包装代码本身:

    newtype MRGen = MRGen MRG.State  -- wrapper type for MRG32k3a generator
                    deriving  Show
    
    instance RandomGen  MRGen  where
        genRange = let  mrg32k3a_m1 = ((2::Integer)^32 - 209)
                   in   const  (0::Int, fromIntegral (mrg32k3a_m1 - 1))
    
        next (MRGen g0) = let  (v, g1) = MRG.next g0
                          in   ((fromIntegral v)::Int, MRGen g1)
    
        split (MRGen g0) = let  g1 = MRG.advance ((2::Integer)^96) g0
                           in   (MRGen g0, MRGen g1) 
    
    mkMRGen :: Int -> MRGen
    mkMRGen userSeed = let  longSeed = hashSeed userSeed
                            g0       =  MRG.seed longSeed
                       in   MRGen g0
    
    ranSeek :: MRGen -> Integer -> MRGen
    ranSeek (MRGen g0) count =  let  g1 = (MRG.advance count g0)  in   MRGen g1
    
    hashSeed :: Int -> Integer
    hashSeed userSeed =
        let str   = "MRG32k3a:" ++ (TP.printf "0x%x" userSeed)
            bytes =  (TSE.encodeUtf8 . TL.pack) $ str
            ints  = (map (fromIntegral) $ BS.unpack (SHA.hash bytes)) :: [Integer]
        in
            L.foldl'  (\acc d -> acc*256 + d)  0  (take 20 ints)
    
    

    函数mkMRGen 类似于mkStdGen。对随机序列的任意访问由函数ranSeek :: MRGen -&gt; Integer -&gt; MRGen 在 O(log n) 时间内提供。

    旁注:我正在重新散列mkMRGen 中用户提供的种子。这是因为 github 包使用它的种子作为随机序列的偏移量。所以为了避免小用户种子的序列重叠风险,我需要从用户种子中生成大量的数。

    感谢我们的RandomGen 实例,我们可以访问常用功能,例如random :: RandomGen g => g -> (a, g)。例如,我们可以从一个简单的Int 种子生成一个 Double 类型的二维随机字段,如下所示:

    randomDoubleField :: Int -> (Int, Int) -> Double
    randomDoubleField userSeed (x,y) =
        let  k  = 1  -- number of needed random values per plane point
             g0 = mkMRGen userSeed
             g1 = ranSeek  g0  (fromIntegral (k * cantor (x,y)))
        in   fst (random g1)
    
    

    现在我们有了这个小工具包,我们可以编写一个小测试程序,为零点邻域绘制一些随机景观,每个 2D 点一个字符。

    比如说,字符“t”代表一种树,而“T”代表另一种树。没有树用减号表示。

    主程序:

    randomCharField :: Int -> (Int, Int) -> Char
    randomCharField  userSeed  (x,y) =
        let  n = floor (8.0 * randomDoubleField userSeed (x,y) )
        in   "------tT"  !!  n
    
    
    rowString :: Int -> Int -> Int -> String
    rowString userSeed size y =
                   let  xRange = [(-size) .. size]
                   in   map  (randomCharField userSeed)  [ (x,y) | x <- xRange ]
    
    
    main = do
        let  userSeed = 42
             size     = 6
             yRange   = [(-size) .. size]
        mapM_  (putStrLn . (rowString userSeed size))  yRange
    
    

    程序输出:

    --t-T----TT-t
    ------t-----T
    -T--T--T-----
    --t-T--tTTT--
    --T--t---T---
    t-Tt------t--
    -T-----t-T---
    -T-t-t----T--
    tT-tT---tT--t
    ---TTt---t---
    -------T---t-
    --t---------t
    -tT-t---t----
    

    优化说明: 如果性能是一个问题,您可能希望将 (mkMRGen userSeed) 计算移出循环。

    【讨论】:

    • 非常感谢您的广泛回答。
    猜你喜欢
    • 1970-01-01
    • 2013-05-28
    • 2012-12-26
    • 2011-05-03
    • 1970-01-01
    • 2011-05-23
    • 2014-10-03
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多