-
Notifications
You must be signed in to change notification settings - Fork 28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Enhancement] Random.py Package Improvement #7
Comments
I did some digging and it appears that the implementation of MT used by CPython's |
Apologies for butting in, but these seemed relevant: |
Good point on the PCG generators. I only pointed it out as it seemed the best suited (there are other, older generators that could be considered in its stead). |
Just read your edit with this secondary link. It appears NumPy adopted a PCG algorithm in 2019 after a long discussion. The Python group seems hesitant due to its novelty (only being a 6-7 year old algorithm, if I’m reading correctly). They know the Twister has problems which there are solutions to. A Xorshift generator might be better as its fail states are known and tested. It has some issues that make it difficult to achieve a long period without sufficient initialization. However it is notably faster than the Twister which would achieve the speedup considerations. |
This isn't my show, but one possibility might be to opportunistically defer to a package like |
I think that makes a lot of sense, and if we expose |
That sounds like a great plan. Could consider the Random.org API library as an option for people who want to pay for pretty near true RNG. Additional to the NumPy option. I wouldn’t mind making a fork for it, though it would require HTTP request async requirements with cache options. Gets a bit nasty and would slow performance. |
Haha! I think that's a little out of scope to bundle with d20 (especially since the package itself tries to make few assumptions about the environment it's running in re. sync/async), but if the plan is to make opportunistic usage of alternate RNG providers (e.g. numpy), it'll likely expose a |
Motivated in part by avrae/d20#7.
I've done a little bit more reading into the articles/issues linked above and some articles linked to by them. Reading list (in the order I read them):
Personal ThoughtsI can't speak for the quality of PCG vs. XorShift, and this topic seems to be of some debate in the mathematical community. What does seem to be agreed upon, at least, is that both of these are "better" than Mersenne Twister. With regards to speed, the table I mentioned in the second linked article seems to display these results:
This table suggests a 1.52x speedup in PCG64 over MT, and 2.31x when using xoroshiro128++ (other variants excluded for brevity). However, in the context of d20, the actual time it takes to generate a random number is effectively negligible compared to the time it spends in other code (i.e. parsing/arithmetic evaluation/function call overhead/Python overhead). For example, the 2.19ns/64b presented in the above table is less than 7% of the 32.7ns it takes to generate a random number in Python (timed with The most recent version of NumPy includes implementations of the Mersenne Twister, PCG64 (and the DXSM variant), Philox, and SFC64 (the latter two of which I have done no reading into, so I can't comment on them) - bringing this back into what this means for d20, I believe that if we were to implement some method for the user to supply which RNG to use (i.e. some interface for exposing a |
Great and thoughtful research that was quite fascinating to read. I agree with the conclusions you’ve drawn for moving forward with d20. It’d provide a great interface for the user. My suggestion would be the links provided here within the external or code documentation to let the user make a thoughtful conclusion at their own pace and learning. |
I took a stab at implementing a In my case, I merely import the resulting |
That looks pretty clean! I think it might be worth implementing Do you know if subclassing Edit: It looks like the implementation of Edit 2: Or maybe not! I bet you could replicate |
I didn't take any time to look into initialization. 😞 I honestly hadn't thought about its implications. 😬 Not calling
Yeah, I think replacing class NumpyRandom(Random):
# …
def getrandbits(self, k: int) -> int:
# Adapted from random.SystemRandom. See
# <https://github.com/python/cpython/blob/8c21941ddafdf4925170f9cea22e2382dd3b0184/Lib/random.py#L800-L806>.
if k < 0:
raise ValueError("number of bits must be non-negative")
numbytes = (k + 7) // 8 # bits / 8 and rounded up
x = int.from_bytes(self.randbytes(numbytes))
return x >> (numbytes * 8 - k) # trim excess bits
def randbytes(self, n: int) -> bytes:
return self._g.bytes(n) |
Curiosity more than anything to be honest; since the MT is implemented in C it's pretty lightweight (relatively) anyway. Looking at the C code, it seems like it will allocate the space for the MT state (https://github.com/python/cpython/blob/main/Modules/_randommodule.c#L103) but never initialize it (which would normally be done here, which is only called by |
Also, the parent
(FWIW, I'm not married to the implementation. I threw it together as a proof-of-concept over an hour or two without thinking too much about it.) |
Maybe something like this? class NumpyRandom(random.Random):
_gen: Generator
def __init__(self, bitgen_type: Type[_BitGenT], x: _SeedT = None):
self._bitgen_type = bitgen_type
super().__init__(x)
# ...
def seed(self, a: _SeedT = None, version: int = 2):
# ...
self._gen = Generator(self._bitgen_type(a))
# ...
if hasattr(numpy.random, "PCG64DXSM"): # available in numpy 1.21 and up
random_impl = NumpyRandom(numpy.random.PCG64DXSM) |
I may be coming around to your proposal. I started to explore something like this: import random
from numpy.random import default_rng
# Same stuff that numpy.random.default_rng takes as its seed argument
_NumpySeed = Union[None, int, Sequence[int], BitGenerator, Generator]
class NumpyRandom(random.Random):
_gen: Generator
def __init__(self, numpy_seed: _NumpySeed):
super().__init__(numpy_seed)
def seed(
self,
a: _RandSeed, # type: ignore
version: int = 0, # ignored
) -> None:
self._gen = default_rng(a)
# … So basically, one could do: from numpy.random import PCG64DXSM
anything_that_default_rng_would_take = PCG64DXSM(7)
rng = NumpyRandom(anything_that_default_rng_would_take)
# … All good, right? Except: # …
anything_that_default_rng_would_take = [1, 2]
default_rng(anything_that_default_rng_would_take) # this works
NumpyRandom(PCG64DXSM([1, 2])) # this works
NumpyRandom(default_rng(anything_that_default_rng_would_take)) # this works
NumpyRandom(anything_that_default_rng_would_take) # WTF? Results in: Traceback (most recent call last):
File "/…….py", line …, in <module>
NumpyRandom(anything_that_default_rng_would_take) # WTF?
TypeError: unhashable type: 'list' 🤦♂️ So there's obviously some magic fighting against |
Interesting! I think the implementation of If you wanted to avoid passing the class as an argument, one could store the bitgen class as a classvar: class NumpyRandom(random.Random, abc.ABC):
_gen: Generator
_bitgen_type: Type[BitGenerator] # to be implemented by subclasses
def __init__(self, x: _SeedT = None):
super().__init__(x)
# ...
class PCG64DXSMRandom(NumpyRandom):
_bitgen_type = numpy.random.PCG64DXSM
# ... But I'm uncertain that doing that really adds much of value; I think it just boils down to code style preferences at that point. |
Follow-up to 3c9b258 to restructure NumPy random implementation according to a more rigid class structure in line with [this advice](avrae/d20#7 (comment)).
You're right, of course. There's another foot gun, in that NumPy "seeds" can be stateful: >>> from numpy.random import default_rng, PCG64DXSM
>>> seed = PCG64DXSM()
>>> generator = default_rng(seed)
>>> state = seed.state
>>> generator.random()
0.8390240576385848
>>> seed.state == state
False
>>> seed.state = state
>>> generator.random()
0.8390240576385848 So if you keep a reference to the BitGenerator outside of the I like the subclass thing. I took a stab (and solved the non-hashable seed problem) here. |
Background
A friend and I were looking into the avrae/d20 package. We noticed some concerning code flow with regards to how Random is being used. The package being used (random.py) is the old version of the MT algorithm which tends towards statistically lower outcomes (as compared to the 2002 or newer versions). It's also hit or miss given the lack of seeding which means we'd expect the randomness to basically end up being subsets of older rolls. (Deterministic for the purpose of statistical modeling.) Also, it's one of the slowest random algorithms.
There's lots of alternatives that are easy to use and provide more randomness and quicker throughput.
Suggested Improvement
Upgrade to a new random package to be used on lines 405 and 407 of expression.py. A package in the PCG family would likely be optimal for Avrae's usage.
Quote from the page on comparison to other algorithms:
This could avoid the known issues of many-zero state recovery in pre-2002 MT algorithms of which random.py uses. The best outcome for Avrae would be faster generation.
The text was updated successfully, but these errors were encountered: