BlessRNG oder Überprüfung des RNG auf Fairness

BlessRNG oder Überprüfung des RNG auf Fairness

In der Spieleentwicklung muss man oft etwas mit Zufälligkeit verknüpfen: Unity hat dafür sein eigenes Random, und parallel dazu gibt es System.Random. Bei einem der Projekte hatte ich einmal den Eindruck, dass beide unterschiedlich funktionieren könnten (obwohl sie eine gleichmäßige Verteilung haben sollten).

Dann gingen sie nicht auf Details ein – es reichte aus, dass der Übergang zu System.Random alle Probleme korrigierte. Jetzt haben wir beschlossen, uns genauer damit zu befassen und ein wenig zu recherchieren: Wie „voreingenommen“ oder vorhersehbar RNGs sind und welches wir wählen sollten. Darüber hinaus habe ich mehr als einmal widersprüchliche Meinungen über ihre „Ehrlichkeit“ gehört – versuchen wir herauszufinden, wie sich die tatsächlichen Ergebnisse mit den erklärten vergleichen lassen.

Ein kurzes Bildungsprogramm oder RNG ist eigentlich RNG

Wenn Sie bereits mit Zufallszahlengeneratoren vertraut sind, können Sie direkt mit dem Abschnitt „Testen“ fortfahren.

Zufallszahlen (RN) sind eine Folge von Zahlen, die durch einen zufälligen (chaotischen) Prozess, eine Entropiequelle, erzeugt werden. Das heißt, es handelt sich um eine Folge, deren Elemente durch kein mathematisches Gesetz miteinander verbunden sind – sie haben keine Ursache-Wirkungs-Beziehung.

Was die Zufallszahl erzeugt, wird als Zufallszahlengenerator (RNG) bezeichnet. Es scheint, dass alles elementar ist, aber wenn wir von der Theorie zur Praxis übergehen, ist es tatsächlich nicht so einfach, einen Softwarealgorithmus zum Generieren einer solchen Sequenz zu implementieren.

Der Grund liegt darin, dass in der modernen Unterhaltungselektronik dieses Chaos nicht herrscht. Ohne sie sind Zufallszahlen nicht mehr zufällig und ihr Generator wird zu einer gewöhnlichen Funktion offensichtlich definierter Argumente. Für eine Reihe von Fachgebieten im IT-Bereich stellt dies ein ernstes Problem dar (z. B. Kryptographie), für andere gibt es jedoch eine völlig akzeptable Lösung.

Es ist notwendig, einen Algorithmus zu schreiben, der zwar keine wirklichen Zufallszahlen, aber so nah wie möglich an ihnen zurückgibt – die sogenannten Pseudozufallszahlen (PRN). Der Algorithmus wird in diesem Fall Pseudozufallszahlengenerator (PRNG) genannt.

Es gibt mehrere Möglichkeiten, ein PRNG zu erstellen, die folgenden sind jedoch für alle relevant:

  1. Die Notwendigkeit einer vorläufigen Initialisierung.

    Der PRNG hat keine Entropiequelle und muss daher vor der Verwendung in einen Anfangszustand versetzt werden. Es wird als Zahl (oder Vektor) angegeben und als Startwert (zufälliger Startwert) bezeichnet. Als Startwert wird häufig der Prozessortaktzähler oder das numerische Äquivalent der Systemzeit verwendet.

  2. Reproduzierbarkeit der Sequenz.

    Der PRNG ist vollständig deterministisch, sodass der bei der Initialisierung angegebene Startwert eindeutig die gesamte zukünftige Zahlenfolge bestimmt. Dies bedeutet, dass ein separater PRNG, der mit demselben Startwert initialisiert wird (zu unterschiedlichen Zeiten, in unterschiedlichen Programmen, auf unterschiedlichen Geräten), dieselbe Sequenz generiert.

Sie müssen auch die Wahrscheinlichkeitsverteilung kennen, die das PRNG charakterisiert – welche Zahlen es mit welcher Wahrscheinlichkeit generieren wird. Meistens handelt es sich dabei entweder um eine Normalverteilung oder eine Gleichverteilung.
BlessRNG oder Überprüfung des RNG auf Fairness
Normalverteilung (links) und Gleichverteilung (rechts)

Nehmen wir an, wir haben einen fairen Würfel mit 24 Seiten. Wenn Sie es werfen, beträgt die Wahrscheinlichkeit, eine Eins zu erhalten, 1/24 (dasselbe wie die Wahrscheinlichkeit, eine andere Zahl zu erhalten). Wenn Sie viele Würfe durchführen und die Ergebnisse aufzeichnen, werden Sie feststellen, dass alle Kanten ungefähr mit der gleichen Häufigkeit ausfallen. Im Wesentlichen kann dieser Würfel als RNG mit gleichmäßiger Verteilung betrachtet werden.

Was wäre, wenn Sie 10 dieser Würfel auf einmal werfen und die Gesamtpunktzahl zählen? Wird dabei die Einheitlichkeit gewahrt bleiben? Nein. Meistens liegt der Betrag nahe bei 125 Punkten, also bei einem Durchschnittswert. Und so können Sie bereits vor dem Wurf das zukünftige Ergebnis grob abschätzen.

Der Grund dafür ist, dass es die größte Anzahl an Kombinationen gibt, um die Durchschnittspunktzahl zu erhalten. Je weiter davon entfernt, desto weniger Kombinationen – und dementsprechend geringer die Wahrscheinlichkeit eines Verlustes. Wenn diese Daten visualisiert werden, ähneln sie vage der Form einer Glocke. Daher kann man ein System aus 10 Würfeln mit etwas Dehnung als RNG mit Normalverteilung bezeichnen.

Ein weiteres Beispiel, nur dieses Mal im Flugzeug – das Schießen auf ein Ziel. Der Schütze ist ein RNG, der ein Zahlenpaar (x, y) generiert, das in der Grafik angezeigt wird.
BlessRNG oder Überprüfung des RNG auf Fairness
Stimmen Sie zu, dass die Option auf der linken Seite näher am wirklichen Leben ist – dies ist ein RNG mit einer Normalverteilung. Wenn Sie jedoch Sterne an einem dunklen Himmel streuen müssen, ist die richtige Option, die mit RNG mit gleichmäßiger Verteilung erzielt wird, besser geeignet. Wählen Sie im Allgemeinen einen Generator je nach der jeweiligen Aufgabe aus.

Lassen Sie uns nun über die Entropie der PNG-Sequenz sprechen. Es gibt zum Beispiel eine Sequenz, die so beginnt:

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

Wie zufällig sind diese Zahlen auf den ersten Blick? Beginnen wir mit der Überprüfung der Verteilung.
BlessRNG oder Überprüfung des RNG auf Fairness
Es sieht nahezu einheitlich aus, aber wenn Sie eine Folge zweier Zahlen lesen und sie als Koordinaten auf einer Ebene interpretieren, erhalten Sie Folgendes:
BlessRNG oder Überprüfung des RNG auf Fairness
Muster werden deutlich sichtbar. Und da die Daten in der Sequenz auf eine bestimmte Weise geordnet sind (das heißt, sie haben eine geringe Entropie), kann dies genau zu dieser „Verzerrung“ führen. Zumindest ist ein solches PRNG nicht sehr geeignet, um Koordinaten auf einer Ebene zu generieren.

Eine weitere Sequenz:

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

Auch im Flugzeug scheint hier alles in Ordnung zu sein:
BlessRNG oder Überprüfung des RNG auf Fairness
Schauen wir uns den Band an (lesen Sie jeweils drei Zahlen):
BlessRNG oder Überprüfung des RNG auf Fairness
Und wieder die Muster. Es ist nicht mehr möglich, eine Visualisierung in vier Dimensionen aufzubauen. Aber Muster können in dieser Dimension und in größeren Dimensionen existieren.

In der Kryptographie, wo an PRNGs die höchsten Anforderungen gestellt werden, ist eine solche Situation grundsätzlich inakzeptabel. Daher wurden spezielle Algorithmen zur Beurteilung ihrer Qualität entwickelt, auf die wir jetzt nicht näher eingehen. Das Thema ist umfangreich und verdient einen eigenen Artikel.

Testing

Wenn wir etwas nicht sicher wissen, wie gehen wir dann damit um? Lohnt es sich, die Straße zu überqueren, wenn man nicht weiß, an welcher Ampel das erlaubt ist? Die Folgen können unterschiedlich sein.

Das Gleiche gilt für die berüchtigte Zufälligkeit in Unity. Es ist gut, wenn die Dokumentation die notwendigen Details preisgibt, aber die am Anfang des Artikels erwähnte Geschichte geschah gerade aufgrund des Fehlens der gewünschten Einzelheiten.

Und wenn Sie nicht wissen, wie das Tool funktioniert, können Sie es nicht richtig verwenden. Im Allgemeinen ist es an der Zeit, ein Experiment zu überprüfen und durchzuführen, um sich zumindest über die Verteilung zu vergewissern.

Die Lösung war einfach und effektiv: Statistiken sammeln, objektive Daten erhalten und die Ergebnisse betrachten.

Gegenstand der Studie

Es gibt mehrere Möglichkeiten, Zufallszahlen in Unity zu generieren – wir haben fünf getestet.

  1. System.Random.Next(). Erzeugt Ganzzahlen in einem bestimmten Wertebereich.
  2. System.Random.NextDouble(). Erzeugt Zahlen mit doppelter Genauigkeit im Bereich von [0; 1).
  3. UnityEngine.Random.Range(). Erzeugt Zahlen mit einfacher Genauigkeit (Floats) in einem bestimmten Wertebereich.
  4. UnityEngine.Random.value. Erzeugt Zahlen mit einfacher Genauigkeit (Floats) im Bereich von [0; 1).
  5. Unity.Mathematics.Random.NextFloat(). Teil der neuen Unity.Mathematics-Bibliothek. Erzeugt Zahlen mit einfacher Genauigkeit (Floats) in einem bestimmten Wertebereich.

Fast überall in der Dokumentation wurde eine einheitliche Verteilung angegeben, mit Ausnahme von UnityEngine.Random.value (wo die Verteilung nicht angegeben wurde, aber analog zu UnityEngine.Random.Range() auch eine einheitliche Verteilung erwartet wurde) und Unity.Mathematics.Random .NextFloat() (wobei die Basis der xorshift-Algorithmus ist, was bedeutet, dass Sie erneut auf eine gleichmäßige Verteilung warten müssen).

Standardmäßig wurden die erwarteten Ergebnisse so übernommen, wie sie in der Dokumentation angegeben sind.

Methodik

Wir haben eine kleine Anwendung geschrieben, die mit jeder der vorgestellten Methoden Folgen von Zufallszahlen generiert und die Ergebnisse zur weiteren Verarbeitung gespeichert hat.

Die Länge jeder Sequenz beträgt 100 Zahlen.
Der Bereich der Zufallszahlen beträgt [0, 100).

Daten wurden von mehreren Zielplattformen gesammelt:

  • Windows
    – Unity v2018.3.14f1, Editor-Modus, Mono, .NET Standard 2.0
  • macOS
    – Unity v2018.3.14f1, Editor-Modus, Mono, .NET Standard 2.0
    — Unity v5.6.4p4, Editor-Modus, Mono, .NET Standard 2.0
  • Android
    – Unity v2018.3.14f1, Build pro Gerät, Mono, .NET Standard 2.0
  • iOS
    – Unity v2018.3.14f1, Build pro Gerät, il2cpp, .NET Standard 2.0

Implementierung

Wir haben verschiedene Möglichkeiten, Zufallszahlen zu generieren. Für jeden von ihnen werden wir eine separate Wrapper-Klasse schreiben, die Folgendes bereitstellen sollte:

  1. Möglichkeit, den Wertebereich [min/max] einzustellen. Wird über den Konstruktor festgelegt.
  2. Methode, die MF zurückgibt. Wählen wir als Typ „float“, da dieser allgemeiner ist.
  3. Der Name der Generierungsmethode zum Markieren der Ergebnisse. Der Einfachheit halber geben wir als Wert den vollständigen Namen der Klasse + den Namen der Methode zurück, die zum Generieren des MF verwendet wurde.

Lassen Sie uns zunächst eine Abstraktion deklarieren, die durch die IRandomGenerator-Schnittstelle dargestellt wird:

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

        float Generate();
    }
}

Implementierung von System.Random.Next()

Mit dieser Methode können Sie einen Wertebereich festlegen, sie gibt jedoch Ganzzahlen zurück, es sind jedoch Gleitkommazahlen erforderlich. Sie können die Ganzzahl einfach als Gleitkommazahl interpretieren oder den Wertebereich um mehrere Größenordnungen erweitern und diese mit jeder Generation des Mittelwerts kompensieren. Das Ergebnis wird so etwas wie ein Fixpunkt mit einer bestimmten Genauigkeitsordnung sein. Wir werden diese Option verwenden, da sie näher am tatsächlichen Float-Wert liegt.

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

Implementierung von System.Random.NextDouble()

Hier ist der feste Wertebereich [0; 1). Um es auf das im Konstruktor angegebene zu projizieren, verwenden wir eine einfache Arithmetik: 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;
    }
}

Implementierung von UnityEngine.Random.Range()

Mit dieser Methode der statischen Klasse UnityEngine.Random können Sie einen Wertebereich festlegen und einen Float-Typ zurückgeben. Sie müssen keine zusätzlichen Transformationen durchführen.

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

Implementierung von UnityEngine.Random.value

Die Value-Eigenschaft der statischen Klasse UnityEngine.Random gibt einen Float-Typ aus einem festen Wertebereich zurück [0; 1). Projizieren wir es auf einen bestimmten Bereich auf die gleiche Weise wie bei der Implementierung von 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;
    }
}

Implementierung von Unity.Mathematics.Random.NextFloat()

Die NextFloat()-Methode der Unity.Mathematics.Random-Klasse gibt einen Gleitkommawert vom Typ float zurück und ermöglicht Ihnen die Angabe eines Wertebereichs. Die einzige Nuance besteht darin, dass jede Instanz von Unity.Mathematics.Random mit einem Startwert initialisiert werden muss – auf diese Weise vermeiden wir die Erzeugung sich wiederholender Sequenzen.

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

Implementierung von MainController

Mehrere Implementierungen von IRandomGenerator sind fertig. Als Nächstes müssen Sie Sequenzen generieren und den resultierenden Datensatz zur Verarbeitung speichern. Dazu erstellen wir in Unity eine Szene und ein kleines MainController-Skript, das alle notwendigen Arbeiten erledigt und gleichzeitig für die Interaktion mit der UI verantwortlich ist.

Legen wir die Größe des Datensatzes und den Bereich der MF-Werte fest und erhalten außerdem eine Methode, die ein Array konfigurierter und betriebsbereiter Generatoren zurückgibt.

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

        ...
    }
}

Jetzt erstellen wir einen Datensatz. In diesem Fall wird die Datengenerierung mit der Aufzeichnung der Ergebnisse in einem Textstream (im CSV-Format) kombiniert. Um die Werte jedes IRandomGenerators zu speichern, wird eine eigene Spalte zugewiesen, und die erste Zeile enthält den Namen des Generators.

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

        ...
    }
}

Es bleibt nur noch, die GenerateCsvDataSet-Methode aufzurufen und das Ergebnis in einer Datei zu speichern oder die Daten sofort über das Netzwerk vom Endgerät zum empfangenden Server zu übertragen.

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

        ...
    }
}

Die Projektquellen finden Sie unter Gitlab.

Ergebnisse

Es geschah kein Wunder. Was sie erwartet haben, ist das, was sie bekommen haben – in allen Fällen eine gleichmäßige Verteilung ohne den Anflug von Verschwörungen. Ich sehe keinen Sinn darin, separate Diagramme für Plattformen zu erstellen – sie zeigen alle ungefähr die gleichen Ergebnisse.

Die Realität ist:
BlessRNG oder Überprüfung des RNG auf Fairness

Visualisierung von Sequenzen auf einer Ebene aus allen fünf Generierungsmethoden:
BlessRNG oder Überprüfung des RNG auf Fairness

Und Visualisierung in 3D. Ich belasse nur das Ergebnis von System.Random.Next(), um nicht eine Menge identischer Inhalte zu erzeugen.
BlessRNG oder Überprüfung des RNG auf Fairness

Die in der Einleitung erzählte Geschichte über die Normalverteilung von UnityEngine.Random wiederholte sich nicht: Entweder war sie zunächst fehlerhaft, oder es hat sich inzwischen etwas an der Engine geändert. Aber jetzt sind wir sicher.

Source: habr.com

Kommentar hinzufügen