资讯详情

资讯详情

Python彩票模拟器:概率统计与保本分析实战

1. 从中奖幻觉到概率真相这个模拟器到底在算什么买彩票这件事绝大多数人都算过一笔糊涂账。两块钱一注中了五百万感觉人生就此翻盘没中也就当捐了两块钱做公益。但如果你真的坐下来用代码把整个摇奖过程跑上一百万次你会得到一个让人后背发凉的数字——它跟你脑子里那个万一呢的直觉差得不是一星半点。我写这个彩票模拟器的起因很简单身边总有人觉得买得多总会中还有人坚信守号十年必出奇迹。与其跟他们争论不如直接写个程序把摇奖过程、中奖概率、保本概率全部算出来用数据说话。这个项目不复杂核心就是三件事模拟摇奖、计算中奖概率、计算保本概率。它适合刚学完Python基础语法、想找一个有实际意义的练手项目的人也适合任何对概率感兴趣、想搞清楚彩票数学本质的读者。先说清楚这个模拟器能做什么。它不预测下一期开奖号码——那是玄学不是编程。它能做的是给定一套彩票规则比如从多少个号码里选几个用随机数生成器模拟摇奖然后拿你选的号码去比对统计在大量重复实验中你中各个奖级的频率是多少最终你的投入和回报是否平衡。所谓保本概率就是你买彩票的钱刚好能通过中奖奖金收回来的概率。这个数字往往低得让人沉默。为什么用Python来做这件事因为Python的random模块足够好用语法简洁几十行代码就能跑出一个完整的模拟框架。你不需要装什么重型库标准库就能搞定。而且Python处理大数运算和统计汇总非常顺手跑一百万次模拟也就几秒钟的事。对于想练手的人来说这个项目覆盖了随机数生成、循环控制、条件判断、字典统计、概率计算这几个核心知识点性价比很高。在动手之前有一个认知必须先建立起来彩票的每一次摇奖都是独立事件。这意味着上一期开了什么号码跟下一期没有任何关系。很多人喜欢研究走势图觉得某个号码该出了这在概率论上叫赌徒谬误。你的模拟器跑出来的结果会反复验证这一点——每个号码出现的频率会趋近于均匀分布不会因为某个号码很久没出就变得更容易出现。理解这一点是你写这个模拟器之前最重要的心理准备。接下来我会从规则设计、摇奖模拟、概率统计、保本计算、性能优化几个层面把这个项目完整拆开讲一遍。代码可以直接跑参数可以自己改你想模拟哪种彩票规则都行。2. 彩票规则建模先把游戏规则翻译成代码2.1 选号规则的结构化描述写模拟器之前第一件事是把彩票规则用数据结构描述清楚。不同彩票的规则差异很大但核心参数就那么几个号码池大小从多少个号码里选、选号个数你要选几个、奖级划分中几个号对应什么奖、奖金金额每个奖级对应多少钱、单注价格一注多少钱。我以最常见的从35个号码中选7个这种类型为例来建模。用字典来存规则是最直观的lottery_rule { pool_size: 35, # 号码池1到35 pick_count: 7, # 每注选7个号 price_per_ticket: 2, # 每注2元 prize_tiers: { # 中几个号 - 奖金 7: 5000000, 6: 50000, 5: 500, 4: 50, 3: 5, } }这个结构的好处是你想换成从49个选6个或者从33个选6个只需要改几个数字后面的逻辑完全不用动。这就是数据驱动的思路——把规则和逻辑分离改规则不改代码。注意奖级设置里中奖号码个数必须是连续的或者有明确对应关系的。如果你的规则里中2个号也有奖那就加上去如果中6个和7个之间还有一档也要补全。奖级表不完整会导致后面统计时出现KeyError。2.2 为什么用字典而不是列表存奖级有人可能会想奖级用列表存不行吗比如[5, 50, 500, 50000, 5000000]索引对应中奖个数。这样做的问题是当奖级不连续时比如中2个没奖、中3个有奖列表的索引就对不上了。用字典的键值对prize_tiers[3]直接拿到中3个号的奖金语义清晰不容易出错。另一个考虑是扩展性。真实彩票往往还有特别号追加投注这类机制用字典可以很方便地加字段比如加一个special_number: True然后在摇奖逻辑里多摇一个号。列表要做到这一点就得改结构成本更高。2.3 单注与复式的处理边界这个模拟器默认按单注处理也就是每注独立选号、独立比对。如果你想模拟复式投注选超过7个号组合成多注那需要在选号阶段做组合展开。我的建议是第一版先不做复式把单注的逻辑跑通、跑准再考虑扩展。因为复式投注的组合数增长很快选10个号就是C(10,7)120注选12个号就是C(12,7)792注计算量和内存占用都会上去。如果你确实想加复式功能核心就是itertools.combinationsfrom itertools import combinations def expand_complex(numbers, pick_count): 把复式选号展开成所有单注组合 return list(combinations(numbers, pick_count))但要注意复式展开后的每一注都要单独比对、单独统计投入否则保本概率会算错。这个坑我在后面会详细说。3. 摇奖模拟的核心随机数到底该怎么摇3.1 random.sample 与 random.choices 的选择Python标准库里生成不重复随机数最直接的方法是random.sampleimport random def draw_numbers(pool_size, pick_count): 从1到pool_size中随机抽取pick_count个不重复的号码 return random.sample(range(1, pool_size 1), pick_count)random.sample保证抽出来的号码不重复这正好符合彩票摇奖的实际情况——同一个号码不会在一期里出现两次。如果你用random.choices注意有s它是有放回抽样可能抽到重复号码那就不对了。这里有一个细节值得说random.sample返回的顺序是随机的但彩票比对时通常不关心顺序只关心中了几个号。所以后面比对时要用集合运算而不是逐位比较。3.2 随机数种子与可复现性调试阶段你肯定希望每次跑出来的结果是一样的这样才能定位问题。random.seed()就是干这个的random.seed(42) # 固定种子每次运行结果一致但在正式跑概率统计时不要固定种子。固定种子意味着你每次跑的都是同一组随机序列统计结果会有偏差。正确的做法是调试时固定种子正式统计时不设种子或者用系统时间自动播种。我踩过的一个坑是有一次跑了一百万次模拟结果中奖概率比理论值高了0.3个百分点查了半天以为是代码逻辑错了最后发现是测试时忘了去掉random.seed(42)那一组特定序列恰好偏向了某些号码组合。去掉种子之后结果就回归正常了。3.3 摇奖函数的性能考量random.sample在号码池不大的时候几十到几百性能完全够用。但如果你要模拟号码池上万、选号上百的场景random.sample内部实现是部分洗牌效率会下降。这时候可以用random.shuffle配合切片def draw_numbers_fast(pool_size, pick_count): pool list(range(1, pool_size 1)) random.shuffle(pool) return pool[:pick_count]实测下来在pool_size35、pick_count7的场景下两种方法差异可以忽略。但如果你要跑上亿次模拟shuffle版本会略快一点因为它避免了sample内部的一些边界检查。不过对于这个项目来说可读性优先random.sample是更好的选择。3.4 比对逻辑集合运算比循环快得多摇完号之后要拿你的选号和开奖号码比对看中了几个。最直观的写法是双重循环def count_matches_slow(my_numbers, drawn_numbers): count 0 for n in my_numbers: if n in drawn_numbers: count 1 return count但更Pythonic、也更快的方法是集合交集def count_matches(my_numbers, drawn_numbers): return len(set(my_numbers) set(drawn_numbers))集合交集在底层是哈希表操作时间复杂度接近O(min(m,n))而双重循环是O(m*n)。在pick_count7的情况下差异不大但如果扩展到选几十个号差距就明显了。养成用集合运算的习惯对以后处理更大规模的数据有好处。4. 中奖概率理论值、模拟值与感觉的差距4.1 组合数学理论概率怎么算在跑模拟之前先算理论概率这样你才知道模拟结果对不对。中奖概率的本质是组合数之比。从N个号码中选K个总的组合数是C(N, K)。你中k个号的组合数是从开奖的K个号中选k个再从剩下的N-K个号中选K-k个即C(K, k) * C(N-K, K-k)。所以中k个号的概率是P(k) C(K, k) * C(N-K, K-k) / C(N, K)用Python的math.comb可以直接算from math import comb def theoretical_probability(pool_size, pick_count, match_count): total comb(pool_size, pick_count) favorable comb(pick_count, match_count) * comb(pool_size - pick_count, pick_count - match_count) return favorable / total以35选7为例中7个号头奖的概率是1/C(35,7) 1/6724520大约是千万分之一点五。中6个号的概率约为万分之零点三。中3个号最低奖级的概率大约是百分之一点六。把这些数字列出来你就能直观感受到中头奖的概率比你出门被鸟屎砸中的概率还低。这不是夸张是数学。4.2 模拟统计跑一百万次看频率理论值有了接下来用模拟来验证。核心逻辑是跑N次每次摇一组号跟固定选号比对统计每个中奖个数的出现次数。def simulate_probability(rule, my_numbers, trials1_000_000): stats {k: 0 for k in rule[prize_tiers]} for _ in range(trials): drawn draw_numbers(rule[pool_size], rule[pick_count]) matches count_matches(my_numbers, drawn) if matches in stats: stats[matches] 1 return {k: v / trials for k, v in stats.items()}跑一百万次大概需要几秒到十几秒取决于机器性能。跑出来的频率应该跟理论值非常接近误差在千分之一以内。如果误差很大那说明代码有问题优先检查比对逻辑和奖级映射。提示模拟次数越多频率越接近理论概率这是大数定律的体现。但注意大数定律说的是趋近不是相等。跑一万次和跑一百万次结果的波动范围是不一样的。跑一万次时中头奖的次数可能是0也可能是2这都正常。4.3 为什么守号不会提高中奖率很多人相信守号——每期买同一组号码觉得坚持久了总会中。模拟器可以帮你验证这个想法。你固定一组号码跑一百万次中奖频率跟随机选号完全一样。因为每一期摇奖都是独立的你的号码不会因为守得久就变得更容易被摇出来。这个结论在模拟器里体现得非常清楚你把my_numbers固定成[1,2,3,4,5,6,7]跑一百万次中奖分布跟随机选号没有任何统计上的显著差异。号码没有记忆随机过程不会补偿任何人。4.4 模拟结果的波动范围与置信区间跑模拟的时候你可能会发现每次跑出来的中奖次数略有不同。这是正常的随机波动。如果你想给出一个更严谨的结论可以计算置信区间。对于二项分布当试验次数n很大、概率p很小时可以用正态近似标准误差 SE sqrt(p * (1-p) / n)95%置信区间大约是 p ± 1.96 * SE。比如中头奖概率p ≈ 1.5e-7跑n1e6次SE ≈ sqrt(1.5e-7 / 1e6) ≈ 1.2e-8置信区间大约是[1.3e-7, 1.7e-7]。这个区间很窄说明百万次模拟已经足够精确了。但如果你想区分更小的概率差异就需要更多次模拟。5. 保本概率一个比中奖概率更扎心的数字5.1 保本概率的定义与计算逻辑保本概率的定义是你买彩票的总投入刚好被中奖奖金覆盖或超过的概率。注意这不是中奖概率而是不亏钱概率。你可能中了很多次5块钱的小奖但投入了100块那还是亏的。计算逻辑是对每一次模拟计算这一注的投入price_per_ticket和回报根据中奖个数查奖金表然后统计回报 投入的次数占比。def simulate_breakeven(rule, my_numbers, trials1_000_000): breakeven_count 0 for _ in range(trials): drawn draw_numbers(rule[pool_size], rule[pick_count]) matches count_matches(my_numbers, drawn) prize rule[prize_tiers].get(matches, 0) if prize rule[price_per_ticket]: breakeven_count 1 return breakeven_count / trials以35选7、单注2元为例中3个号奖金5元已经超过2元投入所以保本。中4个号50元也保本。中5个号500元保本。中6个、7个更不用说。所以保本概率 P(中3个) P(中4个) P(中5个) P(中6个) P(中7个)。算出来大概是百分之一点七左右。也就是说你买100次大约只有不到2次是不亏的剩下98次以上都是纯亏。5.2 单注保本 vs 多注保本一个容易算错的坑如果你一次买多注保本概率的计算就复杂了。比如你买10注总投入20元那么保本意味着这10注的总奖金 20元。这时候不能简单地把单注保本概率乘以10因为各注之间不是独立事件它们共享同一个开奖结果。正确的做法是每次模拟摇一组号然后对这10注分别比对、分别算奖金汇总后判断是否保本。这个逻辑在代码里就是一个内层循环def simulate_multi_ticket_breakeven(rule, my_tickets, trials100_000): breakeven_count 0 total_cost len(my_tickets) * rule[price_per_ticket] for _ in range(trials): drawn draw_numbers(rule[pool_size], rule[pick_count]) total_prize 0 for ticket in my_tickets: matches count_matches(ticket, drawn) total_prize rule[prize_tiers].get(matches, 0) if total_prize total_cost: breakeven_count 1 return breakeven_count / trials我实测过买10注的保本概率比单注的1.7%高一些但也就百分之十几远没有到买得多就容易保本的程度。因为多注虽然增加了中奖机会但投入也成倍增加奖金的增长跟不上投入的增长。5.3 期望值彩票的数学本质比保本概率更本质的指标是期望值。期望值 所有可能结果的奖金乘以对应概率之和再减去投入。def expected_value(rule): ev 0 for match_count, prize in rule[prize_tiers].items(): p theoretical_probability(rule[pool_size], rule[pick_count], match_count) ev p * prize return ev - rule[price_per_ticket]以35选7为例算出来的期望值大概是负一点几元。也就是说你每买一注2元的彩票长期来看平均亏1块多。这个数字是彩票的抽水率——一部分奖金池被运营成本拿走了。任何彩票的期望值都是负的否则发行方就亏了。这是数学上的必然不是运气问题。5.4 奖金结构对保本概率的影响保本概率高度依赖奖金结构。如果最低奖级的奖金刚好等于或略高于票价那保本概率就等于中最低奖级及以上的概率。如果最低奖级奖金低于票价那保本概率会更低。你可以用模拟器做敏感性分析把最低奖级奖金从5元改成3元、2元、1元看保本概率怎么变。实测下来最低奖级奖金每降低1元保本概率可能下降零点几个百分点。这个分析对理解彩票设计很有帮助——发行方通过调整奖级和奖金精确控制着返奖率。6. 代码组织与性能优化让模拟器跑得更快更稳6.1 函数拆分与模块化一个可维护的模拟器应该拆成几个独立函数规则定义、摇奖、比对、单次模拟、批量统计、结果输出。每个函数只做一件事方便单独测试和替换。我习惯把规则定义放在最上面用字典或类来存。如果用类可以这样class LotteryRule: def __init__(self, pool_size, pick_count, price, prize_tiers): self.pool_size pool_size self.pick_count pick_count self.price price self.prize_tiers prize_tiers类比字典的好处是可以加方法比如get_prize(match_count)、validate()等。但对于这个项目字典已经够用类反而增加了复杂度。选择最简单的方案除非有明确理由不用它。6.2 用numpy加速大规模模拟如果你要跑上千万次甚至上亿次模拟纯Python循环会变慢。这时候可以用numpy做向量化操作。核心思路是一次性生成大量随机数然后批量比对。import numpy as np def simulate_numpy(rule, my_numbers, trials10_000_000): pool np.arange(1, rule[pool_size] 1) my_set set(my_numbers) # 批量生成开奖号码 draws np.array([np.random.choice(pool, rule[pick_count], replaceFalse) for _ in range(trials)]) # 批量比对 matches np.array([len(my_set set(d)) for d in draws]) # 统计 unique, counts np.unique(matches, return_countsTrue) return dict(zip(unique, counts / trials))numpy版本比纯Python快5到10倍但内存占用也更高。跑一千万次时draws数组会占用几百MB内存。如果内存不够可以分批跑每批一百万次最后汇总。注意np.random.choice在replaceFalse时对于大号码池性能不如random.sample。如果号码池很大建议还是用random.sample生成再转成numpy数组做后续统计。6.3 进度显示与中断处理跑长时间模拟时加一个进度显示会让人安心很多。可以用tqdm库也可以自己用print打点def simulate_with_progress(rule, my_numbers, trials): stats {} report_interval trials // 10 for i in range(trials): drawn draw_numbers(rule[pool_size], rule[pick_count]) matches count_matches(my_numbers, drawn) stats[matches] stats.get(matches, 0) 1 if (i 1) % report_interval 0: print(f进度: {(i1)/trials*100:.0f}%) return {k: v / trials for k, v in stats.items()}另外建议加上KeyboardInterrupt处理让用户可以用CtrlC中断模拟并输出当前统计结果。这在调试时很有用。6.4 结果输出与可视化模拟跑完之后把结果整理成表格输出比直接打印字典直观得多。可以用tabulate库也可以手动格式化def print_results(rule, simulated_probs): print(f{中奖个数:10}{理论概率:15}{模拟概率:15}{奖金:10}) for match_count in sorted(rule[prize_tiers], reverseTrue): p_theory theoretical_probability(rule[pool_size], rule[pick_count], match_count) p_sim simulated_probs.get(match_count, 0) prize rule[prize_tiers][match_count] print(f{match_count:10}{p_theory:15.8f}{p_sim:15.8f}{prize:10})如果想画图用matplotlib画一个柱状图对比理论值和模拟值一眼就能看出吻合程度。不过对于这个项目表格已经足够画图属于锦上添花。7. 实测中的几个反直觉发现7.1 中奖次数比想象中多但都是小奖跑一百万次模拟中3个号的次数大约有一万六千次看起来不少。但中4个号只有几百次中5个号只有几十次中6个号个位数中7个号基本是0。中奖次数多但绝大多数是最低奖级奖金刚好覆盖成本甚至不够。这就是为什么很多人觉得我经常中奖啊但算总账还是亏的。7.2 买得越多亏得越确定单注的期望值是负的买10注期望值就是负的10倍。模拟器跑出来的结果会告诉你买得越多总亏损的方差越小也就是说亏损越来越确定。买1注可能中个大奖翻盘买100注基本就是稳定亏损。这个结论跟直觉相反但数学上无懈可击。7.3 号码组合的冷热是错觉模拟器可以统计每个号码出现的频率。跑一百万次之后每个号码出现的频率都在理论值附近波动没有哪个号码显著偏多或偏少。所谓热号冷号在足够大的样本下都会回归均匀。你如果只跑几十次确实会看到某些号码出现得多但那只是小样本波动不代表任何趋势。7.4 保本概率对奖金结构极度敏感把最低奖级奖金从5元改成4元保本概率可能从1.7%降到1.2%。因为中3个号的概率是1.6%左右这部分人原本能保本奖金降低后就变成亏损了。这个敏感性分析说明彩票的返奖率是设计出来的不是随机形成的。发行方通过精算确保长期返奖率控制在一个固定比例。8. 把这个模拟器用出更多花样8.1 模拟不同彩票规则的对比你可以把多套规则放在一起跑对比它们的期望值和保本概率。比如35选7和49选6哪个更划算模拟器会告诉你答案。实测下来不同规则的期望值都是负的但负的程度不同。有些彩票返奖率设计得高一些亏损慢一些但长期来看都是亏。8.2 用模拟器做概率教学这个项目很适合用来教概率论。你可以让学生自己改参数、跑模拟、对比理论值直观理解组合数学和大数定律。比在黑板上写公式生动得多。我试过让完全没学过概率的人跑一遍他们很快就理解了独立事件和期望值的概念。8.3 扩展到其他随机场景这个模拟器的框架可以迁移到很多场景抽卡游戏的概率验证、骰子游戏的赔率计算、甚至A/B测试的显著性判断。核心逻辑都是生成随机结果 - 比对 - 统计频率。把彩票规则换成抽卡规则代码几乎不用改。8.4 代码复现的完整清单如果你想直接跑起来按这个清单操作安装Python 3.8以上版本标准库就够不需要额外装包。把规则定义、摇奖函数、比对函数、统计函数写在一个.py文件里。设置trials1000000运行观察输出。修改prize_tiers看保本概率怎么变。把my_numbers改成固定号码验证守号无效。如果想加速装numpy用向量化版本。整个项目代码量不大核心逻辑一两百行就能写完。但就是这么点代码能把彩票的数学本质扒得干干净净。我自己的体会是写完这个模拟器之后再看到中奖新闻心态完全不一样了——那不是幸运那是概率海洋里的一朵小浪花恰好被镜头拍到了而已。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →