BlessRNG ou testando a integridade do RNG

BlessRNG ou testando a integridade do RNG

No desenvolvimento de jogos, muitas vezes é necessário amarrar algo ao acaso: no Unity, existe seu próprio Random, e junto com ele, existe o System.Random. Há muito tempo, em um dos projetos, ficou a impressão de que ambos poderiam funcionar de forma diferente (embora devessem ter uma distribuição uniforme).

Naquela época, não nos aprofundamos nos detalhes — bastou que a transição para o System.Random corrigisse todos os problemas. Agora, decidimos entender melhor e realizar uma pequena pesquisa: o quanto os geradores de números aleatórios são "tendenciosos" ou previsíveis, e qual escolher. Especialmente porque já ouvi opiniões contraditórias sobre sua "honestidade" — vamos tentar entender como os resultados reais se comparam com os alegados.

Um breve curso ou o que é realmente um gerador de números aleatórios

Se você já está familiarizado com geradores de números aleatórios, pode ir diretamente para a seção "Testes".

Números aleatórios (NA) são uma sequência de números gerados através de algum processo aleatório (caótico), uma fonte de entropia. Ou seja, é uma sequência na qual os elementos não estão relacionados entre si por qualquer lei matemática — eles não possuem uma relação de causa e efeito.

O que cria números aleatórios é chamado de gerador de números aleatórios (GNA). À primeira vista, tudo parece simples, mas se passarmos da teoria para a prática, na verdade implementar um algoritmo de programação que gere tal sequência não é tão fácil.

A razão está na ausência de verdadeiro caos na eletrônica de consumo moderna. Sem isso, os números aleatórios deixam de ser aleatórios, e seu gerador se transforma em uma função normal com argumentos claramente definidos. Para uma série de especializações na área de TI, isso representa um problema sério (por exemplo, para a criptografia), enquanto para os demais há uma solução bastante aceitável.

É necessário escrever um algoritmo que retornaria, se não números verdadeiramente aleatórios, mas o mais próximo possível deles — os chamados números pseudoaleatórios (NPA). O algoritmo, nesse caso, é chamado de gerador de números pseudoaleatórios (GNPA).

Existem várias opções para criar um GNPA, mas para todas será relevante o seguinte:

  1. A necessidade de inicialização prévia.

    O gerador de números pseudoaleatórios (GPN) carece de uma fonte de entropia, portanto, antes de ser utilizado, é necessário definir seu estado inicial. Este estado é representado por um número (ou vetor) e é chamado de semente (seed, semente aleatória). Muitas vezes, um contador de ciclos do processador ou um equivalente numérico do tempo do sistema é utilizado como semente.

  2. Reproduzibilidade da sequência.

    O GPN é completamente determinístico, logo, a semente definida na inicialização determina de forma única toda a sequência futura de números. Isso significa que um GPN isolado, inicializado com a mesma semente (em momentos diferentes, em programas diferentes, em dispositivos diferentes) gerará a mesma sequência.

Também é necessário conhecer a distribuição de probabilidades caracterizando o GPN — quais números ele gerará e com qual probabilidade. Na maioria das vezes, trata-se de uma distribuição normal ou uniforme.
BlessRNG ou testando a integridade do RNG
Distribuição normal (à esquerda) e distribuição uniforme (à direita)

Suponha que temos um dado honesto com 24 faces. Se ele for lançado, a probabilidade de sair o número um será de 1/24 (assim como a probabilidade de sair qualquer outro número). Se realizarmos uma série de lançamentos e registrarmos os resultados, podemos notar que todas as faces saem com aproximadamente a mesma frequência. Essencialmente, esse dado pode ser considerado um GPN com distribuição uniforme.

E se lançássemos imediatamente 10 desses dados e contássemos a soma total dos pontos? A uniformidade se manteria? Não. Na maioria das vezes, a soma estará próxima de 125 pontos, ou seja, a um certo valor médio. Como consequência, antes mesmo de lançar, podemos estimar aproximadamente o resultado futuro.

A razão é que para se obter a soma média dos pontos existem o maior número de combinações. Quanto mais distante dela, menos combinações — e, consequentemente, menor a probabilidade de ocorrência. Se esses dados forem visualizados, eles se assemelharão de forma remota à forma de um sino. Portanto, com alguma imprecisão, o sistema de 10 dados pode ser chamado de GPN com distribuição normal.

Mais um exemplo, mas agora no plano — disparo em um alvo. O atirador será um GPN, gerando um par de números (x, y), que é plotado em um gráfico.
BlessRNG ou testando a integridade do RNG
Concorda que a opção à esquerda é mais próxima da realidade — é um gerador de números aleatórios com distribuição normal. Mas se você precisa espalhar estrelas no céu escuro, a opção à direita, obtida com um gerador de números aleatórios de distribuição uniforme, é mais adequada. Em geral, escolha o gerador dependendo da tarefa em questão.

Agora vamos falar sobre a entropia da sequência de números aleatórios. 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 esses números parecem à primeira vista? Vamos começar verificando a distribuição.
BlessRNG ou testando a integridade do RNG
Parece próximo do uniforme, mas se lermos a sequência de dois números e os interpretarmos como coordenadas em um plano, teremos isto:
BlessRNG ou testando a integridade do RNG
Padrões tornam-se claramente visíveis. E já que os dados na sequência estão organizados de uma maneira específica (ou seja, possuem baixa entropia), isso pode gerar o chamado "viés". No mínimo, esse gerador de números aleatórios não é muito apropriado para gerar coordenadas no 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, …

Parece que tudo está bem até mesmo no plano:
BlessRNG ou testando a integridade do RNG
Vamos olhar em três dimensões (lendo três números):
BlessRNG ou testando a integridade do RNG
E mais uma vez, padrões aparecem. Não será possível construir uma visualização em quatro dimensões. Mas padrões podem existir nessa dimensão e em dimensões maiores.

