关于抽卡游戏保底机制的重要性及其研究

引子

抽卡是许多游戏为吸引玩家充值消费而推出的一类活动,其核心机制为玩家消费虚拟货币或现实货币抽取卡池内的物品,高价值物品往往对应着低概率。该模式自2010年代起便发展成为移动端游戏的主要盈利模式,取代了传统的买断制。

「保底机制」作为抽卡游戏中的一项重要机制,其设计初衷旨在避免玩家一直消费却抽不到高价值物品,改善这部分非酋(即「不幸玩家」)的消费体验,提升玩家池的综合消费意愿。在本文中,将通过几个具体的案例研究这一机制的重要性和必要性。

概率错觉

考虑在掷骰子游戏中掷三颗骰子出现豹子(即三颗骰子同一面朝上)这一事件,通过古典概型的简单计算可知这一概率为$\sfrac{1}{36}$,从理论上来说,出现豹子的掷骰子次数的数学期望值为36次,即平均每掷36次能见到一次豹子。

但在现实中,人们往往感觉这一概率明显比理论值要低得多,这主要是由于人类对小概率事件的主观感受和经验更侧重于累计出现概率。换言之,概率只是人类抽象出的一个数学概念,人类直觉并不关心事件发生的概率,而是关心「事件在何种条件下大概率会发生」,这是两个截然不同的角度。

在掷骰子游戏中,掷三颗骰子出现豹子的概率确实是$\sfrac{1}{36}$,倘若告诉你这一答案,你在接收这一答案的第一时间会产生的直觉是「数学期望是36次,那我在多掷几次应该就能见到了」。此时,你脑海里的实际问题变成了「我要掷几次骰子,才大概率能见到一次豹子?」,这就是「累计出现概率」,而非「单次发生概率」。

分析

在概率论中,有一种离散型概率分布被专门用于研究累计出现概率——几何分布(Geometric Distribution),它是独立伯努利试验中首次成功所需试验次数的离散型概率分布。

假设单次试验成功的概率为$p$,重复进行$n$次试验,则$n$次都失败的概率为$(1-p)^n$,故至少成功一次的概率为

这便是几何分布的分布列。反之,倘若我们问「要以概率$q$至少取得一次成功,需要进行多少次试验?」,所需的试验次数$n$即为下述不等式的解

对于掷骰子游戏,只要把$p = \sfrac{1}{36}$代入到几何分布的分布列中便能得到

1

由上图可知,尽管见到一次豹子数学期望是掷36次骰子,但掷36次骰子至少出现一次豹子的概率仅为64%,这意味着还有36%的概率,掷36次骰子一次豹子也见不到。

期望

方差

抽卡游戏

回到本文一开始提出的问题,许多玩家参与抽卡游戏的重要动机就是获取其中的高价值物品,而高价值物品对应的低概率又意味着玩家需要多次抽卡才能提高获得高价值物品的概率。

由于几何分布的尾部特性,在参与抽卡游戏的庞大的游戏玩家池中,就会出现少数明明抽卡次数远远超过数学期望值却仍无法获得高价值物品的「非酋」,这部分玩家就会面临巨大的沉没成本而产生不好的消费体验。

保底机制

为了改善「非酋」玩家的消费体验,在抽卡游戏中设计一个游戏机制,避免这种玩家明明抽了很多次却一直抽不中高价值物品的情况发生是非常重要的,这便是「保底机制」。如今在移动端游戏的抽卡游戏中,主要存在着以下三种主流的「保底机制」:

  1. 天井机制(俗称「硬保底」):给抽卡次数设定一个固定的阈值$N$,若一位玩家在$N-1$次抽卡中都未获得高价值物品(即抽卡失败),则强制设定玩第$N$次抽卡的成功率为$100%$,即必出高价值物品。

  2. 水位机制(俗称「软保底」):在给定初始抽卡成功率$p$的前提下,若一位玩家在前$k-1$次抽卡中都未获得高价值物品,则自第$k$次抽卡开始,每次抽卡的成功率以线性或非线性方式递增,直至其达到$100%$触发天井机制的条件。这种「保底机制」给玩家带来的体验最平滑,也能在很大程度上避免触发「硬保底」的玩家仍旧产生「我运气真差」「被系统安排」的不良消费体验,是目前市场上主流的保底机制。

  3. 洗牌机制‌:预先生成包含确定数量高价值物品的奖品序列并打乱,保证有限次抽卡中高价值物品的分布‌均匀‌。该种机制的弊端是需要存储序列状态,在众多玩家参与时需消耗大量内存,多用于小规模精确控奖。

数值模拟

在本节中,将以掷骰子游戏中的$p = \sfrac{1}{36}$为例,研究在无保底机制和天井+水位「软保底机制」下,抽卡游戏累计成功概率与玩家消费体验的区别。

几何分布

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
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib

matplotlib.rcParams['font.sans-serif'] = ['SimHei'] # 或者其他支持中文的字体,如 'Arial'
matplotlib.rcParams['axes.unicode_minus'] = False # 用来正常显示负号

# ============================================================
# 1. 参数
# ============================================================

p = 1 / 36

# 为了画图和观察概率分布,计算到这里
max_draws = 200


# ============================================================
# 2. 理论概率分布
#
# 几何分布:
#
# P(N=n) = (1-p)^(n-1) * p
#
# N = 第一次成功所需要的抽卡次数
# ============================================================

draw_numbers = np.arange(1, max_draws + 1)

prob_first_success = (
(1 - p) ** (draw_numbers - 1) * p
)


# ============================================================
# 3. 累计成功概率
#
# P(N <= n) = 1 - (1-p)^n
# ============================================================

cumulative_probability = (
1 - (1 - p) ** draw_numbers
)


# ============================================================
# 4. 理论精确统计量
# ============================================================

# 期望
expected_draws = 1 / p

# 方差
variance = (1 - p) / p**2

# 标准差
std = np.sqrt(variance)


# ============================================================
# 5. 理论分位数
#
# 定义:
# q分位数 = 满足 P(N <= n) >= q 的最小整数n
#
# 由:
# 1-(1-p)^n >= q
#
# 可以得到:
# n >= log(1-q) / log(1-p)
# ============================================================

def geometric_quantile(q):

n = np.ceil(
np.log(1 - q) / np.log(1 - p)
)

return int(n)


q50 = geometric_quantile(0.50)
q90 = geometric_quantile(0.90)
q95 = geometric_quantile(0.95)
q99 = geometric_quantile(0.99)


# ============================================================
# 6. 输出核心结果
# ============================================================

print("=" * 60)
print("无保底机制:纯几何分布理论精确解")
print("=" * 60)

print(f"成功率 p = {p:.10f}")
print(f"成功率 p = {p:.6%}")

print("\n核心统计量")
print("-" * 60)

print(f"期望抽数 E[N] = {expected_draws:.6f}")
print(f"方差 Var(N) = {variance:.6f}")
print(f"标准差 Std(N) = {std:.6f}")

print("\n分位数")
print("-" * 60)

print(f"50% 分位数 = 第 {q50} 次")
print(f"90% 分位数 = 第 {q90} 次")
print(f"95% 分位数 = 第 {q95} 次")
print(f"99% 分位数 = 第 {q99} 次")


# ============================================================
# 7. 输出一些特定抽数下的累计成功概率
# ============================================================

print("\n累计成功概率")
print("-" * 60)

for n in [1, 10, 18, 20, 25, 36, 50, 72, 100, 200]:

probability = 1 - (1 - p) ** n

print(
f"≤ 第 {n:3d} 次:{probability:.6%}"
)


# ============================================================
# 8. 输出完整概率分布
# ============================================================

result = pd.DataFrame({
"抽卡次数": draw_numbers,
"首次成功概率": prob_first_success,
"累计成功概率": cumulative_probability
})

print("\n" + "=" * 60)
print("前50次理论概率分布")
print("=" * 60)

print(
result.head(50).to_string(
index=False,
formatters={
"首次成功概率": "{:.6%}".format,
"累计成功概率": "{:.6%}".format
}
)
)


# ============================================================
# 9. 保存CSV
# ============================================================

result.to_csv(
"geometric_distribution.csv",
index=False,
encoding="utf-8-sig"
)

print("\n完整理论概率分布已保存为:")
print("geometric_distribution.csv")


# ============================================================
# 10. 图1:第一次成功概率
# ============================================================

plt.figure(figsize=(12, 6))

plt.bar(
draw_numbers,
prob_first_success
)

plt.axvline(
expected_draws,
linestyle="--",
label=f"期望抽数 = {expected_draws:.0f}"
)

plt.axvline(
q50,
linestyle=":",
label=f"50%分位数 = {q50}"
)

plt.xlabel("抽卡次数")
plt.ylabel("第一次成功概率")
plt.title("纯几何分布:第一次成功发生在各抽数的概率")

plt.grid(alpha=0.3)
plt.legend()

plt.tight_layout()
plt.show()


# ============================================================
# 11. 图2:累计成功概率
# ============================================================

plt.figure(figsize=(12, 6))

plt.plot(
draw_numbers,
cumulative_probability,
linewidth=2
)

# 分位数线
plt.axhline(
0.50,
linestyle="--",
label=f"50%:第{q50}次"
)

plt.axhline(
0.90,
linestyle="--",
label=f"90%:第{q90}次"
)

plt.axhline(
0.95,
linestyle="--",
label=f"95%:第{q95}次"
)

plt.axhline(
0.99,
linestyle="--",
label=f"99%:第{q99}次"
)

plt.xlabel("抽卡次数")
plt.ylabel("累计成功概率")
plt.title("纯几何分布:累计成功概率")

plt.ylim(0, 1.05)
plt.grid(alpha=0.3)
plt.legend()

plt.tight_layout()
plt.show()

软保底机制

在下面的代码中,将在初始概率$p = \sfrac{1}{36}$的基础上,从第17次抽卡开始每次成功率增加$\sfrac{1}{18}$,直到第37次抽卡的成功率为$100%$。

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
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib

matplotlib.rcParams['font.sans-serif'] = ['SimHei'] # 或者其他支持中文的字体,如 'Arial'
matplotlib.rcParams['axes.unicode_minus'] = False # 用来正常显示负号

# ============================================================
# 1. 参数
# ============================================================

p = 1 / 36 # 初始成功率
increase = 1 / 18 # 第19次开始,每次增加的成功率
max_draws = 37 # 第37次必定成功


# ============================================================
# 2. 构造每一次抽卡的成功率
# ============================================================

draw_numbers = np.arange(1, max_draws + 1)

prob_success = np.zeros(max_draws)

for n in draw_numbers:

if n <= 18:
# 第1~18次
prob_success[n - 1] = p

elif n <= 36:
# 第19~36次
prob_success[n - 1] = p + (n - 18) * increase

else:
# 第37次必定成功
prob_success[n - 1] = 1.0


# ============================================================
# 3. 理论精确计算:
# P(N=n) = P(前n-1次全部失败) × P(第n次成功)
# ============================================================

prob_first_success = np.zeros(max_draws)

survival_probability = 1.0

for i in range(max_draws):

# 第 i+1 次第一次成功
prob_first_success[i] = (
survival_probability * prob_success[i]
)

# 更新到下一次抽卡之前仍然没有成功的概率
survival_probability *= (1 - prob_success[i])


# ============================================================
# 4. 累计成功概率
# ============================================================

cumulative_probability = np.cumsum(prob_first_success)


# ============================================================
# 5. 检查概率总和
# ============================================================

print("=" * 60)
print("检查概率总和")
print("=" * 60)

print(f"概率总和 = {prob_first_success.sum():.12f}")

if np.isclose(prob_first_success.sum(), 1.0):
print("概率总和为1,理论分布正确")
else:
print("概率总和不为1,请检查模型")


# ============================================================
# 6. 期望抽数
# ============================================================

expected_draws = np.sum(
draw_numbers * prob_first_success
)


# ============================================================
# 7. 方差
# ============================================================

expected_draws_squared = np.sum(
draw_numbers**2 * prob_first_success
)

variance = expected_draws_squared - expected_draws**2


# ============================================================
# 8. 标准差
# ============================================================

std = np.sqrt(variance)


