BlessRNG hoặc kiểm tra RNG cho công bằng

BlessRNG hoặc kiểm tra RNG cho công bằng

Trong quá trình phát triển trò chơi, bạn thường cần gắn kết một thứ gì đó với tính ngẫu nhiên: Unity có Random riêng cho việc này và song song với nó là System.Random. Ngày xửa ngày xưa, trong một dự án, tôi có ấn tượng rằng cả hai đều có thể hoạt động khác nhau (mặc dù chúng phải có sự phân bổ đồng đều).

Sau đó, họ không đi sâu vào chi tiết - việc chuyển đổi sang System.Random đã khắc phục được tất cả các vấn đề là đủ. Bây giờ, chúng tôi quyết định xem xét nó chi tiết hơn và tiến hành một nghiên cứu nhỏ: RNG “sai lệch” hoặc có thể dự đoán được như thế nào và nên chọn loại nào. Hơn nữa, tôi đã hơn một lần nghe thấy những ý kiến ​​​​trái chiều về sự “trung thực” của họ - hãy thử tìm hiểu xem kết quả thực tế so với kết quả đã công bố như thế nào.

Chương trình giáo dục ngắn gọn hay RNG thực ra là RNG

Nếu bạn đã quen thuộc với các trình tạo số ngẫu nhiên, thì bạn có thể chuyển ngay sang phần “Thử nghiệm”.

Số ngẫu nhiên (RN) là một chuỗi các số được tạo bằng cách sử dụng một số quy trình ngẫu nhiên (hỗn loạn), một nguồn entropy. Nghĩa là, đây là một chuỗi mà các phần tử không được kết nối với nhau bởi bất kỳ định luật toán học nào - chúng không có mối quan hệ nhân quả.

Thứ tạo ra số ngẫu nhiên được gọi là bộ tạo số ngẫu nhiên (RNG). Có vẻ như mọi thứ đều cơ bản, nhưng nếu chúng ta chuyển từ lý thuyết sang thực hành, thì trên thực tế, việc triển khai một thuật toán phần mềm để tạo ra chuỗi như vậy không đơn giản như vậy.

Lý do nằm ở chỗ không có sự hỗn loạn tương tự như vậy trong các thiết bị điện tử tiêu dùng hiện đại. Không có nó, các số ngẫu nhiên sẽ không còn ngẫu nhiên nữa và trình tạo của chúng biến thành một hàm thông thường gồm các đối số được xác định rõ ràng. Đối với một số chuyên ngành trong lĩnh vực CNTT, đây là một vấn đề nghiêm trọng (ví dụ: mật mã), nhưng đối với những chuyên ngành khác thì có một giải pháp hoàn toàn có thể chấp nhận được.

Cần phải viết một thuật toán trả về, mặc dù không phải là các số thực sự ngẫu nhiên, nhưng càng gần chúng càng tốt - cái gọi là số giả ngẫu nhiên (PRN). Thuật toán trong trường hợp này được gọi là bộ tạo số giả ngẫu nhiên (PRNG).

Có một số tùy chọn để tạo PRNG, nhưng những tùy chọn sau sẽ phù hợp với mọi người:

  1. Sự cần thiết phải khởi tạo sơ bộ.

    PRNG không có nguồn entropy nên nó phải có trạng thái ban đầu trước khi sử dụng. Nó được chỉ định dưới dạng một số (hoặc vectơ) và được gọi là hạt giống (hạt giống ngẫu nhiên). Thông thường, bộ đếm đồng hồ bộ xử lý hoặc số tương đương với thời gian hệ thống được sử dụng làm hạt giống.

  2. Khả năng tái tạo trình tự.

    PRNG hoàn toàn mang tính quyết định, do đó hạt giống được chỉ định trong quá trình khởi tạo sẽ xác định duy nhất toàn bộ chuỗi số trong tương lai. Điều này có nghĩa là một PRNG riêng biệt được khởi tạo với cùng một hạt giống (tại các thời điểm khác nhau, trong các chương trình khác nhau, trên các thiết bị khác nhau) sẽ tạo ra cùng một chuỗi.

Bạn cũng cần biết phân bố xác suất đặc trưng cho PRNG - nó sẽ tạo ra những con số nào và với xác suất bao nhiêu. Thông thường đây là phân phối chuẩn hoặc phân phối đồng đều.
BlessRNG hoặc kiểm tra RNG cho công bằng
Phân bố chuẩn (trái) và phân bố đều (phải)

Giả sử chúng ta có một con súc sắc công bằng với 24 mặt. Nếu bạn ném nó, xác suất lấy được một số sẽ bằng 1/24 (giống như xác suất lấy được bất kỳ số nào khác). Nếu bạn thực hiện nhiều lần ném và ghi lại kết quả, bạn sẽ nhận thấy rằng tất cả các cạnh rơi ra với tần số gần như nhau. Về cơ bản, khuôn này có thể được coi là một RNG với sự phân bố đồng đều.

Điều gì sẽ xảy ra nếu bạn ném 10 viên xúc xắc này cùng một lúc và đếm tổng số điểm? Tính đồng nhất sẽ được duy trì cho nó? KHÔNG. Thông thường, số tiền sẽ gần bằng 125 điểm, tức là ở một giá trị trung bình nào đó. Và kết quả là, ngay cả trước khi thực hiện cú ném, bạn có thể ước tính đại khái kết quả trong tương lai.

Lý do là có số lượng kết hợp lớn nhất để đạt được điểm trung bình. Càng ở xa nó, càng ít kết hợp - và theo đó, xác suất thua lỗ càng thấp. Nếu dữ liệu này được hình dung, nó sẽ trông giống hình dạng của một chiếc chuông. Do đó, với một chút căng thẳng, hệ thống gồm 10 viên xúc xắc có thể được gọi là RNG có phân bố chuẩn.

Một ví dụ khác, chỉ lần này là trên máy bay - bắn vào mục tiêu. Người bắn sẽ là RNG tạo ra một cặp số (x, y) hiển thị trên biểu đồ.
BlessRNG hoặc kiểm tra RNG cho công bằng
Đồng ý rằng tùy chọn bên trái gần với đời thực hơn - đây là RNG có phân phối chuẩn. Nhưng nếu bạn cần phân tán các ngôi sao trên bầu trời tối, thì lựa chọn phù hợp thu được bằng cách sử dụng RNG với sự phân bố đồng đều sẽ phù hợp hơn. Nói chung, chọn một máy phát điện tùy thuộc vào nhiệm vụ trước mắt.

Bây giờ hãy nói về entropy của chuỗi PNG. Ví dụ: có một chuỗi bắt đầu như thế này:

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

Những con số này thoạt nhìn ngẫu nhiên đến mức nào? Hãy bắt đầu bằng cách kiểm tra phân phối.
BlessRNG hoặc kiểm tra RNG cho công bằng
Nó trông gần giống như đồng nhất, nhưng nếu bạn đọc một chuỗi gồm hai số và hiểu chúng dưới dạng tọa độ trên mặt phẳng, bạn sẽ nhận được điều này:
BlessRNG hoặc kiểm tra RNG cho công bằng
Các mẫu trở nên rõ ràng. Và vì dữ liệu trong chuỗi được sắp xếp theo một cách nhất định (nghĩa là nó có entropy thấp), điều này có thể dẫn đến chính sự “sai lệch” đó. Ở mức tối thiểu, PRNG như vậy không phù hợp lắm để tạo tọa độ trên mặt phẳng.

Một trình tự khác:

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

Mọi thứ ở đây dường như đều ổn ngay cả trên máy bay:
BlessRNG hoặc kiểm tra RNG cho công bằng
Hãy xem số lượng (đọc ba số cùng một lúc):
BlessRNG hoặc kiểm tra RNG cho công bằng
Và một lần nữa các mẫu. Không còn có thể xây dựng một hình ảnh trực quan theo bốn chiều. Nhưng các mô hình có thể tồn tại trên chiều này và trên các chiều lớn hơn.

Trong mật mã, nơi áp đặt các yêu cầu nghiêm ngặt nhất đối với PRNG, tình huống như vậy là không thể chấp nhận được. Do đó, các thuật toán đặc biệt đã được phát triển để đánh giá chất lượng của chúng mà bây giờ chúng tôi sẽ không đề cập đến. Chủ đề này rất rộng và xứng đáng có một bài viết riêng.

Kiểm tra

Nếu chúng ta không biết chắc chắn điều gì đó thì làm thế nào để giải quyết nó? Có đáng để băng qua đường nếu bạn không biết đèn giao thông nào cho phép không? Hậu quả có thể khác nhau.

Điều tương tự cũng xảy ra với tính ngẫu nhiên khét tiếng trong Unity. Sẽ rất tốt nếu tài liệu tiết lộ các chi tiết cần thiết, nhưng câu chuyện được đề cập ở đầu bài viết xảy ra chính xác là do thiếu các chi tiết cụ thể mong muốn.

Và nếu bạn không biết công cụ này hoạt động như thế nào thì bạn sẽ không thể sử dụng nó một cách chính xác. Nói chung, đã đến lúc kiểm tra và tiến hành một thử nghiệm để cuối cùng ít nhất có thể đảm bảo về sự phân bố.

Giải pháp rất đơn giản và hiệu quả - thu thập số liệu thống kê, lấy dữ liệu khách quan và xem kết quả.

Đề tài nghiên cứu

Có một số cách để tạo số ngẫu nhiên trong Unity - chúng tôi đã thử nghiệm năm cách.

  1. System.Random.Next(). Tạo các số nguyên trong một phạm vi giá trị nhất định.
  2. System.Random.NextDouble(). Tạo các số có độ chính xác gấp đôi trong phạm vi từ [0; 1).
  3. UnityEngine.Random.Range(). Tạo các số có độ chính xác đơn (số float) trong một phạm vi giá trị nhất định.
  4. UnityEngine.Random.value. Tạo các số có độ chính xác đơn (số float) trong phạm vi từ [0; 1).
  5. Unity.Mathematics.Random.NextFloat(). Một phần của thư viện Unity.Mathematics mới. Tạo các số có độ chính xác đơn (số float) trong một phạm vi giá trị nhất định.

Hầu như ở mọi nơi trong tài liệu, một bản phân phối thống nhất đã được chỉ định, ngoại trừ UnityEngine.Random.value (trong đó bản phân phối không được chỉ định, nhưng bằng cách tương tự với đồng phục UnityEngine.Random.Range() cũng được mong đợi) và Unity.Mathematics.Random .NextFloat() (trong đó Cơ sở là thuật toán xorshift, có nghĩa là một lần nữa bạn cần đợi phân phối đồng đều).

Theo mặc định, kết quả mong đợi được lấy theo kết quả được chỉ định trong tài liệu.

Phương pháp luận

Chúng tôi đã viết một ứng dụng nhỏ tạo ra các chuỗi số ngẫu nhiên bằng cách sử dụng từng phương pháp được trình bày và lưu kết quả để xử lý tiếp.

Độ dài của mỗi dãy là 100 số.
Phạm vi của các số ngẫu nhiên là [0, 100).

Dữ liệu được thu thập từ một số nền tảng mục tiêu:

  • Windows
    — Unity v2018.3.14f1, Chế độ soạn thảo, Mono, .NET Standard 2.0
  • macOS
    — Unity v2018.3.14f1, Chế độ soạn thảo, Mono, .NET Standard 2.0
    — Unity v5.6.4p4, Chế độ soạn thảo, Mono, .NET Standard 2.0
  • Android
    — Unity v2018.3.14f1, xây dựng trên mỗi thiết bị, Mono, .NET Standard 2.0
  • iOS
    — Unity v2018.3.14f1, xây dựng trên mỗi thiết bị, il2cpp, .NET Standard 2.0

Thực hiện

Chúng tôi có một số cách khác nhau để tạo số ngẫu nhiên. Đối với mỗi loại, chúng ta sẽ viết một lớp trình bao bọc riêng biệt, lớp này sẽ cung cấp:

  1. Khả năng đặt phạm vi giá trị [min/max). Sẽ được thiết lập thông qua hàm tạo.
  2. Phương thức trả về MF. Hãy chọn kiểu float vì nó tổng quát hơn.
  3. Tên của phương pháp tạo để đánh dấu kết quả. Để thuận tiện, chúng ta sẽ trả về một giá trị là tên đầy đủ của lớp + tên của phương thức được sử dụng để tạo MF.

Trước tiên, hãy khai báo một sự trừu tượng hóa sẽ được biểu thị bằng giao diện IRandomGenerator:

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

        float Generate();
    }
}

Triển khai System.Random.Next()

Phương thức này cho phép bạn đặt một phạm vi giá trị, nhưng nó trả về số nguyên, nhưng cần có số float. Bạn có thể chỉ cần diễn giải số nguyên dưới dạng số float hoặc bạn có thể mở rộng phạm vi giá trị theo nhiều bậc độ lớn, bù chúng bằng mỗi thế hệ của tầm trung. Kết quả sẽ giống như một điểm cố định với mức độ chính xác nhất định. Chúng ta sẽ sử dụng tùy chọn này vì nó gần với giá trị thả nổi thực hơn.

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

Triển khai System.Random.NextDouble()

Ở đây phạm vi giá trị cố định [0; 1). Để chiếu nó lên cái được chỉ định trong hàm tạo, chúng ta sử dụng số học đơn giản: 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;
    }
}

Triển khai UnityEngine.Random.Range()

Phương thức này của lớp tĩnh UnityEngine.Random cho phép bạn đặt một phạm vi giá trị và trả về kiểu float. Bạn không phải thực hiện bất kỳ chuyển đổi bổ sung nào.

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

Triển khai UnityEngine.Random.value

Thuộc tính giá trị của lớp tĩnh UnityEngine.Random trả về kiểu float từ một phạm vi giá trị cố định [0; 1). Hãy chiếu nó lên một phạm vi nhất định giống như khi triển khai 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;
    }
}

Triển khai Unity.Mathematics.Random.NextFloat()

Phương thức NextFloat() của lớp Unity.Mathematics.Random trả về một dấu phẩy động kiểu float và cho phép bạn chỉ định một phạm vi giá trị. Sắc thái duy nhất là mỗi phiên bản của Unity.Mathematics.Random sẽ phải được khởi tạo bằng một số hạt giống - bằng cách này, chúng ta sẽ tránh tạo ra các chuỗi lặp lại.

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

Triển khai MainController

Một số triển khai IRandomGenerator đã sẵn sàng. Tiếp theo, bạn cần tạo chuỗi và lưu tập dữ liệu kết quả để xử lý. Để thực hiện điều này, chúng tôi sẽ tạo một cảnh và một tập lệnh MainController nhỏ trong Unity, tập lệnh này sẽ thực hiện tất cả các công việc cần thiết, đồng thời chịu trách nhiệm tương tác với giao diện người dùng.

Hãy đặt kích thước của tập dữ liệu và phạm vi giá trị MF, đồng thời lấy phương thức trả về một mảng trình tạo được định cấu hình và sẵn sàng hoạt động.

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

        ...
    }
}

Bây giờ hãy tạo một tập dữ liệu. Trong trường hợp này, việc tạo dữ liệu sẽ được kết hợp với việc ghi kết quả vào luồng văn bản (ở định dạng csv). Để lưu trữ các giá trị của mỗi IRandomGenerator, cột riêng của nó được phân bổ và dòng đầu tiên chứa Tên của trình tạo.

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

        ...
    }
}

Tất cả những gì còn lại là gọi phương thức GeneCsvDataSet và lưu kết quả vào một tệp hoặc chuyển ngay dữ liệu qua mạng từ thiết bị cuối đến máy chủ nhận.

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

        ...
    }
}

Nguồn dự án có tại GitLab.

Những phát hiện

Không có phép lạ nào xảy ra. Những gì họ mong đợi chính là những gì họ nhận được - trong mọi trường hợp, một sự phân phối đồng đều mà không có một chút âm mưu nào. Tôi không thấy có ích gì khi đặt các biểu đồ riêng biệt cho các nền tảng - tất cả chúng đều hiển thị các kết quả gần giống nhau.

Thực tế là:
BlessRNG hoặc kiểm tra RNG cho công bằng

Trực quan hóa các chuỗi trên một mặt phẳng từ tất cả năm phương pháp tạo:
BlessRNG hoặc kiểm tra RNG cho công bằng

Và hiển thị trong 3D. Tôi sẽ chỉ để lại kết quả của System.Random.Next() để không tạo ra nhiều nội dung giống hệt nhau.
BlessRNG hoặc kiểm tra RNG cho công bằng

Câu chuyện được kể trong phần giới thiệu về quá trình phân phối bình thường của UnityEngine.Random đã không lặp lại: ban đầu nó có lỗi hoặc có điều gì đó đã thay đổi trong công cụ. Nhưng bây giờ chúng tôi chắc chắn.

Nguồn: www.habr.com

Thêm một lời nhận xét