尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

用Python分析电竞赛果:从NIP 2:1 WBG看晋级概率模拟

用Python分析电竞赛果:从NIP 2:1 WBG看晋级概率模拟 NIP 以 2:1 击败 WBG这个比分背后并不是一个简单的“谁赢谁输”问题。原评论里有一层非常典型的赛事数据分析逻辑希望 WBG 赢是因为 WBG 的胜利会改变积分分布从而让 IG 进入某个小组第一的概率变大。类似这样“一个结果影响另一支队伍出线概率”的判断如果靠人工在 Excel 里逐个累加小分很容易出错而且无法回答“落后一分的队伍接下来要连胜几场才能反超”这类衍生问题。这篇博客会以“NIP 2:1 WBG”这条赛果作为起点用 Python 从零实现一个电竞赛果分析小项目抓取比赛结果、按自定义积分规则计算小组排名、通过蒙特卡洛模拟估算各队晋级概率并把结果输出成报表。整体不依赖复杂赛事平台数据结构清晰适合当作数据采集、业务规则计算和自动化报表的练手项目。1. 为什么赛果分析不能只看大比分很多人在看到一场 BO3 结束后只关心“谁赢了输了几局”然后把这个大比分记到表格里。这个操作本身没有错但一旦要看小组排名和晋级概率只记大比分远远不够。1.1 排名的竞争是多因子叠加以常见的分组积分赛为例两支战队大比分可能相同比如都是 4 胜 2 负但其中一支队伍的小局胜场明显更多或者净胜场更高。在积分规则里这通常会成为区分排名的关键依据。假设 NIP 以 2:1 赢下 WBG那么 NIP 会得到 1 个大分同时小分从原来的基础上增加 2 胜 1 负WBG 则是 1 负局。这个结果会改变两支队伍的相对排名也会间接影响它们所在小组里其他队伍的压力。如果只看 2:1 这个结果你只能说“NIP 赢了”但如果你想知道 IG 是不是还有机会争小组第一就必须同时看NIP 当前的大分是多少WBG 还剩多少场比赛IG 和这两支队伍之间的相互战绩后续剩余赛程里哪些队伍会碰面。这些问题叠加在一起人工统计会非常痛苦。1.2 单场结果的边际影响原评论中的判断“希望 WBG 赢 NIP这样 IG 进涅槃第一机会更大”本质是在比较两种赛果的边际影响如果 NIP 赢NIP 的积分会继续增加IG 想追第一就需要自己赢且 NIP 输如果 WBG 赢NIP 就没有拿到这 1 个大分IG 在积分榜上的差距不会进一步扩大。这种逻辑用程序来计算就是把“不同赛果”分别代入排名计算器然后对比 IG 拿到第一名概率的差异。1.3 用程序解决问题的思路整个分析可以拆成四条链路数据采集拿到比赛双方、比分、日期、阶段。数据清洗把大比分割成积分、小分、净胜场。规则计算根据积分规则生成当前小组排名。场景模拟对剩余赛程做多次随机推演统计各队晋级概率。这四条链路是本文的最小闭环。后面每一节都会围绕它们展开。2. 环境准备与项目初始化在实际写代码之前先把环境对齐。这个项目对系统没有特殊要求Windows、macOS、Linux 都可以跑。2.1 技术选型和安装依赖我会使用 Python 3.10 以上的版本因为本文代码会用到str | Path这类类型语法。依赖库选择尽量精简requests负责 HTTP 请求beautifulsoup4负责解析 HTMLpandas负责数据清洗和汇总pytest负责核心函数测试。软件/依赖建议版本作用Python3.10运行脚本和类型提示requests2.31抓取比赛页面beautifulsoup44.12解析 HTMLpandas2.1数据清洗与聚合pytest7.4单元测试创建虚拟环境并安装依赖python -m venv venv source venv/bin/activate pip install requests beautifulsoup4 pandas pytest如果是在 Windows 上激活命令是venv\Scripts\activate。也可以把依赖写进requirements.txt方便其他人重现环境。注意具体版本会随着时间更新进入新项目时要先确认当前可用的稳定版本。不要直接照抄旧项目里的版本号。2.2 项目目录结构项目采用一个简单的分层结构把数据、逻辑、脚本和测试分开lpl_rank_analyzer/ ├── data/ │ ├── matches.csv │ ├── standings.csv │ └── simulation.json ├── analyzer/ │ ├── __init__.py │ ├── models.py │ ├── parser.py │ ├── standings.py │ └── simulator.py ├── scripts/ │ ├── crawl_matches.py │ ├── compute_standings.py │ └── simulate_playoffs.py ├── tests/ │ ├── test_standings.py │ └── test_simulator.py └── requirements.txt这个结构的好处是数据文件、核心逻辑、入口脚本相互独立。以后增加新的比赛数据来源只需要改parser.py增加新的排名规则只需要改standings.py换报表展示层不影响模拟逻辑。2.3 先准备一份样例赛果数据学习阶段不建议直接抓取整站数据因为反爬机制和站点条款都不确定。可以先手动维护一份data/matches.csv把样例赛果放进去。season,stage,group,date,team_a,team_b,score_a,score_b 2025,regular,A,2025-01-01,NIP,WBG,2,1 2025,regular,A,2025-01-02,IG,NIP,1,2 2025,regular,A,2025-01-03,WBG,IG,0,2 2025,regular,A,2025-01-05,IG,WBG,2,0 2025,regular,A,2025-01-06,NIP,IG,2,1上面这些是演示数据用来验证代码逻辑。真实项目里数据应该来自官方出道的接口或经过授权的数据源。3. 把比赛结果落成结构化数据程序计算排名第一步要有一个清晰的数据模型。不能一边解析一边用字典堆字段否则后面排名和模拟都会变得难以维护。3.1 定义比赛结果数据模型使用 Pythondataclass定义一场比赛from dataclasses import dataclass dataclass class MatchResult: season: str stage: str group: str date: str team_a: str team_b: str score_a: int score_b: int property def winner(self) - str: if self.score_a self.score_b: return self.team_a if self.score_b self.score_a: return self.team_b return draw property def total_games(self) - int: return self.score_a self.score_b这里把score_a和score_b设计成小局比分而不是大分。比如 BO3 中 NIP 2:1 WBG对应score_a2score_b1。大分是后面根据小局胜负计算出来的。3.2 从 CSV 导入比赛数据定义读取函数统一处理编码、文件缺失和字段转换import csv from pathlib import Path from analyzer.models import MatchResult def load_matches(filepath: str | Path) - list[MatchResult]: filepath Path(filepath) if not filepath.exists(): raise FileNotFoundError(f比赛数据文件不存在: {filepath}) matches [] with filepath.open(r, encodingutf-8-sig) as fp: reader csv.DictReader(fp) for row in reader: matches.append( MatchResult( seasonrow[season].strip(), stagerow[stage].strip(), grouprow[group].strip(), daterow[date].strip(), team_arow[team_a].strip(), team_brow[team_b].strip(), score_aint(row[score_a]), score_bint(row[score_b]), ) ) return matches使用utf-8-sig编码可以兼容带 BOM 的 Excel 导出文件避免中文表头出现\ufeff前缀。3.3 从页面抓取数据的通用写法如果后续确实要抓取网页可以保留一个最小实现import requests from bs4 import BeautifulSoup url https://example.com/matches headers {User-Agent: Mozilla/5.0} resp requests.get(url, headersheaders, timeout10) soup BeautifulSoup(resp.text, html.parser) for row in soup.select(table tr): cells [cell.get_text(stripTrue) for cell in row.find_all(td)] if len(cells) 6: print(cells)这里只做示例。真实抓取前要确认目标站点是否允许爬虫遵守robots.txt和用户协议并控制请求频率。更推荐的做法是寻找官方 API 或开放数据集而不是解析 HTML。注意抓取接口拿到数据后字段可能不是干净的。要做重复比赛去重、比分数值校验、日期格式统一。3.4 数据清洗的三个检查点每次拿到新数据至少检查三件事日期是否完整有没有缺省值两支队伍是否相同避免出现“NIP vs NIP”这种无效记录小局比分是否符合项目规则比如 BO3 不可能出现3:0。如果发现异常应该直接报错或跳过而不是让脏数据进入积分计算。4. 按积分规则计算当前排名有了比赛数据后下一步是把每一场的结果累积到队伍维度再用排名规则排序。4.1 积分规则的通用设计不同联赛的排名规则会有差异。这里定义一个可配置的示例规则优先级指标含义1大分BO3 获胜场次胜一场 12净胜场总胜局数 - 总负局数3总局胜场只看赢得的小局总数4相互战绩两队同名次时按胜负关系判断在工程里不建议把排序逻辑写死在函数里应该拆成一个可组合的排序键。4.2 定义队伍累计数据先定义TeamRecord记录队伍累计数据from dataclasses import dataclass from analyzer.models import MatchResult dataclass class TeamRecord: team: str wins: int 0 losses: int 0 game_wins: int 0 game_losses: int 0 head_to_head: dict None def __post_init__(self): if self.head_to_head is None: self.head_to_head {} property def score(self) - int: return self.wins property def game_diff(self) - int: return self.game_wins - self.game_losses def add_match(self, match: MatchResult) - None: if match.team_a self.team: self.game_wins match.score_a self.game_losses match.score_b if match.score_a match.score_b: self.wins 1 else: self.losses 1 elif match.team_b self.team: self.game_wins match.score_b self.game_losses match.score_a if match.score_b match.score_a: self.wins 1 else: self.losses 1add_match的核心逻辑是把一场比赛同时累加到两个队伍的数据上但只更新属于当前队伍的那一侧。4.3 生成排名表提供公开函数根据比赛列表生成排名from collections import defaultdict from analyzer.models import MatchResult from analyzer.standings import TeamRecord def build_standings(matches: list[MatchResult]) - list[TeamRecord]: records defaultdict(TeamRecord) for match in matches: records[match.team_a].team match.team_a records[match.team_b].team match.team_b records[match.team_a].add_match(match) records[match.team_b].add_match(match) if match.winner ! draw: records[match.team_a].head_to_head[match.team_b] ( records[match.team_a].head_to_head.get(match.team_b, 0) (1 if match.winner match.team_a else 0) ) records[match.team_b].head_to_head[match.team_a] ( records[match.team_b].head_to_head.get(match.team_a, 0) (1 if match.winner match.team_b else 0) ) standings [records[name] for name in records] standings.sort( keylambda r: ( -r.score, -r.game_diff, -r.game_wins, r.losses, ) ) return standings排序时使用-号是因为 Python 列表排序默认是升序取负号可以把大值排到前面。4.4 分析样例数据的排名结果跑完build_standings后输出大致类似team,score,game_wins,game_losses,game_diff NIP,3,6,3,3 IG,2,4,4,0 WBG,1,2,5,-3这个结果对应 4.3 节的样例 CSV 数据只用来验证代码。真实赛季数据量很大同样一套函数可以支持几十支队伍。4.5 常见坑排序规则写错最容易犯的错误是排序条件与规则文档不一致。比如先看总胜局而不是净胜场会导致两支同大分队伍的顺序颠倒。解决办法是把排名比较器单独拆开写单元测试。不要在生产脚本里手动观察排序结果。5. 用蒙特卡洛模拟估算晋级概率静态排名只能告诉你现在谁排第几。要回答“IG 进涅槃第一机会有多大”必须考虑剩余赛程的所有可能组合这就是蒙特卡洛模拟的用武之地。5.1 为什么要做随机模拟剩余赛程不是一个固定结果。后面对手不同、队伍状态不同会导致最终积分榜有多种可能。枚举所有组合在队伍多的时候不可行所以用随机模拟对剩余每一场比赛随机指定一个胜者更新队伍积分和净胜场算一次最终排名重复 1 万次以上统计目标事件出现次数。5.2 模拟器核心函数这里实现一个简化版本的模拟器import random from analyzer.models import MatchResult from analyzer.standings import TeamRecord def simulate_remaining( standings: list[TeamRecord], remaining: list[MatchResult], target_team: str, n_sim: int 10000, win_prob: float 0.5, seed: int 42, ) - dict[str, float]: random.seed(seed) first_count 0 second_count 0 total n_sim for _ in range(total): records { rec.team: TeamRecord( teamrec.team, winsrec.wins, lossesrec.losses, game_winsrec.game_wins, game_lossesrec.game_losses, ) for rec in standings } for match in remaining: if random.random() win_prob: winner, loser match.team_a, match.team_b win_score 2 lose_score 1 else: winner, loser match.team_b, match.team_a win_score 2 lose_score 1 win_match MatchResult( seasonmatch.season, stagematch.stage, groupmatch.group, datematch.date, team_awinner, team_bloser, score_awin_score, score_blose_score, ) records[winner].add_match(win_match) final_list sorted( records.values(), keylambda r: (-r.score, -r.game_diff, -r.game_wins, r.losses), ) if final_list and final_list[0].team target_team: first_count 1 elif len(final_list) 1 and final_list[1].team target_team: second_count 1 return { first: first_count / total * 100, second: second_count / total * 100, other: (1 - (first_count second_count) / total) * 100, }这里有一个简化假设剩余的比赛都按win_prob0.5随机分配胜负也就是默认双方实力五五开。真实项目可以用 Elo 模型、历史交手记录或赔率数据来动态计算每场的胜率。注意模拟结果不代表真实未来它回答的是“在当前规则和剩余赛程假设下各结果出现的频率”。5.3 比较不同赛果对晋级概率的影响回到最开始的场景当前赛果是 NIP 2:1 WBG关注 IG 拿到小组第一的概率。我们可以构造两个对照组基准组保留实际赛果 NIP 2:1 WBG反事实组把这场比赛改成 WBG 2:1 NIP。两组都模拟 IG 最终拿到小组第一的概率。如果反事实组的概率高于基准组说明“希望 WBG 赢”的观点在数据上成立。这个对比过程不需要预测未来只需要确定性的赛果替换和相同的剩余赛程集合就能算出单场比赛对不同队伍晋级概率的边际影响。5.4 模拟次数和随机种子模拟次数太少结果波动会很大模拟次数太多计算时间会上升。一般建议目的模拟次数快速验证流程1000日常分析10000发布前详细报告50000 以上设置固定seed可以保证脚本可重复。没有固定随机种子每次运行概率都会略有差异。6. 结果输出与验证写完核心逻辑后需要有明确的运行和验证方式。否则代码只能在编辑器里“看起来正确”。6.1 生成排名报表把排名结果写成 Markdown 或 CSVimport csv from pathlib import Path from analyzer.standings import build_standings def export_standings(matches, output_path: str): standings build_standings(matches) output_path Path(output_path) output_path.parent.mkdir(parentsTrue, exist_okTrue) with output_path.open(w, encodingutf-8-sig, newline) as fp: writer csv.writer(fp) writer.writerow([rank, team, score, game_wins, game_losses, game_diff]) for rank, rec in enumerate(standings, start1): writer.writerow([rank, rec.team, rec.score, rec.game_wins, rec.game_losses, rec.game_diff])将结果写入data/standings.csv之后可以用 Excel 或 Python 读取查看。6.2 用 pytest 验证核心函数排名计算和模拟逻辑很容易悄悄出错建议给核心函数加测试from analyzer.models import MatchResult from analyzer.standings import TeamRecord def test_add_match_updates_score(): match MatchResult(2025, regular, A, 2025-01-01, NIP, WBG, 2, 1) rec TeamRecord(NIP) rec.add_match(match) assert rec.wins 1 assert rec.game_wins 2 assert rec.game_losses 1 assert rec.game_diff 1运行测试pytest -q如果测试失败优先检查数据读取和add_match的字段方向而不是先怀疑模拟逻辑。6.3 如何解读模拟输出一次模拟可能输出IG 拿到小组第一的概率: 42.3% IG 拿到小组第二的概率: 35.1% IG 进入其他名次的概率: 22.6%这组数字在演示数据下成立。生产环境要看具体赛季、剩余赛程和规则。如果“希望 WBG 赢”的反事实模拟结果是48.7%说明在模型假设下这个观点确实有数据支撑。实际项目中分析师会继续验证不同胜率参数下结论是否稳定减少或增加某场比赛后概率变化结果对净胜场的小分是否敏感。6.4 赛果数据核对清单在输出任何结论前建议按以下清单核对数据链路[ ] 每场比赛的小局比分是否在同一天内重复出现[ ] 队伍名称是否统一比如 “IG”、“iG” 是否被当作同一支队伍[ ] 剩余赛程是否包含已完成的比赛[ ] 排名规则的优先级是否与赛事官方一致[ ] 随机模拟是否固定了随机种子[ ] 概率结果是否标注了模拟次数和参数假设。这份清单同样适用于其他类似赛事的晋级分析项目。7. 常见问题排查7.1 抓取返回 403 或页面为空现象requests.get返回 403或者 HTML 里没有比赛表格。可能原因目标站点有反爬缺少 User-Agent或者页面结构已经变化。检查方式resp.raise_for_status() print(resp.status_code, resp.headers.get(content-type)) print(resp.text[:500])处理建议设置合理的 User-Agent、控制请求间隔、优先寻找官方 API。如果只是学习用本地 CSV 作为数据源即可。7.2 排名对不上现象排名结果和赛事官网不一致。可能原因排序规则顺序写错或小局比分理解错误。检查方式取两支队伍的历史比赛手工计算大分、净胜场再对比程序输出。处理建议把排序条件抽成_sort_key函数并为每种规则写独立测试。7.3 模拟概率每次都不一样现象同一份数据重复运行概率结果差几个百分点。可能原因没有固定随机种子或模拟次数太少。处理建议在simulate_remaining中显式设置seed并把模拟次数提高到 10000 以上。7.4 常见问题汇总问题现象可能原因检查方式处理建议CSV 中文乱码编码不一致查看文件头部字符使用utf-8-sig读写队伍名称无法合并IG和iG混用打印唯一队伍集合统一定义队伍别名映射加比赛后排名不动新旧 CSV 数据叠加检查是否重复读取同一文件清理历史数据或按赛季过滤概率稳定但不符合预期剩余赛程设置错误打印剩余比赛场次核对赛程表日期和参赛双方脚本在 Windows 下乱码控制台编码问题查看locale.getpreferredencoding()使用 UTF-8 输出并设置终端编码8. 最佳实践与扩展方向这个赛果分析项目虽然小但它覆盖了一条完整的数据处理链路。如果要在生产环境中使用还有几个地方需要加强。8.1 保持数据与规则分离积分规则、赛程数据、概率参数不应该散落在代码里。建议用配置文件管理season: 2025 stage: regular group: A ranking_priority: - score - game_diff - game_wins - head_to_head simulation: n_sim: 10000 seed: 42 default_win_prob: 0.5这样当官方调整规则时不需要改 Python 代码只需要修改配置并重新跑脚本。8.2 生产环境需要补强的部分本地脚本可以跑通但生产级系统通常还要考虑使用数据库替代 CSV避免并发写入冲突用定时任务自动拉取赛果并更新排名增加日志和监控记录每次抓取失败的原因对关键数据保留历史快照方便回朔模拟结果增加置信区间而不是只输出单个概率。接入实时胜率和选手状态数据让模拟更接近真实。8.3 扩展方向这个项目可以从四个方向继续迭代加入选手数据把每局 KDA、出场英雄、MVP 等信息纳入分析引入 Elo 模型根据历史比赛动态计算每场胜率替代固定win_prob0.5搭建可视化看板用 Flask ECharts 展示积分榜和概率变化支持多赛季对比分析同一支队伍在不同版本的效率变化。8.4 新手的练习建议不要一开始就追求覆盖整个赛季。先用 10 场左右的本地 CSV 数据跑通排名然后加入 5 场剩余赛程模拟最后再接入真实抓取。每一步都写一个测试等到排查问题时你会很快定位是数据问题、规则问题还是模拟参数问题。判断一个赛果分析系统是否可靠不是看它能不能算出当前排名而是看当数据输入、规则配置和随机参数变化时系统是否能给出稳定、可解释的结果。这也是“NIP 2:1 WBG”这种单场赛果背后最值得写代码解决的问题。
返回列表