How can I make an object with an interface like a random number generator, but that actually generates a specified sequence?

Viewed 3592

I'd like to construct an object that works like a random number generator, but generates numbers in a specified sequence.

# a random number generator
rng = lambda : np.random.randint(2,20)//2

# a non-random number generator
def nrng():
    numbers = np.arange(1,10.5,0.5)
    for i in range(len(numbers)):
        yield numbers[i]

for j in range(10):
    print('random number', rng())
    print('non-random number', nrng())

The issue with the code above that I cannot call nrng in the last line because it is a generator. I know that the most straightforward way to rewrite the code above is to simply loop over the non-random numbers instead of defining the generator. I would prefer getting the example above to work because I am working with a large chunk of code that include a function that accepts a random number generator as an argument, and I would like to add the functionality to pass non-random number sequences without rewriting the entire code.

EDIT: I see some confusion in the comments. I am aware that python's random number generators generate pseudo-random numbers. This post is about replacing a pseudo-random-number generator by a number generator that generates numbers from a non-random, user-specified sequence (e.g., a generator that generates the number sequence 1,1,2,2,1,0,1 if I want it to).

6 Answers

You can call next() with a generator or iterator as an argument to withdraw exactly one element from it. Saving the generator to a variable beforehand allows you to do this multiple times.

# make it a generator
def _rng():
    while True:
        yield np.random.randint(2,20)//2

# a non-random number generator
def _nrng():
    numbers = np.arange(1,10.5,0.5)
    for i in range(len(numbers)):
        yield numbers[i]

rng = _rng()
nrng = _nrng()
for j in range(10):
    print('random number', next(rng))
    print('non-random number', next(nrng))

Edit:

The cleanest way to do this would be to use a lambda to wrap your call to next(nrng) as per great comment from @GACy20:

def nrng_gen():
    yield from range(10)

nrng = nrng_gen()

nrng_func = lambda: next(nrng)

for i in range(10):
    print(nrng_func())

Original answer:

If you want your object to keep state and look like a function, create a custom class with __call__ method.

eg.

class NRNG:
    def __init__(self):
        self.numbers = range(10)
        self.state = -1
    def __call__(self):
        self.state += 1
        return self.numbers[self.state]
        
nrng = NRNG()


for i in range(10):
    print(nrng())

However, I wouldn't recommend this unless absolutely necessary, as it obscures the fact that your nrng keeps a state (although technically, most rngs keep their state internally).

It's best to just use a regular generator with yield by calling next on it or to write a custom iterator (also class-based). Those will work with things like for loops and other python tools for iteration (like the excellent itertools package).

np.random.randint can remember the last number it generated because it's a function of the np.random.RandomState class. Numpy simply aliases the class method so that it's accessible directly from the np.random module instead of having you access it through the class.

Knowing this, you can write your own class to work like so:

class NotRandom:

    numbers = np.arange(1,10.5,0.5)
    last_index = -1

    @classmethod
    def nrng(cls):
        cls.last_index += 1
        if cls.last_index < len(cls.numbers):
            return cls.numbers[cls.last_index]
        # else:
        return None

# Create an alias to the classmethod
nrng = NotRandom.nrng      # Note this is OUTSIDE the class

Then, you can do:

print(nrng())    # 1.0
print(nrng())    # 1.5
print(nrng())    # 2.0

If you want to be able to have multiple concurrent instances of nrng, you could make nrng() an instance method instead of a class method:

class NotRandom:
    def __init__(self):
        self.numbers = np.arange(1,10.5,0.5)
        self.last_index = -1

    def nrng(self):
        self.last_index += 1
        if self.last_index < len(self.numbers):
            return self.numbers[self.last_index]
        # else:
        return None

# Create an object. Then create an alias to its method
nrng = NotRandom().nrng 

Then, you can use nrng() to refer to the method that's bound to the NotRandom instance you created. If you want another instance, you can have that too:

another_notrandom = NotRandom()
nrng2 = another_notrandom.rng

print(nrng())    # 1.0
print(nrng())    # 1.5

print(nrng2())   # 1.0

print(nrng())    # 2.0

print(nrng2())   # 1.5

function that accepts a random number generator as an argument

Call it like this then:

that_function(nrng().__next__)

Or with functools.partial:

that_function(partial(next, nrng()))

Or without your generator, if that arange is all you want:

that_function(iter(np.arange(1,10.5,0.5)).__next__)

Demo code (rng and nrng are yours, I added that_function and the test calls):

import numpy as np
from functools import partial

# a random number generator
rng = lambda : np.random.randint(2,20)//2

# a non-random number generator
def nrng():
    numbers = np.arange(1,10.5,0.5)
    for i in range(len(numbers)):
        yield numbers[i]

def that_function(rng):
    print(*(rng() for j in range(10)))

that_function(rng)
that_function(nrng().__next__)
that_function(iter(np.arange(1,10.5,0.5)).__next__)
that_function(partial(next, nrng()))

Output (Try it online!):

4 6 1 3 8 7 3 6 2 1
1.0 1.5 2.0 2.5 3.0 3.5 4.0 4.5 5.0 5.5
1.0 1.5 2.0 2.5 3.0 3.5 4.0 4.5 5.0 5.5
1.0 1.5 2.0 2.5 3.0 3.5 4.0 4.5 5.0 5.5

The simplest solution to making a generator callable is to make a bound method for __next__:

>>> def f():
        yield 10
        yield 20
        yield 30
    
>>> g = f().__next__
>>> g()
10
>>> g()
20
>>> g()
30

I do agree, next() seems the way to go. Honestly I would have simply done something like this:

# Get the first n numbers (10 in this case)

for i,j in enumerate(nrng()):  
    if i > 9:
        break
    print('random number', rng())
    print('non-random number', j)
    
# Or this if you want to get all numbers in nrng()

for j in nrng():
    print('random number', rng())
    print('non-random number', j)
Related