Skip to content

Commit

Permalink
Restructure NumPy Random implementation
Browse files Browse the repository at this point in the history
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)).
  • Loading branch information
posita committed Sep 24, 2021
1 parent 3c9b258 commit bc74905
Show file tree
Hide file tree
Showing 5 changed files with 206 additions and 115 deletions.
2 changes: 1 addition & 1 deletion docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
* Introduces experimental generic [``walk``][dyce.r.walk] function and supporting visitor data structures.
* Uses ``pygraphviz`` to automate class diagram generation.
(See the note on special considerations for regenerating class diagrams in the [hacking quick start](contrib.md#hacking-quick-start).)
* Uses ``numpy`` for RNG, if present.
* Introduces experimental use of ``numpy`` for RNG, if present.
* Migrates to using ``pyproject.toml`` and ``setup.cfg``.

## [0.4.0](https://github.com/posita/dyce/releases/tag/v0.4.0)
Expand Down
4 changes: 2 additions & 2 deletions dyce/h.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@
overload,
)

from . import rng
from .bt import beartype
from .lifecycle import experimental
from .rng import RNG
from .symmetries import comb, gcd
from .types import (
CachingProtocolMeta,
Expand Down Expand Up @@ -1720,7 +1720,7 @@ def roll(self) -> OutcomeT:
Returns a (weighted) random outcome, sorted.
"""
return (
RNG.choices(
rng.RNG.choices(
population=tuple(self.outcomes()),
weights=tuple(self.counts()),
k=1,
Expand Down
138 changes: 92 additions & 46 deletions dyce/rng.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,76 +8,122 @@

from __future__ import annotations

from abc import ABC
from random import Random
from typing import Any, NewType, Optional, Union
from sys import version_info
from typing import NewType, Sequence, Type, Union

from .bt import beartype

__all__ = ("RNG",)


# ---- Types ---------------------------------------------------------------------------


_RandSeed = Union[int, float, str, bytes, bytearray]
_RandState = NewType("_RandState", Any)
_RandState = NewType("_RandState", object)
_RandSeed = Union[None, int, Sequence[int]]


# ---- Data ----------------------------------------------------------------------------


RNG: Random = Random()
RNG: Random


# ---- Classes -------------------------------------------------------------------------


try:
import numpy.random
from numpy.random import BitGenerator, Generator
from numpy.random import PCG64DXSM, BitGenerator, Generator, default_rng

class NumpyRandom(Random):
_BitGeneratorT = Type[BitGenerator]

class NumPyRandomBase(Random, ABC):
r"""
Defines a [``!#python
Base class for a [``#!python
random.Random``](https://docs.python.org/3/library/random.html#random.Random)
implementation that accepts and uses a [``!#python
implementation that uses a [``#!python
numpy.random.BitGenerator``](https://numpy.org/doc/stable/reference/random/bit_generators/index.html)
under the covers. Motivated by
[avrae/d20#7](https://github.com/avrae/d20/issues/7).
The [initializer][rng.NumPyRandomBase.__init__] takes an optional *seed*, which is
passed to
[``NumPyRandomBase.bit_generator``][dyce.rng.NumPyRandomBase.bit_generator] via
[``NumPyRandomBase.seed``][dyce.rng.NumPyRandomBase.seed] during construction.
"""

def __init__(self, bit_generator: BitGenerator):
self._g = Generator(bit_generator)
bit_generator: _BitGeneratorT
_generator: Generator

if version_info < (3, 11):

@beartype
def __new__(cls, seed: _RandSeed = None):
r"""
Because ``#!python random.Random`` is broken in versions <3.11, ``#!python
random.Random``’s vanilla implementation cannot accept non-hashable
values as the first argument. For example, it will reject lists of
``#!python int``s as *seed*. This implementation of ``#!python __new__``
fixes that.
"""
return super(NumPyRandomBase, cls).__new__(cls)

@beartype
def __init__(self, seed: _RandSeed = None):
# Parent calls self.seed(seed)
super().__init__(seed)

# ---- Overrides ---------------------------------------------------------------

@beartype
def getrandbits(self, k: int) -> int:
# Adapted from the implementation for random.SystemRandom.getrandbits
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), "big")

return x >> (numbytes * 8 - k) # trim excess bits

@beartype
# TODO(posita): See <https://github.com/python/typeshed/issues/6063>
def getstate(self) -> _RandState: # type: ignore
return _RandState(self._generator.bit_generator.state)

@beartype
def randbytes(self, n: int) -> bytes:
return self._generator.bytes(n)

@beartype
def random(self) -> float:
return self._g.random()

def seed(self, a: Optional[_RandSeed], version: int = 2) -> None:
if a is not None and not isinstance(a, (int, float, str, bytes, bytearray)):
raise ValueError(f"unrecognized seed type ({type(a)})")

bg_type = type(self._g.bit_generator)

if a is None:
self._g = Generator(bg_type())
else:
# This is somewhat fragile and may not be the best approach. It uses
# `random.Random` to generate its own state from the seed in order to
# maintain compatibility with accepted seed types. (NumPy only accepts
# ints whereas the standard library accepts ints, floats, bytes, etc.).
# That state consists of a 3-tuple: (version: int, internal_state:
# tuple[int], gauss_next: float) at least for for versions through 3 (as
# of this writing). We feed internal_state as the seed for the NumPy
# BitGenerator.
version, internal_state, _ = Random(a).getstate()
self._g = Generator(bg_type(internal_state))

def getstate(self) -> _RandState:
return _RandState(self._g.bit_generator.state)

def setstate(self, state: _RandState) -> None:
self._g.bit_generator.state = state

if hasattr(numpy.random, "PCG64DXSM"):
RNG = NumpyRandom(numpy.random.PCG64DXSM())
elif hasattr(numpy.random, "PCG64"):
RNG = NumpyRandom(numpy.random.PCG64())
elif hasattr(numpy.random, "default_rng"):
RNG = NumpyRandom(numpy.random.default_rng().bit_generator)
return self._generator.random()

@beartype
def seed( # type: ignore
self,
a: _RandSeed,
version: int = 2,
) -> None:
self._generator = default_rng(self.bit_generator(a))

@beartype
def setstate( # type: ignore
self,
# TODO(posita): See <https://github.com/python/typeshed/issues/6063>
state: _RandState,
) -> None:
self._generator.bit_generator.state = state

class PCG64DXSMRandom(NumPyRandomBase):
r"""
A [``NumPyRandomBase``][dyce.rng.NumPyRandomBase] based on
[``numpy.random.PCG64DXSM``](https://numpy.org/doc/stable/reference/random/bit_generators/pcg64dxsm.html#numpy.random.PCG64DXSM).
"""
bit_generator = PCG64DXSM

RNG = PCG64DXSMRandom()
except ImportError:
pass
RNG = Random()
4 changes: 2 additions & 2 deletions tests/test_h.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,13 @@ def test_op_sub_h(self) -> None:
assert d2 - d3 == {
o_type(-2): 1,
o_type(-1): 2,
# See <https://github.com/sympy/sympy/issues/6545>
# TODO(posita): See <https://github.com/sympy/sympy/issues/6545>
o_type(0) + o_type(0): 2,
o_type(1): 1,
}, f"o_type: {o_type}; c_type: {c_type}"
assert d3 - d2 == {
o_type(-1): 1,
# See <https://github.com/sympy/sympy/issues/6545>
# TODO(posita): See <https://github.com/sympy/sympy/issues/6545>
o_type(0) + o_type(0): 2,
o_type(1): 2,
o_type(2): 1,
Expand Down
Loading

0 comments on commit bc74905

Please sign in to comment.