# ============================================================
# 9. 离散分位数
#
# 定义:
# q分位数 = 满足 CDF(n) >= q 的最小n
# ============================================================

def discrete_quantile(q):
"""
返回离散抽卡次数分布的q分位数。
定义为最小n,使得 P(N <= n) >= q。
"""

index = np.searchsorted(
cumulative_probability,
q
)

return draw_numbers[index]


q50 = discrete_quantile(0.50)
q90 = discrete_quantile(0.90)
q95 = discrete_quantile(0.95)
q99 = discrete_quantile(0.99)


# ============================================================
# 10. 输出核心统计量
# ============================================================

print("\n" + "=" * 60)
print("核心统计量")
print("=" * 60)

print(f"期望抽数 E[N] = {expected_draws:.6f}")
print(f"方差 Var(N) = {variance:.6f}")
print(f"标准差 Std(N) = {std:.6f}")

print("\n分位数:")
print(f"50% 分位数 = 第 {q50} 次")
print(f"90% 分位数 = 第 {q90} 次")
print(f"95% 分位数 = 第 {q95} 次")
print(f"99% 分位数 = 第 {q99} 次")


# ============================================================
# 11. 输出完整概率分布
# ============================================================

result = pd.DataFrame({
"抽卡次数": draw_numbers,
"本次成功率": prob_success,
"首次成功概率": prob_first_success,
"累计成功概率": cumulative_probability
})

print("\n" + "=" * 60)
print("完整理论概率分布")
print("=" * 60)

print(
result.to_string(
index=False,
formatters={
"本次成功率": "{:.6%}".format,
"首次成功概率": "{:.6%}".format,
"累计成功概率": "{:.6%}".format
}
)
)


# ============================================================
# 12. 保存结果到CSV
# ============================================================

result.to_csv(
"theoretical_distribution.csv",
index=False,
encoding="utf-8-sig"
)

print("\n理论概率分布已经保存为:")
print("theoretical_distribution.csv")


# ============================================================
# 13. 图1:每一次首次成功的概率
# ============================================================

plt.figure(figsize=(12, 6))

plt.bar(
draw_numbers,
prob_first_success
)

plt.axvline(
18.5,
linestyle="--",
label="水位机制开始"
)

plt.axvline(
37,
linestyle="--",
label="第37次必定成功"
)

plt.xlabel("抽卡次数")
plt.ylabel("第一次成功概率")
plt.title("第一次成功发生在各抽数的概率")

plt.xticks(draw_numbers)
plt.grid(alpha=0.3)
plt.legend()

plt.tight_layout()
plt.show()


# ============================================================
# 14. 图2:累计成功概率
# ============================================================

plt.figure(figsize=(12, 6))

plt.step(
draw_numbers,
cumulative_probability,
where="post"
)

plt.axhline(
0.50,
linestyle="--",
label="50%"
)

plt.axhline(
0.90,
linestyle="--",
label="90%"
)

plt.axhline(
0.95,
linestyle="--",
label="95%"
)

plt.axhline(
0.99,
linestyle="--",
label="99%"
)

plt.axvline(
q50,
linestyle=":"
)

plt.axvline(
q90,
linestyle=":"
)

plt.axvline(
q95,
linestyle=":"
)

plt.axvline(
q99,
linestyle=":"
)

plt.xlabel("抽卡次数")
plt.ylabel("累计成功概率")
plt.title("累计成功概率")

plt.xlim(1, 37)
plt.ylim(0, 1.05)

plt.xticks(draw_numbers)
plt.grid(alpha=0.3)
plt.legend()

plt.tight_layout()
plt.show()

结论

对比上述代码的运行结果,在不引入「保底机制」的情况下,玩家抽到高价值物品所需的抽卡次数将完全服从几何分布,有大约一成玩家在抽卡80次后仍无法获得高价值物品,会给处于尾部的玩家带来不好的消费体验;而引入「保底机制」后,玩家将在37次内必然抽中高价值物品。

这样的做法,在另一方面会降低玩家抽中高价值物品所需抽卡次数的数学期望值,降低游戏开发商的收入,这可以通过提升单次抽卡费用或降低初始概率和递增概率、延后水位机制开始时机等方式来解决。