대메뉴 바로가기 본문 바로가기

데이터 기술 자료

데이터 기술 자료 상세보기
제목 표준 라이브러리 완전정복 : 실전 C++ 표준 난수 라이브러리 사용 ②
등록일 조회수 5741
첨부파일  

표준 라이브러리 완전정복

실전 C++ 표준 난수 라이브러리 사용 ②



표준 C++ 라이브러리는 다양한 상황에서 난수의 품질을 선택할 수 있는 난수 엔진(Engine), 빈도와 범위의 조절이 가능한 분포(Distribution), 난수를 구성하는 수열의 보안성과 난수성을 높여주는 아답터 클래스를 제공한다. 지난 시간에 이어 이 글에서는 미처 다루지 못했던 분포 클래스 중 자주 사용하는 대표적인 클래스를 다양한 사례와 함께 알아본다.



엔진은 지정된 시드 값을 통해 임의의 수열을 생성하고 이를 기반으로 난수를 생성한다. 분포는 생성된 난수를 프로젝트의 비즈니스 로직에 맞게 범위나 빈도를 변환하는 역할을 한다. 엔진 클래스로는 rand() 함수와 동일한 원리의 선형 합동 알고리즘(Liner Congruential Algorithm)을 구현한 liner_congruential_engine 템플릿 클래스, 메르센 트위스터 알고리즘(Mersenne Twister Algorithm)을 구현한 mersenne_twister_engine 템플릿 클래스, 그리고 지연된 피보나치(Lagged Fibonacci)를 이용한 subtract_with_carry_engine 템플릿 클래스 등이 있다. 각각의 템플릿 인자를 통해 원하는 난수 엔진 클래스를 정의해 사용할 수 있지만, 품질 좋은 엔진 클래스를 얻기 위해 템플릿 인자를 부여하려면 각 엔진 알고리즘의 특성을 정확히 알고 있어야 한다.



<리스트 1> 메르센 트위스터 템플릿 엔진 클래스를 미리 정의한 mt19937과 mt19937_64의 선언과 uniform_int_distribution을 연동해 난수를 생성하는 예 typedef mersenne_twister_engine< unsigned int, 32, 624, 397, 31, 0x9908b0df, 11, 0xffffffff, 7, 0x9d2c5680, 15, 0xefc60000, 18, 1812433253> mt19937; typedef mersenne_twister_engine< _ULonglong, 64, 312, 156, 31, 0xb5026f5aa96619e9ULL, 29, 0x5555555555555555ULL, 17, 0x71d67fffeda60000ULL, 37, 0xfff7eee000000000ULL, 43, 6364136223846793005ULL> mt19937_64; #include < iostream> #include < random> int main() { mt19937 mt(1729); uniform_int_distribution< int> dist(0, 99); cout < < dist(mt) < < endl; auto random_with_bind = std::bind(dist, mt); cout < < random_with_bind() < < endl; }



분배 클래스는 엔진으로부터 얻은 임의의 수를 특정 범위나 빈도에 맞게 변환한다. 프로그램의 비즈니스 로직에 맞는 난수로 변환하는 역할을 한다. 분배 클래스는 변환하는 범위나 빈도에 따라 균일 분포(Uniform distributions) 그룹, 베르누이 분포(Bernoulli Distribution) 그룹, 푸아송 분포(Poisson Distribution) 그룹, 정규 분포(Normal Distribution) 그룹, 표본 분포(Sampling Distribution) 그룹으로 나뉜다.

분포로는 크게 확률변수가 가질 수 있는 값이 유한한 이산 확률 분포(Discrete Distribution)와 확률 밀도 함수(Probability Density Function)를 이용해 분포를 표현할 수 있는 연속 확률 분포(Continuous Distribution)가 있다. 지난 글에서 표준 C++ 난수 라이브러리가 제공하는 대표적인 이산 확률 분포인 베르누이 분포, 기하 분포(Geometric Distribution), 음이항 분포(Negative Binomial Distribution) 등은 물론 연속 확률 분포로 균일 정수 분포(Uniform Int Distribution), 균일 실수 분포(Uniform Real Distribution) 등을 살펴봤다. 이산 분포는 가능한 값들의 모음인 가산집합에서 난수를 선택할 수 있다. 예컨대 공평한 동전 던지기를 가정하면 앞면이 나올 확률이 0.5인 이산 균등 분포가 적합할 것이다. 주사위의 경우 1, 2, 3, 4, 5, 6이라는 가산 집합 내에서 주사위를 던졌을 때 나올 숫자를 난수로 고를 수가 있다. 베르누이 분포, 이항 분포(Binomial Distribution), 기하 분포, 푸아송 분포, 균일 정수 분포 등 표준 C++ 난수 라이브러리가 제공하는 이산 분포는 정수 값을 결과 값으로 제공한다.

연속 분포는 가산 집합 형태가 아닌 연속한 범위에서 가능한 값들 중 선택된 난수를 나타낸다. 예측할 수 없는 특정 주파수 대역이 진동하는데 걸리는 시간이 하나의 예가 될 수 있다. 결과 값들에 대해 특정 가산 집합을 선택할 수 없는 것과 달리 표준 C++ 난수 라이브러리는 이러한 연속 분포의 결과 값으로 부동 소수점 값을 제공한다. 지수 분포, 감마 분포, 정규 분포, 균일 실수 분포 등이 대표적인 연속 분포다.



푸아송 분포 그룹

푸아송 분포 그룹에는 푸아송 분포(Poisson distribution), 지수 분포(Exponential Distribution), 감마 분포(Gamma Distribution), 웨이블 분포(Weibull Distribution), 극단값 분포(Extreme Value Distribution) 등이 있다. 표준 C++ 난수 라이브러리는 각각 poisson_distribution, exponential_distribution, gamma_distribution, weibull_distribution, extreme_value_distribution 템플릿 클래스로 해당 분포를 지원하고 있다.

푸아송 분포는 베르누이 분포 등과 함께 대표적인 이산 확률 분포로, 매우 빈번하게 사용된다. 푸아송 분포는 어떤 공간이나 기간에 특정 사건이 발행하는 횟수를 의미한다. 예를 들면 페스트 푸드 매장에 방문하는 사람의 수, 신발 깔창을 붙이는 공장에서 깔창이 잘 붙지 않은 불량품의 개수, 형상관리 시스템인 깃허브(GitHub)에 등록된 대형 프로젝트에 커밋한 횟수를 난수로 얻는 것이 여기에 해당한다. 또는 특정 시간을 기준으로 서울지역에서 내일 출생하는 사람의 수를 예측하거나 판교 톨게이트에서 한 시간 동안 하이패스 게이트를 지나가는 자동차의 수를 추측하는 것은 물론 일정 시간동안 발생하는 불량품의 개수 등을 추측할 때에도 유용하다.

장소나 시간은 단위 구간 혹은 단위 시간이어야 하며 어떤 사건이 발행할 확률이 단위시간 전반에서 일정해야 한다. 또한 불량품 발생, 출생, 게이트를 지나가는 자동차처럼 다른 사건과 관련이 없이 일어나는 사건이어야 한다. 다른 인자의 영향을 받지 않으므로 특정 단위 구간에서 발생할 확률로 인해 전체 발생 횟수는 구간 크기에 비례한다. 다른 이산 확률의 경우 총 시행 횟수, 성공 횟수, 실패 횟수를 알아야만 그에 연동된 확률 사이의 값을 얻을 수 있지만, 푸아송 분포는 시행 횟수가 아닌 발생 횟수만 의미가 있으므로 성공과 실패의 경우 관련이 없다. 따라서 발생횟수와 평균만 알 수 있다면 확률 값이 반영된 분포 범위 내에서 난수 값을 얻을 수 있다. 푸아송 분포의 점화식은 다음과 같다. 참고로 점화식에서 람다 값은 반드시 0 이상이어야 한다.



점화식의 람다 값은 평균이다. 평균 길이가 람다인 구간에서 어떤 사건이 발생한다면, 난수 수열들의 각 값인 k가 발생할 확률은 길이가 람다인 한 구간 안에서 그 사건이 k번 발생할 확률과 같다. e값은 상수이자 무리수다. 무리수이기 때문에 십진수로 나타내는 소수는 근사 값인 2.7182818284590452353602874... 으로 나타낼 수 있다. e값은 자연로그의 밑으로 사용되는 중요한 상수며, 점화식은 다음과 같다.



람다 값은 poisson_distribution 템플릿 클래스 맴버의 mean에 해당한다. IntType에 해당하는 0 이상의 값으로 구성된 수열을 생성해 난수로 사용한다.



<리스트 2> poisson_distribution 템플릿 클래스 맴버 예 template< class IntType = int, class RealType = double> class poisson_distribution { public: typedef T1 input_type; typedef IntType result_type; struct param_type; explicit poisson_distribution(RealType mean0 = RealType(1.0)); explicit poisson_distribution(const param_type& par0); RealType mean() const; param_type param() const; void param(const param_type& par0); result_type min() const; result_type max() const; void reset(); template< class Engine> result_type operator()(Engine& eng); template< class Engine> result_type operator()(Engine& eng, const param_type& par0); private: RealType stored_mean; // exposition only };



poisson_distribution의 맴버 중 맴버 함수 mean()의 반환 값은 poisson_distribution 템플릿 클래스의 인스턴스를 생성할 때 생성자의 인자로 넣는 값이다. 즉 점화식에서 람다 값에 해당한다. 일반적인 분포와 같이 min(), max() 맴버와 input_type, result_type 등의 타입을 가지고 있다.



<리스트 3> 3.5의 확률로 푸아송 분포를 따르는 난수를 얻는 예제 array< int, 20> tmp = { 0, }; std::random_device seed_gen; std::default_random_engine eng(seed_gen()); std::poisson_distribution< int, double> dist(3.5); for (int i = 0; i < 100000; ++i) { int t = dist(eng); ++tmp[t]; if (t > 19) continue; }



poisson_distribution의 인스턴스에 평균값을 넣으면, 평균값을 기반으로 푸아송 분포를 따르는 난수를 얻을 수 있다. 랜덤한 엔진을 통해 계속적으로 충분한 숫자의 난수를 생성해 그래프를 그리면 결과 값의 빈도가 푸아송 분포를 따른다.



지수 분포는 표준 C++난수 라이브러리의 대표적인 연속 분포 라이브러리다. 보통 시간이 흐를수록 확률이 작아질 때 사용한다. 예컨대 디아블로와 같은 게임에 시뮬레이션적인 요소를 넣고자 체력이라는 수치를 만드는 경우를 생각해 보자. 장비와 무기, 획득한 아이템을 들고 맵과 마을간 장거리를 이동할 경우 시간이 지남에 따라 이동 시간은 서서히 감소할 것이다. 유니크한 보스 몬스터가 필드에 있을 때 특정 시간 동안 존재하도록 만들려면 몬스터에 수명이라는 요소를 두어야 한다. 몬스터가 등장했을 때부터 시간이 흐를수록 몬스터가 없어질 확률이 증가할 것이다. 이때 시간이 지날수록 살아 있을 확률이 감소하는 것에 대해 난수를 사용한다면 지수 분포를 반영할 수 있다. 퀘이크와 같은 게임에서 강력한 전자기총을 충전해 가지고 다니는 경우를 생각해 보자. 사용하지 않아도 방전이 되고, 게임상에서 재충전 하지 않으면 사용할 수 없게 하려면 시간이 흐를수록 확률이 작아지는 지수 분포를 적용해야 한다.



던전앤드래곤과 같은 게임에서 용의칼이라는 무기를 이벤트로 다수 나누어 주는 케이스를 가정해 보자. 이 무기는 시간이 흐를수록 내구성이 약해져 결국 사용할 수 없게 깨져 버린다. 일괄적으로 나누어준 용의 칼을 모두 깨뜨리기보다는 캐릭터의 성격과 능력치마다 확률을 다르게 둘 수 있는 난수를 반영해 깨지는 타이밍을 다르게 한다면 좀 더 현실적일 것이다. 지수 분포의 점화식은 다음과 같이 표현할 수 있다.



템플릿 클래스에 생성자로 전달하는 값과 인스턴스에 저장되는 값은 확률 밀도 함수 p의 람다 값이다. 온라인게임에서 1초에 5번을 때릴 수 있는 능력을 가진 캐릭터의 타격 횟수가 푸아송 분포의 난수로 정해질 경우 타격 이후 다음 타격 때까지 걸리는 시간은 지수 분포의 난수로 구할 수 있다. 이때 exponential_distribution 템플릿 클래스의 생성자에 전달하는 람다 값은 5가 된다.



<리스트 4> exponential_distribution 템플릿 클래스 맴버 예 template< class RealType = double> class exponential_distribution { public: typedef RealType input_type; typedef RealType result_type; struct param_type; explicit exponential_distribution( result_type lambda0 = result_type(1.0)); explicit exponential_distribution(const param_type& par0); result_type lambda() const; param_type param() const; void param(const param_type& par0); result_type min() const; result_type max() const; void reset(); template< class Engine> result_type operator()(Engine& eng); template< class Engine> result_type operator()(Engine& eng, const param_type& par0); private: result_type stored_lambda; // exposition only };



exponential_distribution의 생성자에 람다 값을 전달해 인스턴스를 생성한다. 인스턴스에 부여된 람다 값은 lambda() 맴버를 통해 조회할 수 있다.



<리스트 5> exponential_distribution 클래스 사용 예 array< int, 10> tmp = { 0, }; std::random_device seed_gen; std::default_random_engine eng(seed_gen()); std::exponential_distribution< > dist(1); for (int i = 0; i < 100000; ++i) { double number = dist(eng); if(number < 1.0) ++tmp[int(10 * number)]; }



결과 값이 부동 소수점으로 반환된다는 점에 주목하자. exponential_distribution를 이용해 충분히 많은 난수를 구해 그래프로 표현하면 지수 분포인 것을 알 수 있다.



감마 분포는 연속 확률 분포로, 점화식은 다음과 같다. 인자로 알파와 베타 두 값을 받는데, 다음 점화식에서 x가 1일 경우 람다는 1인 지수 분포 값들과 같은 분포를 가진다.



만일 1초에 5번의 타격을 할 수 있는 캐릭터가 있고 한 번 타격으로 100의 데미지를 주며 타격의 수가 푸아송 분포를 따른다고 가정하자. 체력이 1000인 몬스터를 죽이는데 걸리는 시간 값은 감마 분포가 된다. 한 번 타격으로 데미지를 100씩 주기 때문에 몬스터를 죽이기 위해서는 캐릭터는 10번 타격해야 한다. 따라서 감마 분포의 모수는 5와 10을 부여해야 한다.



<리스트 6> gamma_distribution 템플릿 클래스 맴버 예 template< class RealType = double> class gamma_distribution { public: typedef T1 input_type; typedef RealType result_type; struct param_type; explicit gamma_distribution(result_type alpha0 = result_type(1.0), result_type beta0 = result_type(1.0)); explicit gamma_distribution(const param_type& par0); result_type alpha() const; result_type beta() const; param_type param() const; void param(const param_type& par0); result_type min() const; result_type max() const; void reset(); template< class Engine> result_type operator()(Engine& eng); template< class Engine> result_type operator()(Engine& eng, const param_type& par0); private: result_type stored_alpha; // exposition only result_type stored_beta; // exposition only };



gamma_distribution은 모수인 알파와 베타를 생성자로 받으며 이를 조회할 수 있는 alpha()와 beta() 맴버 함수를 제공한다. gamma_distribution은 0보다 큰 난수를 생성하며 템플릿 인자로 결과 값에 대한 타입을 받는다.



<리스트 7> gamma_distribution의 알파값과 베타값이 각각 2.0일 때의 난수 예 void test(const double a, const double b) { std::mt19937 gen(1701); std::gamma_distribution< > distr(a, b); std::cout < < std::endl; std::cout < < "min() == " < < distr.min() < < std::endl; std::cout < < "max() == " < < distr.max() < < std::endl; std::cout < < "alpha() == " < < distr.alpha() < < std::endl; std::cout < < "beta() == " < < distr.beta() < < std::endl; std::map< double, int> histogram; for(int i = 0; i < 10; ++i) { ++histogram[distr(gen)]; } int counter = 0; for(const auto& elem : histogram) { std::cout < < ++counter < < ": " < < elem.first < < std::endl; } std::cout < < std::endl; } 결과값 : min() == 4.94066e-324 max() == 1.79769e+308 alpha() == 2.0 beta() == 2.0 1: 0.475795 2: 0.677861 3: 1.74458 4: 2.00203 5: 2.55754 6: 2.5754 7: 4.74874 8: 5.88514 9: 6.01007 10: 6.14123



알파값과 베타값이 각각 2.0인 각 구간의 빈도를 구하기 위해서는 충분한 만큼의 난수를 발생시켜야 한다. 이를 그래프로 그리면 gamma_distribution가 감마 분포를 보일 것이다.



<리스트 8> gamma_distribution의 빈도를 구하는 예 std::default_random_engine eng; std::gamma_distribution< double> dist(2.0, 2.0); array< int, 10> tmp = {0, }; for(int i = 0; i < 10000; ++i) { double number = dist(eng); if(number < 10) ++tmp[int(number)]; } for(int t : tmp) { std::cout < < t < < std::endl; }



지수 분포와 함께 감마 분포는 연속 분포이기 때문에 그래프에서 의미하는 값이 특정 값의 횟수가 아닌 0에서>

웨이블 분포는 일반적으로 미생물 실험 환경에서 특정 자극을 가했을 때 생존하는 박테리아의 생존을 시뮬레이션 하거나, 기기 속 특정 환경에서 특정 부품의 수명을 모델링할 때처럼 특정 요인에 의해 열화, 파괴, 죽음 등의 상황을 구할 때 사용한다. 지수 분포와 다른 것은 특정 부품이 고장날 확률이 일정한 경우 웨이블 분포는 시간이 지나면서 부품이 고장날 확률이 증가하거나 일정한 경우, 줄어들 경우를 모두 고려해 난수를 구할 수 있다는 점이다.



점화식에서는 a와 b를 받는데, a는 웨이블 계수나 쉐이프 매개변수(Shape Parameter)라고 하며, b는 스케일 매개변수(Scale Parameter)라고 한다. weibull_distribution 템플릿 클래스는 a와 b값을 조회할 수 있는 맴버 함수 a(), b()를 제공한다.





정규 분포 그룹

동전의 앞뒷면, 가위바위보, 주사위, 윳놀이 등에서 난수를 구한다면 결국 나올 수 있는 가짓수가 정해져 있는 상황에서 특정 선택을 하는 것이므로 이항 분포를 사용한다. 그러나 이러한 가짓수가 많아질 경우를 생각해 보자. 예컨대 윳가락이 수천, 수만 개가 되면 어떤 패턴이 가장 빈번히 나올지 예상하기 어려워진다. 그러나 난수의 분포는 이러한 빈도가 반영돼야 한다. 자연 현상에서 나올 수 있는 가짓수가 많아지면 정규 분포와 같은 모양새가 나오게 된다. 예컨대 사람의 평균키를 기준으로 크게 벗어나지 않은 사람들이 많을 것이다. 또한 어느 순간 크게 줄어 평균키와 차이가 많이 나면 날수록 여기에 속하는 사람의 수는 점점 적어질 것이다. 정규 분포는 국내 정규 고교 과정에도 포함돼 있는 연속 확률 분포로 가장 빈번하게 사용되면서 가장 유명한 분포 중 하나가 됐다. 이를 흔히 가우스 분포라고도 부르는데 통계에서도 가장 중요하고 많이 사용된다. 많은 자연적인 값들이 정규 분포를 보이기 때문이다.



표준 C++ 난수 라이브러리가 지원하는 정규 분포 그룹으로는 정규 분포(Normal Distribution), 로그 정규 분포(Lognormal Distribution), 카이제곱 분포(Chisquared Distribution), 코시 분포(cauchy_distribution), F 분포(Fisher_F_distribution), 스튜던트 t 분포(Student's t Distribution) 등이 있다. 그리고 각각의 분포를 지원하는 normal_distribution, lognormal_distribution, chi_squared_distribution, cauchy_distribution, fisher_f_distribution, student_t_distribution 템플릿 클래스를 제공한다. 정규 분포의 점화식은 다음과 같다. 인자는 평균값인 μ와 표준편차(Standard Deviation)인 σ값이 필요하다.



정규 분포를 지원하는 normal_distribution 템플릿 클래스는 평균값과 표준편차 값을 생성자를 통해 입력받고, mean() 맴버와 stddev() 맴버를 통해 조회할 수 있다. 생성자를 이용해 표준편차 값을 부여할 때에는 0 이상의 값을 넣어야 한다는 점에 유의하자.



<리스트 9> normal_distribution 템플릿 클래스 맴버의 예 template< class RealType = double> class normal_distribution { public: typedef T1 input_type; typedef RealType result_type; struct param_type; explicit normal_distribution(result_type mean0 = result_type(0.0), result_type sigma0 = result_type(1.0)); explicit normal_distribution(const param_type& par0); result_type mean() const; result_type sigma() const; result_type stddev() const; param_type param() const; void param(const param_type& par0); result_type min() const; result_type max() const; void reset(); template< class Engine> result_type operator()(Engine& eng); template< class Engine> result_type operator()(Engine& eng, const param_type& par0); private: result_type stored_mean; // exposition only result_type stored_sigma; // exposition only };



normal_distribution 템플릿 클래스는 RealType 타입의 난수를 생성한다. 값들의 평균 발생 확률은 평균이 mean에 부여한 값이 되고 분산이 S*S인 정규 분포를 따른다.



<리스트 10> normal_distribution 템플릿 클래스 사용 예 std::default_random_engine eng; std::normal_distribution< double> dist(5.0, 2.0); array< int, 10> tmp = {0, }; for(int i = 0; i < 10000; ++i) { double number = dist(eng); if((number >= 0.0) && (number < 10.0)) ++tmp[int(number)]; }



평균이 5, 표준편차가 2인 normal_distribution 분포 템플릿 클래스를 이용해 난수를 발생시키고 이를 이용해 그래프를 그리면 <그림 7>처럼 표준 분포 형태를 띄게 된다.



로그 정규 분포는 정규 분포에 지수 값을 취해 가장 빈번히 발생하는 빈도가 왼쪽으로 치우친 형태인데, 어떤 사건의 확률이 크게 증가했다가 급격히 낮아지는 패턴을 보인다. 예컨대 주식 시뮬레이션에서 수익을 정규 분포 형태로 증가시키기 위해 수익률을 로그 정규 분포를 따르는 난수 값을 얻을 수 있다. 또한 경영 시뮬레이션 게임에서 사원들에게 지급할 급여의 인상분 결정에 난수를 포함한다고 했을 때 로그 정규 분포를 사용할 수 있다.





표본 분포 그룹

표본 분포 그룹에는 이산 분포(Discrete Distribution), 피스와이즈 상수 분포(Piecewise Constant Distribution)), 피스와이즈 리니어 분포(Piecewise Linear Distribution)가 있다.



<리스트 11> discrete_distribution 템플릿 클래스 정의부 예 template< class _Ty = int> class discrete_distribution { // 중략 discrete_distribution(_XSTD initializer_list< double> _Ilist) : _Par(_Ilist) {// construct from initializer list } // 중략 };



이 3개의 분포는 매우 유사하다. 이산 분포는 특정 분포에 대한 비율을 지정할 수 있다. 이를 위해 discrete_distribution 템플릿 클래스가 가지고 있는 여러 개의 생성자 중 initializer_list를 받는 생성자가 존재한다.



출처 : 마이크로소프트웨어 1월호

제공 : 데이터전문가 지식포털 DBguide.net