Na mesma criptografia, onde os geradores de números aleatórios são submetidos às mais rigorosas exigências, tal situação é categoricamente inadmissível. Portanto, algoritmos específicos foram desenvolvidos para avaliar sua qualidade, que não vamos abordar agora. O tema é vasto e poderia ocupar um artigo separado.

Testes

Se não temos certeza de algo, como devemos agir? Vale a pena atravessar a rua se você não sabe qual sinal de trânsito permite isso? As consequências podem variar.

O mesmo se aplica ao famoso random no Unity. É bom se a documentação revelar os detalhes necessários, mas a história mencionada no início do artigo aconteceu justamente por causa da falta de especificidade desejada.

E sem saber como a ferramenta funciona, você não poderá aplicá-la corretamente. Em geral, chegou a hora de verificar e realizar um experimento para ter certeza, pelo menos em relação à distribuição.

A solução era simples e eficaz — coletar estatísticas, obter dados objetivos e observar os resultados.

Objeto de pesquisa

No Unity, existem várias maneiras de gerar números aleatórios — testamos cinco.

  1. System.Random.Next(). Gera números inteiros no intervalo de valores especificado.
  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 simples no intervalo de valores especificado.
  4. UnityEngine.Random.value. Gera números de precisão simples no intervalo de [0; 1).
  5. Unity.Mathematics.Random.NextFloat(). Parte da nova biblioteca Unity.Mathematics. Gera números de precisão simples no intervalo de valores especificado.

Praticamente todos os documentos mencionavam a distribuição uniforme, exceto o UnityEngine.Random.value (onde a distribuição não é especificada, mas por analogia com o UnityEngine.Random.Range() também se esperava uma distribuição uniforme) e o Unity.Mathematics.Random.NextFloat() (onde se baseia no algoritmo xorshift, então novamente é preciso esperar uma distribuição uniforme).

Por padrão, os resultados esperados eram aqueles indicados na documentação.

Metodologia

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 é 100.000 números.
O intervalo de valores dos números aleatórios é [0, 100).

Os dados foram coletados de várias plataformas-alvo:

  • 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, build para dispositivo, Mono, .NET Standard 2.0
  • iOS
    — Unity v2018.3.14f1, build para dispositivo, il2cpp, .NET Standard 2.0

Implementação

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

  1. A capacidade de definir o intervalo de valores [min/max). Isso será estabelecido através do construtor.
  2. Um método que retorna um número aleatório. Escolheremos float como um tipo mais geral.
  3. O nome do método de geração para rotular os resultados. Para conveniência, como valor, retornaremos o nome completo da classe + o nome do método utilizado para gerar o número aleatório.

Primeiro, vamos declarar uma abstração representada pela interface IRandomGenerator:

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

        float Generate();
    }
}

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

Este método permite especificar um intervalo de valores, mas retorna números inteiros (integers), enquanto precisamos de floats. Podemos simplesmente interpretar o inteiro como float, ou podemos expandir o intervalo de valores em várias ordens, compensando-os a cada geração de números. O resultado será algo como ponto fixo com precisão ordenada. Vamos usar esta opção, pois ela se aproxima mais do valor float 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 no intervalo dado no construtor, usamos uma 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 retorna um número tipo float. Não será necessário fazer nenhuma conversã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 número tipo float de um intervalo fixo de valores [0; 1). Vamos projetá-lo no intervalo especificado da mesma forma que na implementação de 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 do Unity.Mathematics.Random.NextFloat()

O método NextFloat() da classe Unity.Mathematics.Random retorna um valor do tipo float e permite definir um intervalo de valores. A única observação é que cada instância de Unity.Mathematics.Random deve ser inicializada com uma semente, evitando assim 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 MainController

Várias implementações de IRandomGenerator estão prontas. O próximo passo é gerar sequências e salvar o conjunto de dados resultante para processamento. Para isso, vamos criar uma cena em Unity e um pequeno script MainController, que realizará todo o trabalho necessário e também cuidará da interação com a interface do usuário.

Vamos definir o tamanho do conjunto de dados e o intervalo de valores, e também teremos um método que retorna um array de geradores configurados e prontos para uso.

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 formar o conjunto de dados. Neste caso, a geração de dados será combinada com a gravação dos resultados em um stream de texto (no formato csv). Cada IRandomGenerator terá sua própria coluna, e a primeira linha conterá 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;

            // escreve o cabeçalho
            for (int j = 0; j <= lastIdx; j++)
            {
                writer.Write(generators[j].Name);
                if (j != lastIdx)
                    writer.Write(separator);
            }
            writer.WriteLine();

            // escreve os dados
            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();
            }
        }

        ...
    }
}

Agora, é necessário chamar o método GenerateCsvDataSet e salvar o resultado em um arquivo, ou passar os dados pela rede diretamente do dispositivo de origem para o receptor. servidor.

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

        ...
    }
}

Os fontes do projeto estão em GitLab.

Resultados

Não houve milagres. O que se esperava, foi exatamente o que se obteve — em todos os casos, uma distribuição uniforme sem indícios de conspirações. Não vejo sentido em anexar gráficos separados por plataforma — todos mostram resultados aproximadamente iguais.

A realidade é que:
BlessRNG ou testando a integridade do RNG

Visualização das sequências no plano de todas as cinco formas de geração:
BlessRNG ou testando a integridade do RNG

E a visualização em 3D. Vou mostrar apenas o resultado de System.Random.Next(), para não gerar uma quantidade excessiva de conteúdo repetido.
BlessRNG ou testando a integridade do RNG

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

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster