Post

확률형 게임의 보상 모델을 시뮬레이션하자

X2E의 숙명.. 확률형 보상 지급

X2E 서비스에서 일하다보니 확률을 통한 보상 지급이 필수적이다. 현재로서는 기획자가 구간별로 최소, 최대 값을 정한 뒤, 발생 변수의 수를 조정하여 구간별 확률과 총 기대값을 시뮬레이션하는 방식의 “때려 넣기” 모델을 활용하고 있다. 물론 기대값은 의도한 값에 수렴하니 소기의 목적은 달성하고 있달까.

다만 이런 상황은 기형적인 확률 분포를 야기하고 있었고, 보상이 목적인 X2E의 유저에게 질 나쁜 보상 경험을 제공하게 된다는 리스크가 존재했다. 따라서, 확률 게임의 보상 모델을 확률 분포의 모양을 조정하면서 보다 나은 보상 경험을 디자인하기 위해 시뮬레이션 코드를 작성했다.

As-Is

평균 유지의 목적만을 달성함.

To-Be

평균을 유지하되, 다수의 유저가 경험할 보상 금액의 무게 중심을 조정함. 도박성을 높이거나, 안정성을 높이거나. (소수가 희생하여 다수가 행복해질 수 있다고(?))

100원 이하 지급 모델

60원 이하 지급 모델

결과와 활용 방안

결과적으로 매개 변수를 조정해가면서 보상 구간별 확률을 시각적으로 시뮬레이션 할 수 있게 되었고, 과반수의 시행에서 평균보다 높거나 낮은 보상 경험을 의도적으로 제공할 수 있게 되었다. 더 나아가서, 해당 구간별 확률을 가중치로 활용해서 유저 등급에 따른 차등 확률 적용으로 LTV 및 VIP의 이탈률을 감소 시킬 수 있는 가능성도 열렸다!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
import numpy as np
import pandas as pd
from scipy.stats import gamma
from scipy.stats import lognorm
# pd.options.display.float_format = '{:.2f}'.format


class Gotcha:
    def __init__(self, start, end, interval_range, input_mean_value, num_simulations, alpha,sigma):
        self.start = start
        self.end = end
        self.interval_range = interval_range
        self.input_mean_value = input_mean_value
        self.num_simulations = num_simulations
        self.bonus_range = self.set_range()
        self.alpha = alpha  # 감마 분포의 모양 매개변수
        # self.beta = self.alpha / self.input_mean_value  # 평균을 유지하기 위해 비율 매개변수 설정
        self.beta = self.alpha / self.input_mean_value  # 평균을 유지하기 위해 비율 매개변수 설정
        self.sigma = sigma  # 로그-노멀 분포의 형태 매개변수
        # 로그-노멀 분포의 mu 값을 계산합니다. 평균을 input_mean_value로 유지하려면,
        # mu는 다음과 같이 계산됩니다.
        self.mu = np.log(input_mean_value) - (sigma ** 2) / 2
        self.bonus_range = self.set_range()
        self.lognorm_probability = self.get_lognorm_distribution_probability()
        self.exp_probabilities = self.get_exp_distribution_probability()
        self.gamma_probabilities = self.get_gamma_distribution_probability()
        self.lognorm_probability = self.get_lognorm_distribution_probability()

    def set_range(self):
        intervals = [(i, min(i + self.interval_range, self.end)) for i in range(self.start, self.end, self.interval_range)]
        return intervals
    
    def get_exp_distribution_probability(self):
        lambda_param = 1 / self.input_mean_value
        exp_probabilities = []

        for (interval_start, interval_end) in self.bonus_range:
            prob = (1 - np.exp(-lambda_param * interval_end)) - (1 - np.exp(-lambda_param * interval_start))
            exp_probabilities.append(prob)

        return exp_probabilities
    
    def get_gamma_distribution_probability(self):
            gamma_probabilities = []

            for (interval_start, interval_end) in self.bonus_range:
                mid_point = (interval_start + interval_end) / 2
                # 감마 분포의 누적분포함수(CDF)를 사용하여 구간별 확률 계산
                prob = gamma.cdf(mid_point, self.alpha, scale=1/self.beta) - gamma.cdf(interval_start, self.alpha, scale=1/self.beta)
                gamma_probabilities.append(prob)

            return gamma_probabilities
    
    def get_lognorm_distribution_probability(self):
            lognorm_probability = []
            # scipy의 lognorm은 scale 파라미터로 e^mu를 받습니다.
            for (interval_start, interval_end) in self.bonus_range:
                prob = lognorm.cdf(interval_end, self.sigma, scale=np.exp(self.mu)) - \
                    lognorm.cdf(interval_start, self.sigma, scale=np.exp(self.mu))
                lognorm_probability.append(prob)
            return lognorm_probability
    
    def get_num_trials(self):
        expected_trials = [1/p if p > 0 else float('inf') for p in self.exp_probabilities]  # p가 0일 경우, 무한대로 설정
        return expected_trials
    
    def get_simulated_result(self, distribution_key):
        win_counts = np.zeros(len(self.bonus_range))
        
        # 분포 선택
        if distribution_key == 'exp':
            probabilities = self.exp_probabilities
        elif distribution_key == 'gamma':
            probabilities = self.gamma_probabilities
        elif distribution_key == 'lognorm':
            probabilities = self.lognorm_probability
        else:
            raise ValueError("Invalid distribution key. Choose 'exp', 'gamma', or 'lognorm'.")

        # 확률을 정규화.
        total_prob = sum(probabilities)
        normalized_probs = [prob / total_prob for prob in probabilities]

        for _ in range(self.num_simulations):
            # 0과 1 사이에서 랜덤 값을 생성
            random_value = np.random.random()
            cumulative_prob = 0

            # 생성된 랜덤 값이 어느 구간에 속하는지 확인
            for i, prob in enumerate(normalized_probs):
                cumulative_prob += prob
                if random_value <= cumulative_prob:
                    win_counts[i] += 1
                    break

        # 당첨 비율을 계산
        win_rates = win_counts / self.num_simulations

        return win_counts, win_rates

### 클래스 외, 실행 영역

### 시뮬레이션 진행
def get_sim(profit_range,exp_probability,gamma_probability,lognorm_probability,num_of_trials_needs,win_counts,win_rates,sim_dist):
    # 표로 정리하깅
    df = pd.DataFrame({
        'range':profit_range,
        'exp':exp_probability,
        'gamma':gamma_probability,
        'lognorm':lognorm_probability,
        'trials_need':num_of_trials_needs,
        'sim_win_counts':win_counts,
        'sim_win_rates':win_rates,
        'sim_profit':win_counts * win_rates
        })

    ## 시뮬레이션으로 지급될 총 액수 (=구간의 대표값 * 구간 내 당첨자)
    mid_points = [(start + end) / 2 for start, end in profit_range]
    sim_profit = [count * mid for count, mid in zip(win_counts, mid_points)]

    df['range_mid'] = mid_points
    df['sim_profit'] = sim_profit

    total_expected_payout = np.sum(sim_profit)

    print("총 지급액: ",total_expected_payout)
    print("총 시행: ",df.sim_win_counts.sum())
    print("평균 지급액: ",total_expected_payout/df.sim_win_counts.sum())

    # 최다분포 확률구간 검색 함수 실행
    min_range, max_range,min_range_key,max_range_key, high_prob_interval = find_central_range(profit_range, df[sim_dist].tolist())
    # min_range_low, max_range_high = min(min_range), max(max_range)
    min_range_key = df[df['range']==min_range_key].index.values[0]
    max_range_key = df[df['range']==max_range_key].index.values[0]
    plt.figure(figsize=(14, 8))
    plt.fill_betweenx(y=[0, max(df['sim_win_counts'])], x1=min_range_key, x2=max_range_key, color='red', alpha=0.1)
    sns.barplot(x='range', y='sim_win_counts', data=df, palette='viridis')
    plt.title(f'{sim_dist} 분포 시뮬레이션 - 구간별 당첨자수 (총 {round(df.sim_win_counts.sum().astype(int) / 10000)}만명) / 평균 지급액 : {round(total_expected_payout/df.sim_win_counts.sum(),2)}원 / 50% 유저 밀집 구간 : {min_range}~{max_range}원 / 최빈 구간 : {high_prob_interval}')
    plt.xlabel('Range Midpoint')
    plt.ylabel(f'{sim_dist} Probability')
    plt.xticks(rotation=45)  # x축 레이블 회전

    return df

### 최다분포 확률구간 검색
def find_central_range(intervals, probabilities):
    # 확률을 내림차순으로 정렬
    sorted_prob_indices = sorted(range(len(probabilities)), key=lambda k: probabilities[k], reverse=True)
    
    # 정렬된 확률을 사용하여 중앙 50% 구간 계산
    total_prob = sum(probabilities)
    half_prob = total_prob / 2
    accumulated_prob = 0
    central_range_indices = []

    for i in sorted_prob_indices:
        accumulated_prob += probabilities[i]
        central_range_indices.append(i)
        if accumulated_prob >= half_prob:
            break  # 누적 확률이 50%를 넘으면 종료

    # 중심 구간의 최소값과 최대값 찾기
    min_index = min(central_range_indices)
    max_index = max(central_range_indices)
    min_range = intervals[min_index][0]  # 최소 구간의 최소값
    max_range = intervals[max_index][1]  # 최대 구간의 최대값
    
    # 가장 확률이 높은 구간 찾기
    high_prob_index = probabilities.index(max(probabilities))
    high_prob_interval = intervals[high_prob_index]

    return min_range, max_range, intervals[min_index], intervals[max_index], high_prob_interval
######


#### 클래스 인스턴스 생성 및 시뮬레이션 실행라인
# 매개변수 정의
gotcha_game = Gotcha(start=15, end=81, interval_range=2, 
                     input_mean_value=5, #exp & all
                     num_simulations=500000,
                     alpha=8,#gamma
                     sigma=0.5 #log-norm
                     )

profit_range = gotcha_game.set_range()
exp_probability = gotcha_game.get_exp_distribution_probability()
gamma_probability = gotcha_game.get_gamma_distribution_probability()
lognorm_probability = gotcha_game.get_lognorm_distribution_probability()
num_of_trials_needs = gotcha_game.get_num_trials()

sim_dist = 'lognorm' #'exp', 'gamma','lognorm'
win_counts,win_rates = gotcha_game.get_simulated_result(sim_dist)

### 클래스 인스턴스 외, 실행 라인

df = get_sim(profit_range,exp_probability,gamma_probability,lognorm_probability,num_of_trials_needs,win_counts,win_rates,sim_dist)

This post is licensed under CC BY 4.0 by the author.