BlessRNG или проверяваме ГСЧ за честност

BlessRNG или проверяваме ГСЧ за честност

В геймдевелопмента често е необходимо да се завърже нещо на произвола: Unity има свой Random за това, а паралелно му съществува System.Random. Отдавна, на един от проектите, се създаде впечатление, че и двата могат да работят различно (въпреки че трябва да имат равномерно разпределение).

Тогава не се задълбочихме в детайлите — достатъчно беше, че преминаването на System.Random коригира всички проблеми. Сега решихме да се опитаме да се разберем по-добре и да проведем малко изследване: колко „предвзети“ или предсказуеми са генераторите на случайни числа и кой да изберем. Освен това, не веднъж съм чувал противоречиви мнения за тяхната „честност“ — ще се опитаме да разберем как реалните резултати съответстват на заявените.

Кратко ликбез или ГСЧ всъщност е ГПСЧ.

Ако вече сте запознати с генераторите на случайни числа, можете веднага да преминете към раздела „Тестване“.

Случайните числа (СЧ) — това е последователност от числа, генерирана с помощта на някакъв случайно (хаотично) процес, източник на ентропия. Тоест това е последователност, елементите на която не са свързани помежду си чрез математически закон — те нямат причинно-следствена връзка.

Това, което генерира СЧ, се нарича генератор на случайни числа (ГСЧ). Изглежда елементарно, но всъщност да се реализира софтуерен алгоритъм за генериране на такава последователност не е толкова просто.

Причината е в отсъствието на споменатата хаотичност в съвременната потребителска електроника. Без нея, случайните числа спират да бъдат случайни, а техният генератор се превръща в обикновена функция от предварително определени аргументи. За редица специалности в ИТ сферата това е сериозен проблем (например, за криптографията), а за останалите има напълно приемливо решение.

Необходимо е да се напише алгоритъм, който да връща, макар и не напълно случайни числа, но максимално близки до тях — така наречените псевдослучайни числа (ПСЧ). Алгоритъмът в този случай се нарича генератор на псевдослучайни числа (ГПСЧ).

Има няколко варианта за създаване на ГПСЧ, но за всички ще бъде важно следното:

  1. Необходимост от предварителна инициализация.

    ГПСЧ е лишен от източник на ентропия, затова преди да се използва, е необходимо да се зададе начално състояние. То се определя под формата на число (или вектор) и се нарича семе (seed, random seed). Често за seed се използва брояч на тактовете на процесора или числовият еквивалент на системното време.

  2. Възпроизводимост на последователността.

    ГПСЧ е напълно детерминиран, затова зададеният при инициализацията seed точно определя цялата бъдеща последователност от числа. Това означава, че индивидуален ГПСЧ, инициализиран с еднакъв seed (в различно време, в различни програми, на различни устройства), ще генерира една и съща последователност.

Също така е необходимо да се знае характеристиката на ГПСЧ, разпределението на вероятностите - какви числа ще генерира и с каква вероятност. Най-често това е или нормално разпределение (normal distribution), или равномерно разпределение (uniform distribution).
BlessRNG или проверяваме ГСЧ за честност
Нормално разпределение (вляво) и равномерно разпределение (вдясно)

Представете си, че имаме честна игрална зара с 24 грани. Ако я хвърлим, вероятността да се падне единица е 1/24 (както и вероятността за всяко друго число). Ако извършим множество хвърляния и запишем резултатите, можем да забележим, че всички грани се падат приблизително с една и съща честота. Всъщност, тази игрална зара може да се счита за ГСЧ с равномерно разпределение.

А какво ще се случи, ако хвърлим веднага 10 такива зара и преброим общата сума на точките? Ще се запази ли равномерността? Не. Най-често сумата ще е близка до 125 точки, тоест до определено средно значение. И следователно - дори преди да хвърлим зара, можем да оценим бъдещия резултат.

Причината е, че за получаване на средната сума на точките съществува най-голям брой комбинации. Колкото по-далеч е стойността от нея, толкова по-малко са комбинациите - и съответно, по-малка вероятността за поява. Ако визуализираме тези данни, те ще наподобяват форма на камбана. Затова с някоя наложена интерпретация, системата от 10 зара може да бъде наречена ГСЧ с нормално разпределение.

Още един пример, но в равнината - стрелба по мишена. Стрелецът ще бъде ГСЧ, генериращ двойка числа (x, y), която се изобразява на графика.
BlessRNG или проверяваме ГСЧ за честност
Съгласете се, че вариантът отляво е по-близък до реалния живот — това е ГСЧ с нормално разпределение. Но ако трябва да разпръснете звездите в тъмното небе, то десният вариант, получен с помощта на ГСЧ с равномерно разпределение, би бил по-подходящ. Общо взето, изберете генератор в зависимост от поставената задача.

Сега да поговорим за ентропията на последователността от ПСЧ. Например, имаме последователност, която започва така:

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

Насколько случайни изглеждат тези числа на пръв поглед? Нека започнем с проверка на разпределението.
BlessRNG или проверяваме ГСЧ за честност
Изглежда като близко до равномерно, но ако прочетете последователността по две числа и ги интерпретирате като координати на равнината, получавате това:
BlessRNG или проверяваме ГСЧ за честност
Очевидно се проявяват определени шаблони. И тъй като данните в последователността са подредени по определен начин (тоест притежават ниска ентропия), това може да предизвика въпросната „пристрастност“. Най-малкото, такъв ГПСЧ не е много подходящ за генериране на координати на равнината.

Друга последователност:

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

Изглежда, че тук всичко е наред дори и на равнината:
BlessRNG или проверяваме ГСЧ за честност
Да разгледаме в обем (четем по три числа):
BlessRNG или проверяваме ГСЧ за честност
И отново шаблони. Не е възможно да се построи визуализация в четириизмерно пространство. Но шаблоните могат да съществуват и на това измерение, и на по-големи.

В същата криптография, където ГПСЧ подлежат на най-строги изисквания, подобна ситуация е категорично неприемлива. Затова за оценка на тяхното качество са разработени специални алгоритми, до които в момента няма да се докосваме. Темата е обширна и заслужава отделна статия.

Тестиране

Ако не знаем нещо със сигурност, как да работим с него? Струва ли си да преминеш пътя, ако не знаеш кой сигнал на светофара го разрешава? Последствията могат да бъдат различни.

Същото важи и за прословутия рандом в Unity. Добре е, ако документацията разкрива необходимите подробности, но споменатата в началото на статията история се е случила именно заради липсата на желаната конкретика.

Ако не знаеш как работи инструментът, няма да можеш да го приложиш правилно. В общи линии, дойде време да проверим и да направим експеримент, за да сме напълно сигурни, поне що се отнася до разпределението.

Решението беше просто и ефективно — да съберем статистика, да получим обективни данни и да погледнем резултатите.

Предмет на изследването

В Unity има няколко начина за генериране на случайни числа — ние тествахме пет.

  1. System.Random.Next(). Генерира цели числа (integer) в зададен диапазон от стойности.
  2. System.Random.NextDouble(). Генерира числа с двойна точност (double) в диапазон от [0; 1).
  3. UnityEngine.Random.Range(). Генерира числа с единична точност (float) в зададен диапазон от стойности.
  4. UnityEngine.Random.value. Генерира числа с единична точност (float) в диапазон от [0; 1).
  5. Unity.Mathematics.Random.NextFloat(). Част от новата библиотека Unity.Mathematics. Генерира числа с единична точност (float) в зададен диапазон от стойности.

Почти навсякъде в документацията беше посочено равномерно разпределение, с изключение на UnityEngine.Random.value (където разпределението не е посочено, но по аналогия с UnityEngine.Random.Range() също се очакваше да е равномерно) и Unity.Mathematics.Random.NextFloat() (където в основата лежи алгоритъмът xorshift, а значи отново трябва да чакаме равномерно разпределение).

По подразбиране за очаквани резултати бяха взети тези, които са посочени в документацията.

Методика

Написахме малко приложение, което генерираше последователности от случайни числа с всеки от представените методи и запазваше резултатите за по-нататъшна обработка.

Дължината на всяка последователност — 100 000 числа.
Диапазон стойности на случайните числа — [0, 100).

Данните бяха събирани от няколко целеви платформи:

  • Windows
    — Unity v2018.3.14f1, Режим на редактора, Mono, .NET Standard 2.0
  • macOS
    — Unity v2018.3.14f1, Режим на редактора, Mono, .NET Standard 2.0
    — Unity v5.6.4p4, Режим на редактора, Mono, .NET Standard 2.0
  • Android
    — Unity v2018.3.14f1, компилация за устройство, Mono, .NET Standard 2.0
  • iOS
    — Unity v2018.3.14f1, компилация за устройство, il2cpp, .NET Standard 2.0

