BlessRNG or check the RNG for honesty

BlessRNG or check the RNG for honesty

In game development, you often need to tie something to random: Unity has its own Random for this, and System.Random exists in parallel with it. Once upon a time, on one of the projects, the impression was that both could work differently (although they should have a uniform distribution).

Then they didn’t go into details - it was enough that the transition to System.Random fixed all the problems. Now we decided to take a closer look and conduct a small study: how “biased” or predictable are RNGs, and which one to choose. Moreover, I have heard conflicting opinions about their "honesty" more than once - let's try to figure out how the real results compare with the declared ones.

A brief educational program or RNG is actually a PRNG

If you are already familiar with random number generators, you can skip ahead to the Testing section.

Random numbers (SN) are a sequence of numbers generated by some random (chaotic) process, a source of entropy. That is, this is such a sequence, the elements of which are not interconnected by any mathematical law - they do not have a causal relationship.

The thing that creates the SC is called the Random Number Generator (RNG). It would seem that everything is elementary, but if we move from theory to practice, then in fact it is not so easy to implement a software algorithm for generating such a sequence.

The reason lies in the absence of the same randomness in modern consumer electronics. Without it, random numbers cease to be random, and their generator turns into a regular function from known arguments. For a number of specialties in the IT field, this is a serious problem (for example, for cryptography), while for the rest there is a completely acceptable solution.

It is necessary to write an algorithm that would return, if not truly random numbers, but as close as possible to them - the so-called pseudo-random numbers (PRN). The algorithm in this case is called a pseudo-random number generator (PRNG).

There are several options for creating a PRNG, but the following will be relevant for everyone:

  1. The need for pre-initialization.

    The PRNG is devoid of a source of entropy, therefore, before using it, it is necessary to specify the initial state. It is given as a number (or a vector) and is called a seed (seed, random seed). Often, the processor cycle counter or the numerical equivalent of the system time is used as a seed.

  2. Sequence reproducibility.

    The PRNG is completely deterministic, so the seed specified during initialization uniquely determines the entire future sequence of numbers. This means that a single PRNG initialized with the same seed (at different times, in different programs, on different devices) will generate the same sequence.

You also need to know the probability distribution that characterizes the PRNG - what numbers it will generate and with what probability. Most often this is either a normal distribution or a uniform distribution.
BlessRNG or check the RNG for honesty
Normal distribution (left) and uniform distribution (right)

Let's say we have a fair dice with 24 faces. If it is tossed, then the probability of a unit falling out will be equal to 1/24 (as well as the probability of falling out of any other number). If you make a lot of throws and write down the results, you will notice that all the faces fall out at about the same frequency. In fact, this dice can be considered an RNG with a uniform distribution.

And if you throw 10 such dice at once and count the total amount of points? Will it remain uniform? No. Most often, the sum will be close to 125 points, that is, to some average value. And as a result, even before the throw, you can roughly estimate the future result.

The reason is that there is the highest number of combinations to get the average score. The farther from it, the fewer combinations - and, accordingly, the lower the probability of falling out. If these data are visualized, they will vaguely resemble the shape of a bell. Therefore, with some stretch, a system of 10 bones can be called an RNG with a normal distribution.

Another example, only in the plane, is shooting at a target. The shooter will be an RNG that generates a pair of numbers (x, y), which is displayed on the chart.
BlessRNG or check the RNG for honesty
Agree that the option on the left is closer to real life - this is an RNG with a normal distribution. But if you need to scatter stars in a dark sky, then the right option, obtained using an RNG with a uniform distribution, is better. In general, choose a generator depending on the task at hand.

Now let's talk about the entropy of the PSN sequence. For example, there is a sequence that starts like this:

89, 93, 33, 32, 82, 21, 4, 42, 11, 8, 60, 95, 53, 30, 42, 19, 34, 35, 62, 23, 44, 38, 74, 36, 52, 18, 58, 79, 65, 45, 99, 90, 82, 20, 41, 13, 88, 76, 82, 24, 5, 54, 72, 19, 80, 2, 74, 36, 71, 9, ...

How random are these numbers at first glance? Let's start by checking the distribution.
BlessRNG or check the RNG for honesty
It looks like it is close to uniform, but if you read a sequence of two numbers and interpret them as coordinates on a plane, you get this:
BlessRNG or check the RNG for honesty
Patterns become clearly visible. And since the data in the sequence is ordered in a certain way (that is, it has low entropy), then this can give rise to the very “bias”. At a minimum, such a PRNG is not very suitable for generating coordinates on a plane.

Another sequence:

42, 72, 17, 0, 30, 0, 15, 9, 47, 19, 35, 86, 40, 54, 97, 42, 69, 19, 20, 88, 4, 3, 67, 27, 42, 56, 17, 14, 20, 40, 80, 97, 1, 31, 69, 13, 88, 89, 76, 9, 4, 85, 17, 88, 70, 10, 42, 98, 96, 53, ...

Everything seems to be fine here even on the plane:
BlessRNG or check the RNG for honesty
Let's look in volume (we read three numbers at a time):
BlessRNG or check the RNG for honesty
And again patterns. It will no longer be possible to build a visualization in four dimensions. But patterns can exist both in this dimension and in larger ones.

In the same cryptography, where the most stringent requirements are imposed on PRNG, such a situation is categorically unacceptable. Therefore, to assess their quality, special algorithms have been developed, which we will not touch on now. The topic is extensive and pulls on a separate article.

The test is

If we do not know something for sure, then how to deal with it? Is it worth it to cross the road if you don't know what traffic signal allows it? The consequences may be different.

The same goes for the notorious randomness in Unity. It's good if the documentation reveals the necessary details, but the story mentioned at the beginning of the article happened just because of the lack of the desired specifics.

And without knowing how the tool works, you will not be able to apply it correctly. In general, it is time to check and conduct an experiment to finally make sure at least at the expense of distribution.

The solution was simple and effective - to collect statistics, get objective data and look at the results.

Subject of study

There are several ways to generate random numbers in Unity - we tested five.

  1. System.Random.Next(). Generates integers (integer) in the given range of values.
  2. System.Random.NextDouble(). Generates double precision numbers (double) in the range from [0; 1).
  3. UnityEngine.Random.Range(). Generates single precision (float) numbers in the given range of values.
  4. UnityEngine.Random.value. Generates single precision numbers (float) in the range from [0; 1).
  5. Unity.Mathematics.Random.NextFloat(). Part of the new Unity.Mathematics library. Generates single precision (float) numbers in the given range of values.

Almost everywhere in the documentation, a uniform distribution was specified, with the exception of UnityEngine.Random.value (where the distribution is not specified, but by analogy with UnityEngine.Random.Range() it was also expected to be uniform) and Unity.Mathematics.Random.NextFloat() (where in based on the xorshift algorithm, which means that again you need to wait for a uniform distribution).

By default, the expected results were those specified in the documentation.

Method

We wrote a small application that generated sequences of random numbers in each of the presented ways and saved the results for further processing.

The length of each sequence is 100 numbers.
The range of random numbers is [0, 100).

Data was collected from several target platforms:

  • Windows
    - Unity v2018.3.14f1, Editor mode, Mono, .NET Standard 2.0
  • MacOS
    - Unity v2018.3.14f1, Editor mode, Mono, .NET Standard 2.0
    - Unity v5.6.4p4, Editor mode, Mono, .NET Standard 2.0
  • Android
    - Unity v2018.3.14f1, Build Per Device, Mono, .NET Standard 2.0
  • iOS
    - Unity v2018.3.14f1, Build Per Device, il2cpp, .NET Standard 2.0

implementation

We have several different ways to generate random numbers. For each of them, we will write a separate wrapper class that should provide:

  1. Possibility to set the range of values ​​[min/max). Will be set via the constructor.
  2. The method that returns the MF. As a type, we will choose float, as a more general one.
  3. The name of the generation method for marking the results. For convenience, we will return the full name of the class + the name of the method used to generate the midrange as a value.

First, let's declare an abstraction, which will be represented by the IRandomGenerator interface:

namespace RandomDistribution
{
    public interface IRandomGenerator
    {
        string Name { get; }

        float Generate();
    }
}

System.Random.Next() implementation

This method allows you to set a range of values, but it returns integers (integer), and floats are needed. You can simply interpret integer as a float, or you can expand the range of values ​​​​by several orders of magnitude, compensating them with each generation of the midrange. It will turn out something like a fixed-point with a given order of accuracy. We will use this option, since it is closer to the real float value.

using System;

namespace RandomDistribution
{
    public class SystemIntegerRandomGenerator : IRandomGenerator
    {
        private const int DefaultFactor = 100000;
        
        private readonly Random _generator = new Random();
        private readonly int _min;
        private readonly int _max;
        private readonly int _factor;


        public string Name => "System.Random.Next()";


        public SystemIntegerRandomGenerator(float min, float max, int factor = DefaultFactor)
        {
            _min = (int)min * factor;
            _max = (int)max * factor;
            _factor = factor;
        }


        public float Generate() => (float)_generator.Next(_min, _max) / _factor;
    }
}

System.Random.NextDouble() implementation

Here the fixed range of values ​​is [0; 1). To project it onto the one given in the constructor, we use simple arithmetic: X * (max − min) + min.

using System;

namespace RandomDistribution
{
    public class SystemDoubleRandomGenerator : IRandomGenerator
    {
        private readonly Random _generator = new Random();
        private readonly double _factor;
        private readonly float _min;


        public string Name => "System.Random.NextDouble()";


        public SystemDoubleRandomGenerator(float min, float max)
        {
            _factor = max - min;
            _min = min;
        }


        public float Generate() => (float)(_generator.NextDouble() * _factor) + _min;
    }
}

Implementation of UnityEngine.Random.Range()

This method of the UnityEngine.Random static class allows you to set a range of values ​​and returns a float number. You don't have to do any additional transformations.

using UnityEngine;

namespace RandomDistribution
{
    public class UnityRandomRangeGenerator : IRandomGenerator
    {
        private readonly float _min;
        private readonly float _max;


        public string Name => "UnityEngine.Random.Range()";


        public UnityRandomRangeGenerator(float min, float max)
        {
            _min = min;
            _max = max;
        }


        public float Generate() => Random.Range(_min, _max);
    }
}

UnityEngine.Random.value implementation

The value property of the UnityEngine.Random static class returns a float-type midrange from a fixed range of values ​​[0; 1). Let's project it onto the given range in the same way as when implementing System.Random.NextDouble().

using UnityEngine;

namespace RandomDistribution
{
    public class UnityRandomValueGenerator : IRandomGenerator
    {
        private readonly float _factor;
        private readonly float _min;


        public string Name => "UnityEngine.Random.value";


        public UnityRandomValueGenerator(float min, float max)
        {
            _factor = max - min;
            _min = min;
        }


        public float Generate() => (float)(Random.value * _factor) + _min;
    }
}

Unity.Mathematics.Random.NextFloat() implementation

The NextFloat() method of the Unity.Mathematics.Random class returns a floating point number and allows you to set a range of values. The only nuance is that each instance of Unity.Mathematics.Random will have to be initialized with some seed - this way we will avoid generating repeating sequences.

using Unity.Mathematics;

namespace RandomDistribution
{
    public class UnityMathematicsRandomValueGenerator : IRandomGenerator
    {
        private Random _generator;
        private readonly float _min;
        private readonly float _max;


        public string Name => "Unity.Mathematics.Random.NextFloat()";


