BlessRNG ou verificando a justiça do RNG

BlessRNG ou verificando a justiça do RNG

No desenvolvimento de jogos, muitas vezes você precisa vincular algo à aleatoriedade: o Unity tem seu próprio Random para isso e, paralelamente a ele, existe o System.Random. Era uma vez, em um dos projetos, tive a impressão de que ambos poderiam funcionar de forma diferente (embora devessem ter uma distribuição uniforme).

Então não entraram em detalhes - bastava que a transição para System.Random corrigisse todos os problemas. Agora decidimos analisá-lo com mais detalhes e realizar uma pequena pesquisa: quão “tendenciosos” ou previsíveis são os RNGs e qual escolher. Além disso, já ouvi mais de uma vez opiniões conflitantes sobre sua “honestidade” - vamos tentar descobrir como os resultados reais se comparam aos declarados.

Programa educacional breve ou RNG é na verdade RNG

Se você já está familiarizado com geradores de números aleatórios, pode pular imediatamente para a seção “Teste”.

Números aleatórios (RN) são uma sequência de números gerados usando algum processo aleatório (caótico), uma fonte de entropia. Ou seja, trata-se de uma sequência cujos elementos não estão interligados por nenhuma lei matemática - não possuem relação de causa e efeito.

O que cria o número aleatório é chamado de gerador de números aleatórios (RNG). Parece que tudo é elementar, mas se passarmos da teoria à prática, então na verdade não é tão simples implementar um algoritmo de software para gerar tal sequência.

A razão reside na ausência do mesmo caos na electrónica de consumo moderna. Sem ele, os números aleatórios deixam de ser aleatórios e seu gerador se transforma em uma função comum de argumentos obviamente definidos. Para várias especialidades da área de TI, este é um problema sério (por exemplo, criptografia), mas para outras existe uma solução completamente aceitável.

É necessário escrever um algoritmo que retorne, embora não números verdadeiramente aleatórios, mas o mais próximo possível deles - os chamados números pseudo-aleatórios (PRN). O algoritmo neste caso é chamado de gerador de números pseudoaleatórios (PRNG).

Existem várias opções para criar um PRNG, mas as seguintes serão relevantes para todos:

  1. A necessidade de inicialização preliminar.

    O PRNG não tem fonte de entropia, portanto deve receber um estado inicial antes de ser usado. É especificado como um número (ou vetor) e é chamado de semente (semente aleatória). Freqüentemente, o contador do clock do processador ou o equivalente numérico da hora do sistema é usado como semente.

  2. Reprodutibilidade de sequência.

    O PRNG é completamente determinístico, portanto a semente especificada durante a inicialização determina exclusivamente toda a sequência futura de números. Isso significa que um PRNG separado inicializado com a mesma semente (em momentos diferentes, em programas diferentes, em dispositivos diferentes) gerará a mesma sequência.

Você também precisa conhecer a distribuição de probabilidade que caracteriza o PRNG - quais números ele gerará e com que probabilidade. Na maioria das vezes, esta é uma distribuição normal ou uma distribuição uniforme.
BlessRNG ou verificando a justiça do RNG
Distribuição normal (esquerda) e distribuição uniforme (direita)

Digamos que temos um dado justo com 24 lados. Se você lançar, a probabilidade de obter um será igual a 1/24 (o mesmo que a probabilidade de obter qualquer outro número). Se você fizer muitos lançamentos e registrar os resultados, notará que todas as arestas caem aproximadamente com a mesma frequência. Essencialmente, este dado pode ser considerado um RNG com distribuição uniforme.

E se você jogar 10 desses dados de uma vez e contar o total de pontos? A uniformidade será mantida para isso? Não. Na maioria das vezes, o valor ficará próximo de 125 pontos, ou seja, de algum valor médio. E como resultado, mesmo antes de lançar, você pode estimar aproximadamente o resultado futuro.

A razão é que há o maior número de combinações para obter a pontuação média. Quanto mais longe, menos combinações - e, consequentemente, menor a probabilidade de perda. Se esses dados forem visualizados, eles se assemelharão vagamente ao formato de um sino. Portanto, com alguma extensão, um sistema de 10 dados pode ser chamado de RNG com distribuição normal.

