BlessRNG ou comprobando a equidade do RNG

BlessRNG ou comprobando a equidade do RNG

No desenvolvemento de xogos, moitas veces cómpre ligar algo coa aleatoriedade: Unity ten o seu propio Random para iso, e paralelamente a el hai System.Random. Érase unha vez, nun dos proxectos, tiven a impresión de que ambos podían funcionar de xeito diferente (aínda que deberían ter unha distribución uniforme).

Entón non entraron en detalles: abondaba con que a transición a System.Random corrixise todos os problemas. Agora decidimos analizalo con máis detalle e investigar un pouco: como son os RNG "sesgados" ou previsibles e cal escoller. Ademais, máis dunha vez escoitei opinións contradictorias sobre a súa "honestidade": intentemos descubrir como se comparan os resultados reais cos declarados.

O programa educativo breve ou RNG é en realidade RNG

Se xa estás familiarizado cos xeradores de números aleatorios, podes ir inmediatamente á sección "Probas".

Os números aleatorios (RN) son unha secuencia de números xerados mediante algún proceso aleatorio (caótico), unha fonte de entropía. É dicir, esta é unha secuencia cuxos elementos non están interconectados por ningunha lei matemática: non teñen relación de causa e efecto.

O que crea o número aleatorio chámase xerador de números aleatorios (RNG). Parece que todo é elemental, pero se pasamos da teoría á práctica, entón de feito non é tan sinxelo implementar un algoritmo de software para xerar tal secuencia.

A razón reside na ausencia dese mesmo caos na electrónica de consumo moderna. Sen el, os números aleatorios deixan de ser aleatorios e o seu xerador convértese nunha función ordinaria de argumentos obviamente definidos. Para unha serie de especialidades no campo das TIC, este é un problema grave (por exemplo, a criptografía), pero para outras hai unha solución completamente aceptable.

É necesario escribir un algoritmo que retorne, aínda que non sexa números aleatorios, senón o máis próximo posible a eles: os chamados números pseudoaleatorios (PRN). O algoritmo neste caso chámase xerador de números pseudoaleatorios (PRNG).

Hai varias opcións para crear un PRNG, pero o seguinte será relevante para todos:

  1. A necesidade de inicialización previa.

    O PRNG non ten fonte de entropía, polo que hai que darlle un estado inicial antes de usar. Especifícase como un número (ou vector) e chámase semente (semente aleatoria). Moitas veces, o contador do reloxo do procesador ou o equivalente numérico do tempo do sistema úsase como semente.

  2. Reproducibilidade de secuencias.

    O PRNG é completamente determinista, polo que a semente especificada durante a inicialización determina de forma única toda a futura secuencia de números. Isto significa que un PRNG separado inicializado coa mesma semente (en momentos diferentes, en programas diferentes, en dispositivos diferentes) xerará a mesma secuencia.

Tamén cómpre coñecer a distribución de probabilidade que caracteriza o PRNG: que números xerará e con que probabilidade. Na maioría das veces esta é unha distribución normal ou uniforme.
BlessRNG ou comprobando a equidade do RNG
Distribución normal (esquerda) e distribución uniforme (dereita)

Digamos que temos un dado xusto con 24 lados. Se o lanzas, a probabilidade de obter un será igual a 1/24 (o mesmo que a probabilidade de obter calquera outro número). Se realizas moitos lanzamentos e rexistras os resultados, notarás que todos os bordos caen aproximadamente coa mesma frecuencia. Esencialmente, este dado pódese considerar un RNG cunha distribución uniforme.

E se lanzas 10 destes dados á vez e contas o total de puntos? Manterase a uniformidade para iso? Non. Na maioría das veces, a cantidade estará preto de 125 puntos, é dicir, a algún valor medio. E como resultado, mesmo antes de facer un lanzamento, pode estimar aproximadamente o resultado futuro.

O motivo é que hai o maior número de combinacións para obter a puntuación media. Canto máis lonxe, menos combinacións e, en consecuencia, menor probabilidade de perda. Se se visualizan estes datos, asemellaranse vagamente á forma dunha campá. Polo tanto, con algún tramo, un sistema de 10 dados pódese chamar RNG cunha distribución normal.