        public UnityMathematicsRandomValueGenerator(float min, float max)
        {
            _min = min;
            _max = max;
            _generator = new Random();
            _generator.InitState(unchecked((uint)System.DateTime.Now.Ticks));
        }


        public float Generate() => _generator.NextFloat(_min, _max);
    }
}

Implementation of MainController

Several implementations of IRandomGenerator are ready. Next, you need to generate sequences and save the resulting dataset for processing. To do this, let's create a scene in Unity and a small MainController script that will do all the necessary work and be responsible for interacting with the UI along the way.

Let's set the size of the dataset and the range of midrange values, and also get a method that returns an array of configured and ready-to-work generators.

namespace RandomDistribution
{
    public class MainController : MonoBehaviour
    {
        private const int DefaultDatasetSize = 100000;

        public float MinValue = 0f;
        public float MaxValue = 100f;

        ...

        private IRandomGenerator[] CreateRandomGenerators()
        {
            return new IRandomGenerator[]
            {
                new SystemIntegerRandomGenerator(MinValue, MaxValue),
                new SystemDoubleRandomGenerator(MinValue, MaxValue),
                new UnityRandomRangeGenerator(MinValue, MaxValue),
                new UnityRandomValueGenerator(MinValue, MaxValue),
                new UnityMathematicsRandomValueGenerator(MinValue, MaxValue)
            };
        }

        ...
    }
}

And now we form the dataset. In this case, data generation will be combined with the recording of results in a text stream (in csv format). Each IRandomGenerator has its own column to store the values, and the first row contains the Name of the generator.

namespace RandomDistribution
{
    public class MainController : MonoBehaviour
    {
        ...
		
        private void GenerateCsvDataSet(TextWriter writer, int dataSetSize, params IRandomGenerator[] generators)
        {
            const char separator = ',';
            int lastIdx = generators.Length - 1;

            // write header
            for (int j = 0; j <= lastIdx; j++)
            {
                writer.Write(generators[j].Name);
                if (j != lastIdx)
                    writer.Write(separator);
            }
            writer.WriteLine();

            // write data
            for (int i = 0; i <= dataSetSize; i++)
            {
                for (int j = 0; j <= lastIdx; j++)
                {
                    writer.Write(generators[j].Generate());
                    if (j != lastIdx)
                        writer.Write(separator);
                }

                if (i != dataSetSize)
                    writer.WriteLine();
            }
        }

        ...
    }
}

It remains to call the GenerateCsvDataSet method and save the result to a file, or immediately transfer data over the network from the end device to the receiving server.

namespace RandomDistribution
{
    public class MainController : MonoBehaviour
    {
        ...
		
        public void GenerateCsvDataSet(string path, int dataSetSize, params IRandomGenerator[] generators)
        {
            using (var writer = File.CreateText(path))
            {
                GenerateCsvDataSet(writer, dataSetSize, generators);
            }
        }


        public string GenerateCsvDataSet(int dataSetSize, params IRandomGenerator[] generators)
        {
            using (StringWriter writer = new StringWriter(CultureInfo.InvariantCulture))
            {
                GenerateCsvDataSet(writer, dataSetSize, generators);
                return writer.ToString();
            }
        }

        ...
    }
}

The project sources are on GitLab.

The results

The miracle didn't happen. What they expected, they got - in all cases, an even distribution without a hint of conspiracies. I don’t see the point in applying separate charts for platforms - they all show approximately the same results.

The reality is this:
BlessRNG or check the RNG for honesty

Visualization of sequences on the plane from all five generation methods:
BlessRNG or check the RNG for honesty

And visualization in 3D. I will leave only the result of System.Random.Next (), so as not to produce a bunch of the same content.
BlessRNG or check the RNG for honesty

The story told in the introduction about the normal distribution of UnityEngine.Random has not been repeated: either it was initially erroneous, or something has changed in the engine since then. But now we are sure.

Source: habr.com

Add a comment