从 TI15 小组赛 VG 让一追二说起:DOTA2 赛事数据可视化分析实战教程
TI15 国际邀请赛小组赛阶段,VG 对阵 GL 的这场 BO3 打得相当过瘾。VG 首局失利后没有崩心态,第二局开始调整节奏,最终让一追二成功晋级 2-1 组。尤其是 XM 操刀的滚滚(石鳞剑士),几波游走节奏直接把 GL 的阵容拉扯得支离破碎,这种“游龙”级别的表现,赛后自然成为弹幕和论坛的讨论焦点。
不过作为一名关注比赛的开发老哥,除了看比赛、刷集锦之外,我更好奇一个事:能不能把比赛里的关键数据抓下来,做成可视化分析,看看 XM 的滚滚到底“游”在哪一路、“滚”出了多少团队收益?
这篇文章就围绕“DOTA2 比赛数据可视化分析”这个主题展开。我会结合 VG vs GL 这场 TI15 小组赛的真实背景,带大家从比赛 ID 获取、数据分析、关键事件提取到可视化呈现,完整走一遍流程。无论你是刚接触 DOTA2 API 的新手,还是想写一个赛事复盘小工具的进阶玩家,这篇文章都能给你一个可落地的思路。
1. 比赛数据分析能做什么
1.1 数据回放的价值
观赛体验通常停留在“看个热闹”的层面,比如谁拿了人头、谁推了塔、哪波团战赢了。但比赛数据能告诉我们更多:
- XM 的滚滚在游戏时间 12 分钟到 20 分钟之间做了多少次有效游走?
- 每次游走是否成功逼出对方核心的 BKB 或者传送门?
- VG 让一追二的关键转折点,在数据层面有什么异常波动?
- 团队经济差、经验差的曲线,在哪一分钟出现“剪刀差”?
这些问题的答案都藏在比赛的详细数据里。通过赛后数据分析,我们可以把一场 BO3 拆解成时间轴 + 地图事件 + 经济曲线的组合,从而更客观地还原比赛进程。
1.2 应用场景
比赛数据可视化分析并不仅限于职业比赛复盘。它可以延伸到:
- 自己开黑后的录像分析,看看哪个时间点掉点最严重;
- 自定义地图或练习模式的效率评估;
- 构建战队数据库,长期追踪选手英雄池与胜率关系;
- 做赛事内容运营时,快速生成数据截图和动态图表。
与其在各种数据网站上等别人做好图,不如自己动手写一套流程,想分析哪场就分析哪场。
2. 环境准备与工具链
2.1 本教程使用的核心工具
为了完成这次分析,我建议按下面的工具链来搭建环境:
- Python 3.9+:用于数据获取、清洗与分析。
- Requests:调用 DOTA2 数据接口。
- Pandas:处理表格化数据,尤其是英雄经济和经验曲线。
- Matplotlib / Seaborn:绘制经济曲线、击杀时间轴等静态图。
- Plotly:如果需要交互式的图表,可以做 HTML 版本。
版本方面不需要太纠结,Python 3.8 以上都能跑通。如果你用的是较新的 Python 3.11 或 3.12,只要 Pandas 和 Matplotlib 正常安装即可。
2.2 数据来源说明
DOTA2 比赛数据主要通过 Valve 的官方 API 和第三方数据平台暴露。常用的有两个:
- OpenDota API:开源,能查比赛详情、选手信息、英雄数据,实际开发中用得非常多。
- STRATZ API:基于 GraphQL,数据更丰富,需要 Token。
本文以 OpenDota 的公开接口为例来写代码,不需要 Token 就能查询大部分公开比赛数据,入门门槛较低。
如果你准备长期大量抓取数据,建议注册一个 OpenDota 账号并申请 API Key,速率限制会更宽松。
2.3 目录结构
建议先创建一个干净的工程目录,后面所有脚本都放在里面:
dota2-analysis/ ├── main.py # 主流程入口 ├── data/ │ ├── match_data.json # 原始比赛数据 │ └── processed.csv # 处理后的分析数据 └── output/ └── analysis_report/ ├── economy_curve.png ├── event_timeline.png └── xm_roll_track.png在动手写代码之前,先确认一下你的 Python 环境里已经装好依赖:
pip install requests pandas matplotlib plotly装完以后,就可以开始写数据获取脚本了。
3. DOTA2 比赛数据接口的调用方法
3.1 OpenDota API 基本结构
OpenDota 提供很多接口,但我们的分析主要依赖两个:
https://api.opendota.com/api/matches/{match_id}这个接口会返回一场比赛的完整数据,包括:
- players:每个选手的详细数据
- objectives:建筑物摧毁事件
- teamfights:团战数据
- radiant_win:天辉是否获胜
- duration:比赛时长(秒)
match_id 在比赛结束后可在客户端、网页端或者各种数据站中找到。对于已经打过的 TI15 小组赛,你可以通过 OpenDota 的搜索接口找到 VG 和 GL 的交手记录。
3.2 调用示例
先看一个最简单的请求代码,用来测试网络连通性和接口返回格式。
import requests import json def fetch_match(match_id: int): url = f"https://api.opendota.com/api/matches/{match_id}" headers = { "User-Agent": "Mozilla/5.0 (compatible; dota2-analysis/1.0)" } resp = requests.get(url, headers=headers, timeout=30) resp.raise_for_status() return resp.json() if __name__ == "__main__": # 这里替换成你要分析的比赛 ID test_id = 1234567890 data = fetch_match(test_id) print("比赛时长:", data.get("duration"), "秒") print("天辉胜利:", data.get("radiant_win")) print(json.dumps(data, ensure_ascii=False)[:500])这里有一个重要提醒:比赛 ID 不要乱猜,请用真实存在的比赛 ID。你可以先在 OpenDota 官网搜索 VG 或 GL 的战队名,找到对应比赛后再填入。
3.3 搜索战队比赛记录
如果你不知道 VG 和 GL 在 TI15 的具体比赛 ID,可以先通过战队搜索接口进行查询。
OpenDota 的战队信息接口如下:
https://api.opendota.com/api/teams/{team_id}/matches不过战队 ID 也需要现查。更直接的办法是用“选手比赛记录”接口:
https://api.opendota.com/api/players/{account_id}/recentMatches这个接口会返回某位选手最近的比赛记录,里面包含 match_id。你可以先找到 XM 的账号 ID,然后拉取他的近期比赛,过滤出当天对阵 GL 的那一场。
import requests def get_recent_matches(account_id: int, limit: int = 5): url = f"https://api.opendota.com/api/players/{account_id}/recentMatches" params = {"limit": limit} resp = requests.get(url, params=params, timeout=30) resp.raise_for_status() return resp.json() if __name__ == "__main__": # 示例账号 ID,请替换成 XM 的实际数字 ID account_id = 123456789 matches = get_recent_matches(account_id) for m in matches[:3]: print(m["match_id"], m["hero_id"], m.get("kills"), m.get("deaths"), m.get("assists"))这里要注意:如果接口返回 429 状态码,说明请求频率超过了限制。这时需要增加 sleep 间隔或使用 API Key。
4. 比赛关键数据提取与处理
4.1 选手数据解构
拿到比赛 JSON 后,第一步是把 players 数组里的数据提取成结构化表格。OpenDota 的每个玩家对象包含几十个字段,我们重点关注以下内容:
- account_id:玩家 ID
- hero_id:使用的英雄 ID
- kills / deaths / assists:KDA
- gold_per_min:每分钟金钱
- xp_per_min:每分钟经验
- hero_damage:英雄伤害
- tower_damage:建筑伤害
- last_hits:补刀数
- denies:反补数
- item_0 到 item_5:装备槽位
如果我们要分析 XM 的滚滚表现,可以这样处理:
import pandas as pd def extract_players(match_data): players = [] for player in match_data.get("players", []): row = { "account_id": player.get("account_id"), "hero_id": player.get("hero_id"), "kills": player.get("kills"), "deaths": player.get("deaths"), "assists": player.get("assists"), "gold_per_min": player.get("gold_per_min"), "xp_per_min": player.get("xp_per_min"), "hero_damage": player.get("hero_damage"), "tower_damage": player.get("tower_damage"), "last_hits": player.get("last_hits"), "denies": player.get("denies"), } rows.append(row) return pd.DataFrame(rows) df_players = extract_players(match_data) print(df_players)处理后你会看到一个 10 行左右的表格,每个英雄一行,后面的分析就可以基于这个结构进行。
4.2 经济经验曲线提取
经济曲线是复盘 BO3 最直观的数据维度。OpenDota 的比赛详情中有一个字段叫“radiant_gold_adv”,它记录的是“天辉经济领先值”。如果这个值大于 0,说明天辉领先;小于 0,则夜魇领先。
我们可以用这个字段画出全场比赛的经济差走势。
import matplotlib.pyplot as plt def plot_gold_advantage(match_data): gold_adv = match_data.get("radiant_gold_adv", []) xp_adv = match_data.get("radiant_xp_adv", []) minutes = [i / 60 for i in range(len(gold_adv))] plt.figure(figsize=(12, 5)) plt.subplot(1, 2, 1) plt.plot(minutes, gold_adv, label="经济领先", color="#d62728") plt.axhline(0, color="gray", linestyle="--", linewidth=0.8) plt.xlabel("分钟") plt.ylabel("天辉 - 夜魇 经济差") plt.title("团队经济走势") plt.grid(True, alpha=0.3) plt.subplot(1, 2, 2) plt.plot(minutes, xp_adv, label="经验领先", color="#1f77b4") plt.axhline(0, color="gray", linestyle="--", linewidth=0.8) plt.xlabel("分钟") plt.ylabel("天辉 - 夜魇 经验差") plt.title("团队经验走势") plt.grid(True, alpha=0.3) plt.tight_layout() plt.savefig("output/economy_curve.png", dpi=160) plt.show()这段代码会自动生成两张子图。如果 VG 是在后期完成翻盘,你会在经济曲线上看到一个明显的 V 形反转,那是判断节奏拐点的核心依据。
4.3 事件时间轴提取
另一个有价值的数据字段是 objectives,它记录了推塔、打肉山等关键事件。我们可以把这些事件投影到时间轴上,标注出团队节奏变化。
def extract_objectives(match_data): objectives = [] for obj in match_data.get("objectives", []): objectives.append({ "time": obj.get("time"), "type": obj.get("type"), "key": obj.get("key"), "team": obj.get("team") # 0 或 1,对应天辉/夜魇 }) return pd.DataFrame(objectives) df_obj = extract_objectives(match_data) print(df_obj.head(20))这里要注意,objectives 并不包含击杀事件,只包含建筑和 Roshan 事件。如果你想看击杀时间点,需要解析每个玩家的 kill 事件列表,这部分数据相对复杂,后面会介绍一个更简单的方法。
5. 可视化呈现:把数据变成“剪辑版”
5.1 击杀时间线图
比赛里的击杀事件可以通过遍历玩家数据中的“kills_log”字段来获取。每个玩家的 kills_log 都记录了击杀时间、被杀目标、是否助攻等信息。把这些数据聚合起来,就能得到全场比赛的击杀时间轴。
from collections import defaultdict def build_kill_timeline(match_data): kill_events = defaultdict(list) for player in match_data.get("players", []): is_radiant = player.get("player_slot") < 128 for kill in player.get("kills_log", []): minute = int(kill["time"] // 60) kill_events[minute].append({ "team": "天辉" if is_radiant else "夜魇", "killer": player.get("name") or player.get("account_id"), "target": kill.get("target"), "time": kill["time"] }) return kill_events timeline = build_kill_timeline(match_data) for minute in sorted(timeline.keys()): print(f"第{minute}分钟: {len(timeline[minute])}个击杀")这里有一个关键细节:player_slot 小于 128 表示天辉,大于等于 128 表示夜魇。这个判断标准是 OpenDota 的固定规则,不要搞反。
5.2 绘制团队击杀对比柱状图
我们可以把击杀时间轴画成按分钟分组的柱状图,直观对比天辉和夜魇的节奏。
def plot_kill_comparison(timeline, duration_minutes): radiant_kills = [0] * (duration_minutes + 1) dire_kills = [0] * (duration_minutes + 1) for minute, events in timeline.items(): for ev in events: if ev["team"] == "天辉": radiant_kills[minute] += 1 else: dire_kills[minute] += 1 minutes = list(range(duration_minutes + 1)) plt.figure(figsize=(14, 5)) plt.bar(minutes, radiant_kills, label="天辉击杀", color="#2ca02c", alpha=0.7) plt.bar(minutes, dire_kills, label="夜魇击杀", color="#d62728", alpha=0.7, bottom=radiant_kills) plt.xlabel("分钟") plt.ylabel("击杀数") plt.title("按分钟击杀分布") plt.legend() plt.grid(True, axis="y", alpha=0.3) plt.savefig("output/kill_timeline.png", dpi=160) plt.show()这种方式虽然简单,但在复盘时非常实用。比如 XM 的滚滚在 12 分钟、16 分钟、22 分钟各带了一波节奏,你会在柱状图上看到对应分钟区间内天辉击杀柱突然变高。
5.3 选手游走路径还原(基础版)
要真正做“游走路线”的可视化,需要用到比赛内的 position 数据,也就是玩家在地图上的坐标。OpenDota 接口不直接提供完整的逐秒坐标,但有一部分字段可以通过match_data["players"][i]["times"]和["positions"]拿到。
这些字段不一定在每次接口调用中都返回,如果缺失,我们可以用团战数据来近似还原 XM 的主要活动区域。
def extract_tf_positions(match_data, account_id): positions = [] for tf in match_data.get("teamfights", []): for player in tf.get("players", []): if player.get("account_id") == account_id: positions.append({ "time": tf.get("start"), "x": player.get("pos_x"), "y": player.get("pos_y"), "deaths": player.get("deaths"), "kills": player.get("kills") }) return pd.DataFrame(positions)拿到这些位置后,就可以在 DOTA2 地图底图上叠加散点,分析 XM 在关键团战发生时的站位。
如果你有更强的算法基础,还可以进一步做热力图,分析他的高频活动区域,但作为一篇入门实战教程,先做到散点标注已经足够。
6. 完整实战:VG vs GL 比赛分析脚本
6.1 准备比赛 ID
假设你已经从 OpenDota 上找到了 VG 对阵 GL 的那场小组赛 match_id。下面我们写一个综合脚本,把前面几个功能整合成一条命令执行。
完整的代码结构如下:
import requests import pandas as pd import matplotlib.pyplot as plt from collections import defaultdict # ---------- 数据获取 ---------- def fetch_match(match_id): url = f"https://api.opendota.com/api/matches/{match_id}" resp = requests.get(url, timeout=30) resp.raise_for_status() return resp.json() # ---------- 玩家数据提取 ---------- def extract_players(match_data): players = [] for p in match_data.get("players", []): players.append({ "玩家ID": p.get("account_id"), "英雄ID": p.get("hero_id"), "击杀": p.get("kills"), "死亡": p.get("deaths"), "助攻": p.get("assists"), "GPM": p.get("gold_per_min"), "XPM": p.get("xp_per_min"), "英雄伤害": p.get("hero_damage"), "建筑伤害": p.get("tower_damage"), "补刀": p.get("last_hits"), "反补": p.get("denies"), }) return pd.DataFrame(players) # ---------- 经济曲线 ---------- def plot_economy(match_data): gold_adv = match_data.get("radiant_gold_adv", []) xp_adv = match_data.get("radiant_xp_adv", []) minute = [i / 60 for i in range(len(gold_adv))] fig, axes = plt.subplots(2, 1, figsize=(12, 8)) axes[0].plot(minute, gold_adv, color="#d62728") axes[0].axhline(0, color="gray", linestyle="--", linewidth=0.8) axes[0].set_title("经济领先走势") axes[0].set_ylabel("经济差") axes[0].grid(True, alpha=0.3) axes[1].plot(minute, xp_adv, color="#1f77b4") axes[1].axhline(0, color="gray", linestyle="--", linewidth=0.8) axes[1].set_title("经验领先走势") axes[1].set_ylabel("经验差") axes[1].set_xlabel("比赛分钟") axes[1].grid(True, alpha=0.3) plt.tight_layout() plt.savefig("output/economy_curve.png", dpi=160) print("经济曲线图已保存到 output/economy_curve.png") # ---------- 击杀时间轴 ---------- def build_timeline(match_data): timeline = defaultdict(list) for p in match_data.get("players", []): team = "天辉" if p.get("player_slot") < 128 else "夜魇" for kill in p.get("kills_log", []): minute = int(kill["time"] // 60) timeline[minute].append({"team": team, "time": kill["time"]}) return timeline # ---------- 主流程 ---------- if __name__ == "__main__": MATCH_ID = 1234567890 # 替换成真实比赛 ID match_data = fetch_match(MATCH_ID) df_players = extract_players(match_data) print("=== 选手数据 ===") print(df_players) plot_economy(match_data) timeline = build_timeline(match_data) print("=== 按分钟击杀统计(前10分钟) ===") for minute in sorted(timeline.keys())[:10]: print(f"第{minute}分钟: {len(timeline[minute])}个击杀")6.2 运行验证
在命令行运行:
python main.py如果网络正常,你会看到按玩家列出的 KDA、GPM、XPM 表格,同时在 output 目录下生成经济曲线图。整体运行时间取决于接口响应速度和比赛数据量,一般 10 秒到半分钟不等。
6.3 预期输出说明
虽然每场比赛的数据不一样,但结构是固定的。以 VG 让一追二那场为例,如果第二局是翻盘局,你会看到经济差曲线从 20 分钟开始持续走低,30 分钟后突然拉升,最终在 40 分钟附近由负转正。这说明 VG 在中后期通过几波关键团战打了回来。
如果你同时对比 XM 的 kills_log,发现他在经济曲线拐点前后出现了多次击杀参与,那就基本可以判断:滚滚在中期带动了团队节奏,是翻盘的重要支点。
7. 常见问题与排查思路
7.1 接口请求报 403 或 429
这是 OpenDota 最典型的限流问题。403 通常是因为没有带 User-Agent 或者触发了防护策略,429 则明确表示请求过于频繁。
解决思路是降低请求频率,或者申请 API Key。建议在代码里加上简单的重试机制。
import time def fetch_match_with_retry(match_id, retries=3): for i in range(retries): try: return fetch_match(match_id) except requests.exceptions.HTTPError as e: if e.response.status_code == 429: wait_time = 5 * (i + 1) print(f"触发限流,等待 {wait_time}s 后重试") time.sleep(wait_time) else: raise7.2 比赛数据中没有 players 字段
通常是因为比赛类型问题。OpenDota 对部分非排位或 bot 比赛的数据覆盖不完整。建议优先选择完整的职业比赛,因为职业比赛的详情解析度最高。
7.3 坐标数据为空
正如前面所说,position 数据不一定每次都能拿到。如果坐标为空,不要硬画路线图,可以退化为团队经济和击杀时间轴分析,效果同样有说服力。
7.4 比赛 ID 无效
比赛 ID 是 DOTA2 客户端的展示 ID,注意不要从网页源码中直接复制拼接特殊字符。建议通过 OpenDota 的搜索接口确认后再使用。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 403 Forbidden | 缺少 User-Agent 或被防火墙拦截 | 添加请求头,降低请求频率 |
| 429 Too Many Requests | 无 API Key,请求频率过高 | 申请 API Key 或增加 sleep |
| 数据缺少 positions | 该比赛未保存完整坐标 | 改用经济差 + 击杀时间轴 |
| 比赛 ID 找不到 | ID 输入错误或比赛类型不支持 | 用选手 recentMatches 接口验证 |
| 绘图中文乱码 | Matplotlib 默认字体不支持中文 | 配置中文字体,例如 SimHei |
8. 工程化与最佳实践
8.1 把比赛 ID 做成参数
不要每次改代码里的 match_id。建议用命令行参数或配置文件来管理。
python main.py --match-id 1234567890 --output ./output这样做的好处是,你可以把同一个脚本复用到所有比赛分析中,不必保存多个副本。
8.2 数据缓存
OpenDota 的接口虽然免费,但请求量过大会影响可用性。建议把每次请求到的原始 JSON 保存到本地,后续分析直接读取本地文件。
import json import os def save_raw_data(match_data, path="data/match_data.json"): os.makedirs("data", exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(match_data, f, ensure_ascii=False)后续如果代码调试出错,只需读取文件,不需要再次请求接口。
8.3 代码分层
把“数据获取”“数据处理”“可视化”拆成独立模块,是工程化分析脚本的关键。这样即使将来换用 STRATZ API,也只需要替换数据获取层,不需要重写可视化逻辑。
8.4 注意合理使用与版权边界
分析公开比赛数据用于学习交流没有问题,但如果要发布商业化报告,请注意数据平台的条款要求。同时,不要在程序里加入任何自动获取账号权限、绕过限制的逻辑。
9. 总结与下一步拓展
本文从 TI15 小组赛 VG 让一追二 GL 的比赛切入,完整展示了基于 OpenDota API 的 DOTA2 比赛数据可视化分析流程。包括比赛数据获取、KDA 与 GPM 等指标提取、团队经济差走势绘制、按分钟击杀时间轴可视化等内容。
你可以把这套思路扩展到任意一场比赛,不一定是 TI 小组赛,也可以是路人局的录像复盘。只要拿到 match_id,脚本就能直接复跑。
接下来可以考虑几个方向:
- 把多场比赛数据整合到 SQLite,建立战队长期数据库;
- 引入 STRATZ GraphQL 接口,获取更精细的插眼、游走、补刀差异数据;
- 使用 Plotly 生成交互式 HTML 报告,方便在浏览器里点选查看每一分钟的事件;
- 加上选手历史英雄池分析,观察对手在特定英雄上的胜率变化。
建议你先拿 VG 对阵 GL 的比赛跑通一次,再找另一场 BO3 验证脚本的通用性。数据分析和看比赛一样,多复盘几场,才能发现真正有意思的规律。