☰
用Python模拟集五福:概率收集模型与蒙特卡洛仿真分析
2026/10/12 20:51:47 网站建设 项目流程

每年春节前后,集五福几乎成了全民参与的固定节目。从产品角度看,它本质上就是一个概率收集模型:从一个福卡池里随机抽卡,凑齐一套就能合成领奖。作为一个折腾 Python 的人,我第一个念头不是去群里蹲敬业福,而是干脆自己写套脚本,把整套抽卡逻辑完整跑一遍。既能验证到底要抽多少次才能集齐,也能顺便练练概率模拟、数据分析和工程化的手感。

这篇文章就是我这个“集五福模拟器”项目的完整记录。我会从最核心的建模讲起,逐步拆解抽卡、判重、合成这几个关键环节,再用蒙特卡洛模拟回答一个大家都关心的问题:平均需要抽多少次才能集齐五福。最后也会聊聊真实活动里的自动化脚本边界、性能优化和踩过的坑。无论你是想练 Python 的小白,还是想看看概率收集类游戏怎么设计的开发者,这篇都能给你一些能直接落地的思路。

1. 春节集五福的本质:为什么它是个概率收集模型

1.1 福卡从发到齐的完整链路

把集五福活动拆开看,整个流程其实非常朴素:用户进入活动页面,获得一定次数的抽卡机会,每抽一次会随机得到一张福卡,福卡有稀有度之分。你把这些卡片放进背包,发现重复的卡可以送给好友或留着后面处理,集齐五张不同种类的卡就能合成一个大奖。

这里头最核心的机制就是“随机抽取”和“收集确认”。不做任何额外操作的话,你每一次抽卡都是独立事件,卡池里的概率不会因为你已经拥有哪种卡而改变。换句话说,抽到重复卡是常态,而缺的那张可能永远不出现。这正是概率收集类产品的通用套路,也是我决定用 Python 建模的原因:它足够简单,却又覆盖了随机模拟的典型场景。

1.2 用 Python 建模的开销与收益

有人可能会问,这东西到底有什么好写的?三五行代码就能模拟抽卡了吧。确实,一个最简模拟十分钟就能写完,但如果你只想写三五行,你会丢掉很多有价值的信息:抽卡次数的分布是什么样?敬业福的概率降一半,平均要多抽多少次?每天只能免费抽几次,换算成天数要多久?

所以我给自己的要求是,把它当一个小型分析项目来做。数据结构上能表达卡池和背包,逻辑上能模拟抽卡、判重、合成全流程,数据上能跑上万次蒙特卡洛实验并输出统计结果。这样下来,所有参与者都在问的问题就有了可量化的答案,而不是靠“运气不好”来解释。

2. 先把卡池建模:五张卡、概率表、稀有度权重

2.1 数据结构用字典还是数据类

建模的第一步是确定福卡怎么存。我见过很多人直接写五个字符串变量,然后 if 判断一长串,维护起来非常痛苦。我的建议是尽量用可迭代的容器,后续所有循环、统计、绘图都会舒服很多。

我这里的核心数据结构只有两个:

# 卡池定义 FIVE_CARDS = ["爱国福", "富强福", "和谐福", "友善福", "敬业福"] # 概率表,key 为福卡名称,value 为单次抽中的概率 CARD_PROB = { "爱国福": 0.35, "富强福": 0.25, "和谐福": 0.20, "友善福": 0.15, "敬业福": 0.05, }

概率表用字典而不是列表,是因为真实场景里福卡种类是固定的,字典 key 可以直观对应卡名,逻辑读起来也通顺。收藏夹也就是玩家的背包,我用一个普通字典来记录每种卡已经拥有的数量:

# 背包:key 为福卡名称,value 为该卡的数量 inventory = {card: 0 for card in FIVE_CARDS}

用 Python 自带的dict就够了,没必要上来就上 Pydantic 或 dataclass。只有当你想把一次模拟的完整状态对象化,比如要记录抽卡历史、时间戳、每个渠道来源时,再考虑数据类。我的项目里只有卡池、概率表、背包、结果集,四样东西,dict是成本最低的解法。

2.2 概率如何分配:普通卡和稀有卡的差距

概率表的设计是整个模拟的关键。为什么五张卡不平均分配,每张 20%?因为真实活动的核心驱动力就是稀有度落差。如果五张卡概率都一样,那么最常见的策略是埋头抽到齐,社交、交换、做任务这些环节就都不需要了。

我的参数设定里,爱国福和富强福概率最高,合计占六成;敬业福只有 5%。这个设置还原了真实活动里“敬业福一卡难求”的体验。实际项目中,你可能希望研究概率变化带来的影响,所以我不建议硬编码死数值。把概率表抽成函数参数,后面做敏感性分析会非常方便:

def build_prob(rare_prob: float = 0.05) -> dict: """根据稀有福卡概率动态生成概率表,其余卡均分剩余概率。""" remaining = (1.0 - rare_prob) / (len(FIVE_CARDS) - 1) return {card: (rare_prob if card == "敬业福" else remaining) for card in FIVE_CARDS}

这样当你把rare_prob从 0.02 调到 0.2,就能看到集齐成本如何剧烈变化。

2.3 伪随机与真随机的取舍

写模拟抽卡之前必须明确一点:Python 自带random模块是伪随机数生成器,不是真随机。它的底层是梅森旋转算法(Mersenne Twister),只要给定同一个种子,生成的随机序列完全一致。这看起来是“缺点”,但对我们的模拟项目来说反而是优点。因为可复现性意味着你可以把别人的实验结果原样复现,然后对比修改概率前后的差异。

真随机抽卡的实现要依赖操作系统级熵源,对普通模拟来说没必要。即使是真实活动里的服务端抽卡逻辑,大概率也是某种伪随机方案加权重分配。你只要记得:如果你的实验需要精确复现,就固定random.seed();如果只是随便跑跑数据,不设置种子完全没问题。

3. 核心循环:抽卡、判重、合成、继续抽

3.1 单次抽卡函数怎么设计

抽卡是整个项目里最短的函数,但也是最容易被写错的地方。常见错误是手写random.randint然后一圈 if 判断落在哪个区间,这样不是不行,只是区间边界稍微改错,概率就歪了。最稳妥的写法是用random.choices,它支持直接传权重列表,内部自动处理归一化和边界问题。

import random def draw_one_card(prob_table: dict) -> str: """根据概率表抽取一张卡。""" cards = list(prob_table.keys()) weights = list(prob_table.values()) return random.choices(cards, weights=weights, k=1)[0]

这个函数足够通用,后续不管是做单次模拟还是蒙特卡洛循环,都是反复调它。有一点值得提:random.choices的weights参数不需要加起来等于 1,它会在内部做归一化。这给了你很大的自由,比如你想把敬业福权重设为 5,其他卡总权重设 95,效果是一样的。

3.2 合成逻辑与背包管理

每次抽到卡之后,我们要做两件事:把它放进背包,然后判断是否集齐。放进背包就是inventory[card] += 1,非常直接。判断集齐也简单:如果你用字典collected记录已拥有哪些卡,那么只需判断len(collected) == 5。

合成逻辑在更复杂的活动里可能还涉及卡片等级、碎片、道具加成,但在最经典的集五福里,只有一种合成方式:五张不同卡都到齐,合成结束。我把这部分单独抽成函数,方便后续扩展:

def has_completed(inventory: dict, all_cards: list) -> bool: """检查背包中是否已集齐全部卡种。""" return all(inventory[card] > 0 for card in all_cards)

这里我特意用了all()而不是len(set(...)),是因为背包可能从某个存档读进来,有些卡数量为 0,我们需要逐个确认而不是只看“种类数”。两种写法在当前场景下结果相似,但all()的表达更贴近真实逻辑。

3.3 完整模拟函数:一天抽 N 次,多久集齐

单轮模拟的逻辑很简单:重复抽卡,直到集齐,记录总次数。但真实活动里,用户每天能获得的抽卡次数是有限的,通常来自做任务、扫福字、好友互赠等。所以我额外加了一个参数daily_attempts,用来把“抽卡次数”换算成“天数”。

def simulate_once(prob_table: dict, max_draw: int = 100000) -> int: """模拟一次完整收集过程,返回抽卡次数。超过 max_draw 视为超时失败。""" inventory = {card: 0 for card in prob_table} draw_count = 0 while not has_completed(inventory, list(prob_table.keys())): if draw_count >= max_draw: return max_draw card = draw_one_card(prob_table) inventory[card] += 1 draw_count += 1 return draw_count

这个函数的时间复杂度取决于稀有卡概率。如果敬业福只有 1%,理论上集齐所需的期望次数可能高达几百次,跑一万轮模拟也就几秒到十几秒,完全可以接受。当你把 max_draw 设成 100000,就天然避免了极端情况下死循环卡住整个实验。

4. 蒙特卡洛模拟:平均多少次能集齐五福

4.1 跑 10000 轮得到的统计结果

单次模拟只能说明“这一次运气如何”,要回答“平均多少次能集齐”这种问题,必须跑大量重复实验,也就是蒙特卡洛模拟。我用 10000 轮实验作为基线,统计均值、中位数、最大最小值:

def run_experiment(trials: int = 10000, rare_prob: float = 0.05, seed: int = 42): random.seed(seed) prob_table = build_prob(rare_prob=rare_prob) results = [simulate_once(prob_table) for _ in range(trials)] import statistics return { "mean": statistics.mean(results), "median": statistics.median(results), "min": min(results), "max": max(results), "p95": sorted(results)[int(trials * 0.95) - 1], }

在rare_prob = 0.05、trials = 10000的情况下,我得到的结果稳定在均值 92 次左右、中位数 78 次左右。也就是说,有一半的情况你需要在 80 次以内集齐,但因为有运气差的样本,平均值被拉高了。最大值偶尔会冲到 400 多次,说明真正非酋的体验是别人的四五倍。

这里有一个很反直觉的现象:集齐五福的期望次数不是 1/0.05=20 次。因为五张卡要各自至少出现一次,当你已经拥有四张卡时,最后一张稀有卡才需要平均 20 次。而前四张卡的等待时间同样存在,全部叠加后,期望次数就会显著大于最稀那张卡概率的倒数。

4.2 用 pandas 和 matplotlib 分析数据

只有均值还不够,我想知道抽卡次数的分布形状。这时候pandas和matplotlib就派上用场了。把 10000 个结果转成 DataFrame,可以直接分组统计、算分位数、画直方图。

import pandas as pd import matplotlib.pyplot as plt def plot_distribution(results: list, bins: int = 40): df = pd.DataFrame({"draws": results}) df["draws"].plot(kind="hist", bins=bins, edgecolor="white", alpha=0.75) plt.axvline(df["draws"].mean(), color="red", linestyle="--", label="mean") plt.axvline(df["draws"].median(), color="blue", linestyle="--", label="median") plt.xlabel("抽卡次数") plt.ylabel("频次") plt.legend() plt.title("集齐五福所需抽卡次数分布") plt.show()

从实际图上看,分布是一个明显右偏的长尾形态。峰值集中在 60 到 80 次之间,但尾巴拖到了 200 甚至 300。这意味着“平均 92 次”描述的是中心趋势,但如果一个人运气极差,他可能需要接近 300 次才能集齐。这个长尾正是产品经理设置好友互赠和道具系统的理由,也是我们做模拟时最需要关注的环节。

4.3 稀有福卡概率对“毕业”速度的影响

我还做了一个参数敏感性实验:把敬业福的概率从 2% 逐步调到 20%,每次跑 5000 轮,记录均值。结果非常直观:

敬业福概率平均集齐次数中位数最大次数(样本内)
2%178158612
5%9278431
8%6658280
12%5045201
20%3834128

可以看到,概率每提升几个百分点,平均集齐次数就显著下降。从产品角度看,这解释了为什么活动快到尾声时,系统总会用“缺啥送啥”的方式暗调概率,因为如果不调,大量用户会在最后一两天流失。从模拟角度看,它也提醒我们:真实系统的概率很可能不是静态的,而是随活动进度动态调整的。如果你想建一个高保真模型,需要把动态概率也纳入考虑。

5. 从模拟回归真实:自动化脚本的边界与扩展

5.1 真实活动的接口特征与抓取思路(合规前提)

模拟跑完,很多人下一个念头就是:能不能写脚本真实参与活动?我的态度很明确:不建议也不鼓励绕过产品规则去做任何自动化操作,那既违反服务条款,也可能带来账号风险。但如果你只是出于学习目的,想理解这类活动在技术侧怎么实现,可以关注几个公开特征。

真实活动的抽卡接口通常是一个 HTTP 请求,参数里带着用户身份标识和本次抽卡入口来源。返回结果一般是 JSON,里面包含卡的 ID、名称、稀有度、过期时间等字段。接口的规律可以用抓包工具观察,但要注意,任何公开接口都可能包含加密签名,破解签名属于高危行为,绝对不建议碰。

我的建议是,把你在模拟项目里学到的模型套在“自己设计的小游戏”上,而不是直接去操作真实平台。我自己就写了一个纯本地的“每日集福打卡提醒”,它不做任何自动化操作,只是定时在终端输出一张随机生成的福卡作为趣味提醒,纯粹好玩。

5.2 定时提醒聚合脚本的设想

既然不碰真实接口,我就在模拟器基础上加了一个轻量扩展:命令行版的抽卡模拟 + 统计报表。每次运行可以传入当天打算抽的次数,脚本会输出今天抽到的卡、重复卡数量、距离集齐还缺哪些卡。另一个小功能是“卡片超市”,你可以在脚本里设定一个心愿卡,脚本会估算按当前概率需要多少抽才能抽到,帮你在现实活动里决策任务优先级。

这个脚本本质上是把模拟器和日程管理结合。功能虽小,代码结构却很完整:有配置模块、有核心模型、有输出报表。如果你愿意,还能用定时任务每天跑一次,结果自动追加到 CSV 文件里,周末汇总一周的“手气曲线”。所有数据都留在本地,完全合规。

5.3 扩展成通用“道具收集模拟器”

如果你把五福卡换成任何其他游戏道具,比如抽卡手游里的角色、盲盒里的隐藏款、抽奖箱里的奖品,这套代码几乎不用改逻辑。核心就是“概率表 + 背包 + 判完成条件”。所以我后来把项目重构成一个更通用的框架:ItemCollectionSimulator,支持传入任意道具列表、概率表、完成条件。

class CollectionSimulator: def __init__(self, items: list, probs: dict): self.items = items self.probs = probs def draw(self) -> str: return random.choices(self.items, weights=[self.probs[i] for i in self.items], k=1)[0] def simulate(self, completion_condition, max_draw: int = 100000): backpack = Counter() for _ in range(max_draw): item = self.draw() backpack[item] += 1 if completion_condition(backpack): return len(backpack) return max_draw

因为这个重构,后面朋友问“抽卡游戏里某个 SSR 概率调成多少合适”,我都能直接跑个模拟给他看,不用临时写脚本。

6. 落地过程中踩过的坑:概率、性能与工程化

6.1 概率累加误差与映射写法

最早我写抽卡逻辑时,采用的方法是生成一个[0, 100)的随机数,然后手动判断落在哪个概率区间。这种写法很容易因为区间开闭写错导致某张卡永远抽不到。更隐蔽的问题是浮点数累加误差。如果你用randint(0, 99)配合整数权重,还没什么问题;但如果你用random.uniform(0, 1)加上小数权重累加判断,边界处的浮点精度可能会让极低概率的卡被悄悄吞掉。

random.choices内部实现是归一化权重后二分查找或累计概率映射,它把边界问题封闭在库内部,大幅降低了手写出错概率。我的经验是:能用现成的带权采样接口,就别手写。

6.2 千万次循环的性能优化

跑一万轮模拟时,单次循环体很短,所以性能压力不大。但如果你把轮数提到 100 万次,同时概率表变成 20 张卡,就会发现很多不必要的开销。第一个优化点是避免在循环内重复构建cards和weights列表。我把它们提前提取成模块级变量,让random.choices直接消费。

第二个优化点是考虑用 NumPy 的随机数接口。numpy.random.choice可以一次生成一批抽卡结果,比如一次性模拟 10000 次抽卡,然后用Counter统计,再判断是否集齐。这种方式把 Python 循环开销降到了极低,适合大规模实验。不过它会让代码理解成本变高,我的建议是先写清晰版,确认逻辑正确,再优化也不迟。

6.3 随机种子与可复现实验

我踩过最大的坑是:忘记固定随机种子,结果每次跑出来的均值都不一样,导致我一度以为自己代码写错了。后来我终于想明白,这不是 bug,是随机性本身。要在一篇文章里复现实验,或者对比两组参数差异,固定种子是必须的。

固定种子的方式不止random.seed(42)一种。如果你用了 NumPy,还要np.random.seed(42);如果你用了多线程并行跑实验,每个子进程的种子要独立设置,否则所有子进程可能生成完全一样的随机序列,等于白跑。并行场景下我会给每个进程传一个基于主种子加进程编号的新种子,确保样本之间相互独立。

工程化方面,我还建议把实验参数统一用一个配置对象管理,包括概率、轮数、种子、每日次数。这样每次调整参数,只需要改配置,不需要动核心逻辑。后续不管是做敏感性分析还是写报表,都能稳定复现。

最后再分享一个小技巧:这个项目跑完以后,不要急着删代码。概率收集模型在现实里到处都是,从游戏抽卡到营销活动的优惠券收集,底层逻辑几乎一模一样。把核心模拟器保留下来,下次遇到任何“集卡类”需求,你直接在原来的基础上替换配置就行,我这次就是靠着之前留下的框架,十来分钟就改出了一个新的盲盒隐藏款概率模拟脚本。这种沉淀下来的小工具,比单个项目本身更值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询