Реализация

Имаме няколко различни начина за генериране на случайни числа. За всеки от тях ще напишем отделен клас-обвивка, който трябва да предоставя:

  1. Възможност за задаване на диапазон от стойности [min/max). Ще се задава чрез конструктора.
  2. Метод, който връща СЧ. За тип ще изберем float, като по-общ.
  3. Наименование на метода на генериране за маркиране на резултатите. За удобство като стойност ще връщаме пълното име на класа + името на метода, използван за генериране на СЧ.

Първо ще обявим абстракция, която ще бъде представена чрез интерфейса IRandomGenerator:

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

        float Generate();
    }
}

Имплементация на System.Random.Next()

Този метод позволява да зададете диапазон от стойности, но връща цели числа (integer), а са нужни float. Можете просто да интерпретирате integer като float или можете да разширите диапазона от стойности с няколко реда, компенсирайки ги при всяка генерация на СЧ. Ще получим нещо подобно на фиксировани числа с определена точност. Ще използваме този вариант, тъй като е по-близо до истинската float стойност.

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

Тук имаме фиксирован диапазон от стойности [0; 1). За да го проектираме върху зададения в конструктора, използваме проста аритметика: 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;
    }
}

Имплементация на UnityEngine.Random.Range()

Този метод на статичния клас UnityEngine.Random позволява да зададете диапазон от стойности и връща СЧ от тип float. Няма да се налага да правите допълнителни преобразувания.

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

Свойството value на статичния клас UnityEngine.Random връща СЧ от тип float от фиксирования диапазон от стойности [0; 1). Ще го проектираме върху зададения диапазон по същия начин, както при имплементацията на 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()

Методът NextFloat() на класа Unity.Mathematics.Random връща стойност от тип float и позволява да зададем диапазон от стойности. Единственият нюанс е, че всеки екземпляр на Unity.Mathematics.Random трябва да бъде инициализиран с определен seed, за да избегнем генерирането на повтарящи се последователности.

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

Имплементация на MainController

Подготовени са няколко реализации на IRandomGenerator. Следва да генерираме последователности и да запазим получените данни за обработка. За целта ще създадем сцена в Unity и кратък скрипт MainController, който ще извърши цялата необходима работа и ще отговаря за взаимодействието с потребителския интерфейс.

Ще зададем размер на набора от данни и диапазон от стойности за СЧ, а също така ще добавим метод, който връща масив от настроени и готови за работа генератори.

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

        ...
    }
}

А сега генерираме набора от данни. В този случай генерирането на данни ще бъде комбинирано с записването на резултатите в текстов стрийм (в формат csv). За съхранение на стойностите от всеки IRandomGenerator е предвидена отделна колона, а първият ред съдържа името на генератора.

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

            // пишем заголовок
            for (int j = 0; j <= lastIdx; j++)
            {
                writer.Write(generators[j].Name);
                if (j != lastIdx)
                    writer.Write(separator);
            }
            writer.WriteLine();

            // записываем данные
            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();
            }
        }

        ...
    }
}

Трябва да се извика методът GenerateCsvDataSet и да се запази резултатът във файл или директно да се предаде данните по мрежата от крайното устройство на приемника. сървър.

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

        ...
    }
}

Изходните файлове на проекта се намират на GitLab.

Резултати

Чудо не се е случило. Това, което очаквахме, това и получихме — в всички случаи равномерно разпределение без намек за заговори. Не виждам смисъл да прилагам отделни графики по платформите — те всички показват приблизително една и съща информация.

Реалността е такава:
BlessRNG или проверяваме ГСЧ за честност

Визуализация на последователностите на равнината от всички пет начина на генериране:
BlessRNG или проверяваме ГСЧ за честност

И визуализация в 3D. Ще оставя само резултата от System.Random.Next(), за да не създавам излишно съдържание.
BlessRNG или проверяваме ГСЧ за честност

Историята, разказана във въведението за нормалното разпределение на UnityEngine.Random, не се повтори: или тя първоначално е била погрешна, или нещо се е променило в движението от тогава. Но сега сме уверени.

Източник: habr.com

Купете надежден хостинг за сайтове с защита от DDoS, VPS VDS сървъри 🔥 Купете надежден хостинг за сайтове с защита от DDoS, VPS VDS сървъри | ProHoster