Outro exemplo, só que desta vez em um avião - atirar em um alvo. O atirador será um RNG que gera um par de números (x, y) que é exibido no gráfico.
BlessRNG ou verificando a justiça do RNG
Concordo que a opção à esquerda está mais próxima da vida real - este é um RNG com distribuição normal. Mas se você precisar espalhar estrelas em um céu escuro, então a opção correta, obtida usando RNG com distribuição uniforme, é mais adequada. Em geral, escolha um gerador dependendo da tarefa em questão.

Agora vamos falar sobre a entropia da sequência PNG. Por exemplo, existe uma sequência que começa assim:

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ão aleatórios são esses números à primeira vista? Vamos começar verificando a distribuição.
BlessRNG ou verificando a justiça do RNG
Parece quase uniforme, mas se você ler uma sequência de dois números e interpretá-los como coordenadas em um plano, obterá o seguinte:
BlessRNG ou verificando a justiça do RNG
Os padrões tornam-se claramente visíveis. E como os dados na sequência são ordenados de uma certa maneira (ou seja, têm baixa entropia), isso pode dar origem a esse mesmo “viés”. No mínimo, tal PRNG não é muito adequado para gerar coordenadas num plano.

Outra sequência:

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

Tudo parece estar bem aqui, mesmo no avião:
BlessRNG ou verificando a justiça do RNG
Vejamos o volume (leia três números por vez):
BlessRNG ou verificando a justiça do RNG
E novamente os padrões. Não é mais possível construir uma visualização em quatro dimensões. Mas podem existir padrões nesta dimensão e em dimensões maiores.

Na criptografia, onde os requisitos mais rigorosos são impostos aos PRNGs, tal situação é categoricamente inaceitável. Portanto, algoritmos especiais foram desenvolvidos para avaliar sua qualidade, dos quais não abordaremos agora. O tema é extenso e merece um artigo à parte.

Teste

Se não sabemos algo com certeza, como trabalhar com isso? Vale a pena atravessar a rua se você não sabe qual semáforo permite? As consequências podem ser diferentes.

O mesmo vale para a notória aleatoriedade no Unity. É bom que a documentação revele os detalhes necessários, mas a história citada no início do artigo aconteceu justamente por falta das especificidades desejadas.

E se você não sabe como a ferramenta funciona, não conseguirá utilizá-la corretamente. Em geral, chegou a hora de verificar e realizar um experimento para finalmente ter certeza pelo menos da distribuição.

A solução foi simples e eficaz – recolher estatísticas, obter dados objectivos e analisar os resultados.

Objeto de estudo

Existem várias maneiras de gerar números aleatórios no Unity – testamos cinco.

  1. System.Random.Next(). Gera números inteiros em um determinado intervalo de valores.
  2. System.Random.NextDouble(). Gera números de precisão dupla no intervalo de [0; 1).
  3. UnityEngine.Random.Range(). Gera números de precisão única (flutuantes) em um determinado intervalo de valores.
  4. UnityEngine.Random.valor. Gera números de precisão única (flutuantes) no intervalo de [0; 1).
  5. Unity.Mathematics.Random.NextFloat(). Parte da nova biblioteca Unity.Mathematics. Gera números de precisão única (flutuantes) em um determinado intervalo de valores.

Em quase todos os lugares da documentação, uma distribuição uniforme foi especificada, com exceção de UnityEngine.Random.value (onde a distribuição não foi especificada, mas por analogia com UnityEngine.Random.Range() uniforme também era esperado) e Unity.Mathematics.Random .NextFloat() (onde em A base está o algoritmo xorshift, o que significa que novamente você precisa esperar por uma distribuição uniforme).

Por padrão, os resultados esperados foram considerados conforme especificados na documentação.

Técnica

Escrevemos um pequeno aplicativo que gerava sequências de números aleatórios usando cada um dos métodos apresentados e salvava os resultados para processamento posterior.

O comprimento de cada sequência é de 100 números.
O intervalo de números aleatórios é [0, 100).

Os dados foram coletados de várias 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, compilação por dispositivo, Mono, .NET Standard 2.0
  • iOS
    — Unity v2018.3.14f1, compilação por dispositivo, il2cpp, .NET Standard 2.0

Implementação

Temos várias maneiras diferentes de gerar números aleatórios. Para cada um deles, escreveremos uma classe wrapper separada, que deverá fornecer:

  1. Possibilidade de definir a faixa de valores [min/max). Será definido através do construtor.
  2. Método que retorna MF. Vamos escolher float como tipo, pois é mais geral.
  3. O nome do método de geração para marcação dos resultados. Por conveniência, retornaremos como valor o nome completo da classe + o nome do método utilizado para gerar o MF.

Primeiramente, vamos declarar uma abstração que será representada pela interface IRandomGenerator:

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

        float Generate();
    }
}

Implementação de System.Random.Next()

Este método permite definir um intervalo de valores, mas retorna números inteiros, mas são necessários pontos flutuantes. Você pode simplesmente interpretar o inteiro como flutuante ou pode expandir o intervalo de valores em várias ordens de magnitude, compensando-os a cada geração do midrange. O resultado será algo como um ponto fixo com uma determinada ordem de precisão. Usaremos esta opção por estar mais próxima do valor flutuante 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;
    }
}

Implementação de System.Random.NextDouble()

Aqui o intervalo fixo de valores [0; 1). Para projetá-lo naquele especificado no construtor, usamos aritmética simples: 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;
    }
}

Implementação de UnityEngine.Random.Range()

Este método da classe estática UnityEngine.Random permite definir um intervalo de valores e retornar um tipo float. Você não precisa fazer nenhuma transformação 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);
    }
}

Implementação de UnityEngine.Random.value

A propriedade value da classe estática UnityEngine.Random retorna um tipo float de um intervalo fixo de valores [0; 1). Vamos projetá-lo em um determinado intervalo da mesma forma que 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;
    }
}

Implementação de Unity.Mathematics.Random.NextFloat()

O método NextFloat() da classe Unity.Mathematics.Random retorna um ponto flutuante do tipo float e permite especificar um intervalo de valores. A única ressalva é que cada instância de Unity.Mathematics.Random deverá ser inicializada com alguma semente - dessa forma evitaremos a geração de sequências 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);
    }
}

Implementação do MainController

Várias implementações do IRandomGenerator estão prontas. Em seguida, você precisa gerar sequências e salvar o conjunto de dados resultante para processamento. Para isso, criaremos uma cena e um pequeno script MainController no Unity, que fará todo o trabalho necessário e ao mesmo tempo será responsável pela interação com a UI.

Vamos definir o tamanho do conjunto de dados e o intervalo de valores de MF, e também obter um método que retorne um array de geradores configurados e prontos 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 vamos criar um conjunto de dados. Neste caso, a geração dos dados será combinada com a gravação dos resultados em um fluxo de texto (em formato csv). Para armazenar os valores de cada IRandomGenerator, sua própria coluna separada é alocada e a primeira linha contém o Nome do gerador.

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

        ...
    }
}

Resta chamar o método GenerateCsvDataSet e salvar o resultado em um arquivo ou transferir imediatamente os dados pela rede do dispositivo final para o 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 projeto estão em GitLab.

Descobertas

Nenhum milagre aconteceu. O que eles esperavam foi o que obtiveram - em todos os casos, uma distribuição uniforme, sem qualquer indício de conspiração. Não vejo sentido em colocar gráficos separados para plataformas - todos mostram aproximadamente os mesmos resultados.

A realidade é esta:
BlessRNG ou verificando a justiça do RNG

Visualização de sequências em um plano a partir de todos os cinco métodos de geração:
BlessRNG ou verificando a justiça do RNG

E visualização em 3D. Vou deixar apenas o resultado de System.Random.Next() para não produzir um monte de conteúdo idêntico.
BlessRNG ou verificando a justiça do RNG

A história contada na introdução sobre a distribuição normal do UnityEngine.Random não se repetiu: ou estava inicialmente errada ou algo mudou desde então no mecanismo. Mas agora temos certeza.

Fonte: habr.com

Adicionar um comentário