Outro exemplo, só que esta vez nun avión - disparando a un branco. O tirador será un RNG que xera un par de números (x, y) que se mostra no gráfico.
BlessRNG ou comprobando a equidade do RNG
Acepta que a opción da esquerda está máis próxima á vida real: este é un RNG cunha distribución normal. Pero se necesitas espallar estrelas nun ceo escuro, entón a opción correcta, obtida usando RNG cunha distribución uniforme, é máis adecuada. En xeral, escolla un xerador dependendo da tarefa que se trate.

Agora imos falar da entropía da secuencia PNG. Por exemplo, hai unha secuencia que comeza así:

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, ...

Que aleatorios son estes números a primeira vista? Comecemos por comprobar a distribución.
BlessRNG ou comprobando a equidade do RNG
Parece uniforme, pero se le unha secuencia de dous números e os interpreta como coordenadas nun plano, obtén isto:
BlessRNG ou comprobando a equidade do RNG
Os patróns fanse claramente visibles. E dado que os datos da secuencia están ordenados dun xeito determinado (é dicir, teñen baixa entropía), isto pode dar lugar a ese mesmo "sesgo". Como mínimo, tal PRNG non é moi axeitado para xerar coordenadas nun plano.

Outra secuencia:

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, ...

Todo parece estar ben aquí mesmo no avión:
BlessRNG ou comprobando a equidade do RNG
Vexamos o volume (le tres números á vez):
BlessRNG ou comprobando a equidade do RNG
E de novo os patróns. Xa non é posible construír unha visualización en catro dimensións. Pero poden existir patróns nesta dimensión e noutras máis grandes.

Na criptografía, onde se impón os requisitos máis estritos aos PRNG, tal situación é categoricamente inaceptable. Por iso, desenvolvéronse algoritmos especiais para avaliar a súa calidade, que non falaremos agora. O tema é extenso e merece un artigo aparte.

Probas

Se non sabemos algo con certeza, como traballar con iso? Paga a pena cruzar a estrada se non sabes que semáforo o permite? As consecuencias poden ser diferentes.

O mesmo ocorre coa notoria aleatoriedade de Unity. É bo se a documentación revela os detalles necesarios, pero a historia mencionada ao comezo do artigo ocorreu precisamente pola falta dos detalles desexados.

E se non sabes como funciona a ferramenta, non poderás usala correctamente. En xeral, chegou o momento de comprobar e realizar un experimento para asegurarse finalmente polo menos da distribución.

A solución foi sinxela e eficaz: recolle estatísticas, obtén datos obxectivos e mira os resultados.

Suxeito de estudo

Hai varias formas de xerar números aleatorios en Unity: probamos cinco.

  1. System.Random.Next(). Xera números enteiros nun determinado intervalo de valores.
  2. System.Random.NextDouble(). Xera números de dobre precisión no intervalo de [0; 1).
  3. UnityEngine.Random.Range(). Xera números de precisión únicos (flotantes) nun intervalo de valores determinado.
  4. UnityEngine.Random.value. Xera números únicos de precisión (flotantes) no intervalo de [0; 1).
  5. Unity.Mathematics.Random.NextFloat(). Parte da nova biblioteca de Unity.Mathematics. Xera números de precisión únicos (flotantes) nun intervalo de valores determinado.

Case en todas partes da documentación especificouse unha distribución uniforme, coa excepción de UnityEngine.Random.value (onde non se especificou a distribución, pero tamén se esperaba por analoxía co uniforme de UnityEngine.Random.Range()) e Unity.Mathematics.Random. .NextFloat() (onde en A base é o algoritmo xorshift, o que significa que de novo cómpre esperar unha distribución uniforme).

Por defecto, os resultados esperados foron tomados como os especificados na documentación.

Metodoloxía

Escribimos unha pequena aplicación que xeraba secuencias de números aleatorios utilizando cada un dos métodos presentados e gardaba os resultados para o seu procesamento posterior.

A lonxitude de cada secuencia é de 100 números.
O rango de números aleatorios é [0, 100).

Os datos recompiláronse de varias plataformas de destino:

  • Windows
    — Unity v2018.3.14f1, modo Editor, Mono, .NET Standard 2.0
  • MacOS
    — Unity v2018.3.14f1, modo Editor, Mono, .NET Standard 2.0
    — Unity v5.6.4p4, modo Editor, Mono, .NET Standard 2.0
  • androide
    — Unity v2018.3.14f1, compilación por dispositivo, Mono, .NET Standard 2.0
  • IOS
    — Unity v2018.3.14f1, compilación por dispositivo, il2cpp, .NET Standard 2.0

Implantación

Temos varias formas diferentes de xerar números aleatorios. Para cada un deles, escribiremos unha clase de envoltorio separada, que debería proporcionar:

  1. Posibilidade de configurar o rango de valores [min/max). Establecerase a través do construtor.
  2. Método de retorno MF. Escollemos flotante como tipo, xa que é máis xeral.
  3. O nome do método de xeración para marcar os resultados. Por comodidade, devolveremos como valor o nome completo da clase + o nome do método utilizado para xerar o MF.

En primeiro lugar, imos declarar unha abstracción que será representada pola interface IRandomGenerator:

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

        float Generate();
    }
}

Implementación de System.Random.Next()

Este método permítelle establecer un intervalo de valores, pero devolve números enteiros, pero son necesarios flotantes. Podes simplemente interpretar o número enteiro como un flotante, ou podes ampliar o rango de valores en varias ordes de magnitude, compensándoos con cada xeración do rango medio. O resultado será algo así como un punto fixo cunha orde de precisión determinada. Usaremos esta opción xa que está máis preto do valor flotante real.

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;
    }
}

Implementación de System.Random.NextDouble()

Aquí o intervalo fixo de valores [0; 1). Para proxectalo sobre o especificado no construtor, usamos aritmética simple: 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;
    }
}

Implementación de UnityEngine.Random.Range()

Este método da clase estática UnityEngine.Random permítelle establecer un intervalo de valores e devolve un tipo flotante. Non tes que facer ningunha transformación adicional.

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);
    }
}

Implementación de UnityEngine.Random.value

A propiedade value da clase estática UnityEngine.Random devolve un tipo flotante dun rango fixo de valores [0; 1). Proxectámolo nun intervalo dado do mesmo xeito que cando implementamos 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;
    }
}

Implementación de Unity.Mathematics.Random.NextFloat()

O método NextFloat() da clase Unity.Mathematics.Random devolve unha coma flotante de tipo float e permítelle especificar un intervalo de valores. O único matiz é que cada instancia de Unity.Mathematics.Random terá que inicializarse con algunha semente; deste xeito evitaremos xerar secuencias repetidas.

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);
    }
}

Implementación de MainController

Varias implementacións de IRandomGenerator están listas. A continuación, cómpre xerar secuencias e gardar o conxunto de datos resultante para procesar. Para iso, imos crear unha escena e un pequeno script MainController en Unity, que fará todo o traballo necesario e, ao mesmo tempo, se encargará da interacción coa IU.

Axustemos o tamaño do conxunto de datos e o intervalo de valores MF, e tamén obteña un método que devolve unha matriz de xeradores configurados e listos para funcionar.

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)
            };
        }

        ...
    }
}

Agora imos crear un conxunto de datos. Neste caso, a xeración de datos combinarase coa gravación dos resultados nun fluxo de texto (en formato csv). Para almacenar os valores de cada IRandomGenerator, asógase a súa propia columna separada e a primeira liña contén o nome do xerador.

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();
            }
        }

        ...
    }
}

Todo o que queda é chamar ao método GenerateCsvDataSet e gardar o resultado nun ficheiro ou transferir inmediatamente os datos a través da rede desde o dispositivo final ao servidor receptor.

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();
            }
        }

        ...
    }
}

As fontes do proxecto están en GitLab.

Descubrimentos

Non ocorreu ningún milagre. O que esperaban é o que conseguiron: en todos os casos, unha distribución uniforme sen un indicio de conspiracións. Non vexo o sentido de poñer gráficos separados para plataformas: todos mostran aproximadamente os mesmos resultados.

A realidade é:
BlessRNG ou comprobando a equidade do RNG

Visualización de secuencias nun plano a partir dos cinco métodos de xeración:
BlessRNG ou comprobando a equidade do RNG

E visualización en 3D. Deixarei só o resultado de System.Random.Next() para non producir un montón de contido idéntico.
BlessRNG ou comprobando a equidade do RNG

A historia contada na introdución sobre a distribución normal de UnityEngine.Random non se repetiu: ou foi inicialmente errónea ou algo cambiou no motor. Pero agora estamos seguros.

Fonte: www.habr.com

Engadir un comentario