BlessRNG o verificar la equidad del RNG

BlessRNG o verificar la equidad del RNG

En el desarrollo de juegos, a menudo es necesario vincular algo con la aleatoriedad: Unity tiene su propio Random para esto y, en paralelo, está System.Random. Una vez, en uno de los proyectos, tuve la impresión de que ambos podrían funcionar de manera diferente (aunque deberían tener una distribución uniforme).

Luego no entraron en detalles: fue suficiente que la transición a System.Random corrigiera todos los problemas. Ahora decidimos analizarlo con más detalle y realizar una pequeña investigación: qué tan "sesgados" o predecibles son los RNG y cuál elegir. Además, más de una vez he escuchado opiniones contradictorias sobre su "honestidad": intentemos averiguar cómo se comparan los resultados reales con los declarados.

Programa educativo breve o RNG es en realidad RNG

Si ya está familiarizado con los generadores de números aleatorios, puede pasar inmediatamente a la sección "Pruebas".

Los números aleatorios (RN) son una secuencia de números generada mediante algún proceso aleatorio (caótico), una fuente de entropía. Es decir, se trata de una secuencia cuyos elementos no están interconectados por ninguna ley matemática: no tienen ninguna relación de causa y efecto.

Lo que crea el número aleatorio se llama generador de números aleatorios (RNG). Parecería que todo es elemental, pero si pasamos de la teoría a la práctica, en realidad no es tan sencillo implementar un algoritmo de software para generar dicha secuencia.

La razón radica en la ausencia de ese mismo caos en la electrónica de consumo moderna. Sin él, los números aleatorios dejan de serlo y su generador se convierte en una función ordinaria de argumentos claramente definidos. Para varias especialidades en el campo de las tecnologías de la información, este es un problema grave (por ejemplo, la criptografía), pero para otras existe una solución completamente aceptable.

Es necesario escribir un algoritmo que devuelva, aunque no números verdaderamente aleatorios, pero lo más cerca posible de ellos: los llamados números pseudoaleatorios (PRN). El algoritmo en este caso se llama generador de números pseudoaleatorios (PRNG).

Hay varias opciones para crear un PRNG, pero las siguientes serán relevantes para todos:

  1. La necesidad de una inicialización preliminar.

    El PRNG no tiene fuente de entropía, por lo que se le debe dar un estado inicial antes de su uso. Se especifica como un número (o vector) y se llama semilla (semilla aleatoria). A menudo, se utiliza como semilla el contador del reloj del procesador o el equivalente numérico de la hora del sistema.

  2. Reproducibilidad de secuencia.

    El PRNG es completamente determinista, por lo que la semilla especificada durante la inicialización determina de forma única toda la secuencia futura de números. Esto significa que un PRNG separado inicializado con la misma semilla (en diferentes momentos, en diferentes programas, en diferentes dispositivos) generará la misma secuencia.

También es necesario conocer la distribución de probabilidad que caracteriza al PRNG: qué números generará y con qué probabilidad. La mayoría de las veces se trata de una distribución normal o una distribución uniforme.
BlessRNG o verificar la equidad del RNG
Distribución normal (izquierda) y distribución uniforme (derecha)

Digamos que tenemos un dado justo con 24 caras. Si lo lanzas, la probabilidad de obtener uno será igual a 1/24 (la misma que la probabilidad de obtener cualquier otro número). Si realiza muchos lanzamientos y registra los resultados, notará que todos los bordes caen aproximadamente con la misma frecuencia. Básicamente, este dado puede considerarse un RNG con una distribución uniforme.

¿Qué pasa si lanzas 10 de estos dados a la vez y cuentas el total de puntos? ¿Se mantendrá la uniformidad para ello? No. La mayoría de las veces, la cantidad estará cerca de 125 puntos, es decir, de algún valor medio. Y como resultado, incluso antes de realizar un lanzamiento, puede estimar aproximadamente el resultado futuro.

La razón es que existe el mayor número de combinaciones para obtener la puntuación media. Cuanto más lejos de allí, menos combinaciones y, en consecuencia, menor será la probabilidad de pérdida. Si se visualizan estos datos, se parecerán vagamente a la forma de una campana. Por lo tanto, con un poco de estiramiento, un sistema de 10 dados puede denominarse RNG con una distribución normal.

Otro ejemplo, sólo que esta vez en un avión: disparar a un objetivo. El tirador será un RNG que genera un par de números (x, y) que se muestran en el gráfico.
BlessRNG o verificar la equidad del RNG
Esté de acuerdo en que la opción de la izquierda está más cerca de la vida real: este es un RNG con una distribución normal. Pero si necesita dispersar estrellas en un cielo oscuro, entonces la opción correcta, obtenida utilizando RNG con una distribución uniforme, es más adecuada. En general, elija un generador según la tarea en cuestión.

Ahora hablemos de la entropía de la secuencia PNG. Por ejemplo, hay una secuencia que comienza 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, ...

¿Qué tan aleatorios son estos números a primera vista? Empecemos comprobando la distribución.
BlessRNG o verificar la equidad del RNG
Parece casi uniforme, pero si lees una secuencia de dos números y los interpretas como coordenadas en un plano, obtienes esto:
BlessRNG o verificar la equidad del RNG
Los patrones se vuelven claramente visibles. Y dado que los datos de la secuencia están ordenados de cierta manera (es decir, tienen baja entropía), esto puede dar lugar a ese mismo "sesgo". Como mínimo, un PRNG de este tipo no es muy adecuado para generar coordenadas en un avión.

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

Aquí todo parece ir bien, incluso en el avión:
BlessRNG o verificar la equidad del RNG
Miremos en volumen (lea tres números a la vez):
BlessRNG o verificar la equidad del RNG
Y de nuevo los patrones. Ya no es posible construir una visualización en cuatro dimensiones. Pero pueden existir patrones en esta dimensión y en otras más grandes.

En criptografía, donde se imponen los requisitos más estrictos a los PRNG, tal situación es categóricamente inaceptable. Por ello, se han desarrollado algoritmos especiales para evaluar su calidad, que no abordaremos ahora. El tema es extenso y merece un artículo aparte.

pruebas

Si no sabemos algo con certeza, ¿cómo trabajar con ello? ¿Vale la pena cruzar la calle si no sabes qué semáforo lo permite? Las consecuencias pueden ser diferentes.

Lo mismo ocurre con la notoria aleatoriedad en Unity. Es bueno que la documentación revele los detalles necesarios, pero la historia mencionada al principio del artículo ocurrió precisamente por la falta de los detalles deseados.

Y si no sabes cómo funciona la herramienta, no podrás utilizarla correctamente. En general, ha llegado el momento de comprobar y realizar un experimento para finalmente asegurarse al menos de la distribución.

La solución fue sencilla y eficaz: recopilar estadísticas, obtener datos objetivos y observar los resultados.

Tema de estudio

Hay varias formas de generar números aleatorios en Unity; probamos cinco.

  1. Sistema.Aleatorio.Siguiente(). Genera números enteros en un rango determinado de valores.
  2. Sistema.Aleatorio.NextDouble(). Genera números de doble precisión en el rango de [0; 1).
  3. UnityEngine.Random.Range(). Genera números de precisión simple (flotantes) en un rango de valores determinado.
  4. UnityEngine.Valor.aleatorio. Genera números de precisión simple (flotantes) en el rango de [0; 1).
  5. Unity.Mathematics.Random.NextFloat(). Parte de la nueva biblioteca Unity.Mathematics. Genera números de precisión simple (flotantes) en un rango de valores determinado.

Casi en todas partes de la documentación se especificó una distribución uniforme, con la excepción de UnityEngine.Random.value (donde no se especificó la distribución, pero por analogía con UnityEngine.Random.Range() también se esperaba uniforme) y Unity.Mathematics.Random .NextFloat() (donde la base es el algoritmo xorshift, lo que significa que nuevamente debe esperar una distribución uniforme).

Por defecto, los resultados esperados se tomaron como los especificados en la documentación.

Metodología

Escribimos una pequeña aplicación que generaba secuencias de números aleatorios utilizando cada uno de los métodos presentados y guardaba los resultados para su posterior procesamiento.

La longitud de cada secuencia es de 100 números.
El rango de números aleatorios es [0, 100).

Los datos se recopilaron 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
  • Android
    — 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

implementación

Tenemos varias formas diferentes de generar números aleatorios. Para cada uno de ellos, escribiremos una clase contenedora separada, que debería proporcionar:

  1. Posibilidad de configurar el rango de valores [min/max). Se configurará a través del constructor.
  2. Método que devuelve MF. Elijamos float como tipo, ya que es más general.
  3. El nombre del método de generación para marcar los resultados. Por conveniencia, devolveremos como valor el nombre completo de la clase + el nombre del método utilizado para generar el MF.

Primero, declaremos una abstracción que será representada por la interfaz IRandomGenerator:

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

        float Generate();
    }
}

Implementación de System.Random.Next()

Este método le permite establecer un rango de valores, pero devuelve números enteros, pero se necesitan puntos flotantes. Puede simplemente interpretar el número entero como un flotante, o puede expandir el rango de valores en varios órdenes de magnitud, compensándolos con cada generación del rango medio. El resultado será algo así como un punto fijo con un orden de precisión determinado. Usaremos esta opción ya que está más cerca del 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í el rango fijo de valores [0; 1). Para proyectarlo sobre el especificado en el constructor, utilizamos 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 de la clase estática UnityEngine.Random le permite establecer un rango de valores y devuelve un tipo flotante. No es necesario realizar ninguna 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

La propiedad value de la clase estática UnityEngine.Random devuelve un tipo flotante de un rango fijo de valores [0; 1). Proyectémoslo en un rango determinado de la misma manera que cuando 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()

El método NextFloat() de la clase Unity.Mathematics.Random devuelve un punto flotante de tipo float y le permite especificar un rango de valores. El único matiz es que cada instancia de Unity.Mathematics.Random deberá inicializarse con alguna semilla; de esta manera evitaremos generar 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 del controlador principal

Varias implementaciones de IRandomGenerator están listas. A continuación, debe generar secuencias y guardar el conjunto de datos resultante para su procesamiento. Para hacer esto, crearemos una escena y un pequeño script MainController en Unity, que hará todo el trabajo necesario y al mismo tiempo será responsable de la interacción con la UI.

Establezcamos el tamaño del conjunto de datos y el rango de valores de MF, y también obtengamos un método que devuelva una matriz de generadores configurados y 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)
            };
        }

        ...
    }
}

Ahora creemos un conjunto de datos. En este caso, la generación de datos se combinará con el registro de los resultados en un flujo de texto (en formato csv). Para almacenar los valores de cada IRandomGenerator, se asigna su propia columna separada y la primera línea contiene el Nombre del generador.

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 lo que queda es llamar al método GenerateCsvDataSet y guardar el resultado en un archivo, o transferir inmediatamente los datos a través de la red desde el dispositivo final al 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();
            }
        }

        ...
    }
}

Las fuentes del proyecto están en GitLab.

resultados

No ocurrió ningún milagro. Lo que esperaban fue lo que obtuvieron: en todos los casos, una distribución equitativa y sin indicios de conspiración. No veo el sentido de poner gráficos separados para las plataformas: todos muestran aproximadamente los mismos resultados.

La realidad es esta:
BlessRNG o verificar la equidad del RNG

Visualización de secuencias en un plano de los cinco métodos de generación:
BlessRNG o verificar la equidad del RNG

Y visualización en 3D. Dejaré solo el resultado de System.Random.Next() para no producir un montón de contenido idéntico.
BlessRNG o verificar la equidad del RNG

La historia contada en la introducción sobre la distribución normal de UnityEngine.Random no se repitió: o inicialmente fue errónea o algo cambió en el motor desde entonces. Pero ahora estamos seguros.

Fuente: habr.com

Añadir un comentario