news 2026/9/2 11:10:12

用Python做体育赛事数据分析:从数据抓取到趋势可视化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Python做体育赛事数据分析:从数据抓取到趋势可视化

“国乒男单全军覆没”“连续两站WTT无人晋级八强”冲上热搜时,评论区最常见的两种声音:要么是“梯队建设完蛋了”,要么是“一场比赛说明不了问题”。但这两种结论其实都站不住脚——因为大家看到的只是“止步16强”这个点状结果,没有人把时间线拉长,看参赛密度、输球对象、年龄结构、外协进步幅度这些真正能说明趋势的数据。

如果你在互联网行业做数据开发或后端,这个热搜事件其实是一个很好的分析样本:当“输了”这个事实刷屏时,如何用 Python 把赛事数据抓下来、清洗干净、算清楚趋势,再用可视化说清楚“是不是真的危机”?这篇文章不预测冠军,也不评价球员,而是给你一套可以复用的体育赛事数据分析流程。读完你能自己跑一个“国乒男单 WTT 参赛表现分析”的迷你项目,以后遇到任何热点赛事,都能用同样的方法做数据复盘。

1. 为什么体育热点也需要数据分析

体育讨论天然是情绪驱动的。一个球员输了,热搜上的结论可以在一小时内反转三次:先是“技不如人”,然后是“教练组有问题”,最后变成“时代变了”。但如果你去看真实的赛事数据,会发现很多所谓的“危机”只是小样本波动,而很多表面上的“稳定”反而藏着结构性风险。

以“连续两站 WTT 无人晋级八强”为例,这个描述本身就有问题。它只统计了最近的比赛窗口,没有比较:

  • 去年同期同样级别的比赛,国乒男单是否也有人提前出局;
  • 出局的对手世界排名是多少,是输给外协顶尖选手,还是输给排名靠后的黑马;
  • 参赛球员的年龄结构是偏老还是偏年轻;
  • 队伍是主力全出,还是战略性轮换。

这些问题,靠热搜和评论区是回答不了的,但靠数据可以。数据不能替球员上场,但能帮我们把“男乒是不是不行了”这种大问题,拆解成几个可以量化的小问题:参赛密度有没有下降、输球对手的排名分布、年龄结构是否断层、外协进步的速度是否超过我们。这才是数据分析在体育场景里的真实价值。

这篇文章选择用 Python 来做,原因很简单:Python 在数据采集、清洗、统计和可视化这条链路上生态最成熟,requests 加 BeautifulSoup 解决抓取,pandas 解决清洗和透视,matplotlib 解决出图,整套流程可以用最少的代码跑通。而且这套方法不止能分析乒乓球,换成足球、篮球、电竞赛事,思路完全一样。

2. 数据复盘的技术思路与核心指标

要分析“国乒男单是不是真的下滑”,第一步不是写爬虫,而是先想清楚分析框架。赛事数据分析和业务数据分析一样,指标定义比工具重要得多。

2.1 数据源选择

做体育赛事数据复盘,常见的数据源有这么几类:

数据源优点缺点
官方赛事网站(如 WTT 官网)数据准确、赛程完整页面结构经常改,反爬策略不一
国际乒联 ITTF 排名页面有连续的世界排名历史版本需要定期存档才有
第三方体育数据平台字段丰富、更新快版权限制、接口收费
新闻媒体报道有赛前赛后背景信息非结构化,需要 NER 提取实体
维基百科赛事词条历史数据完整、带对阵表需要解析表格,依赖志愿者更新

对个人学习项目来说,最稳妥的方式是优先找官方页面里暴露的 JSON 接口,找不到再退到 HTML 解析。需要注意,采集公开数据前要查看目标网站的 robots.txt 和使用条款,控制请求频率,只做学习用途。

2.2 核心指标设计

“实力”是个抽象概念,不能直接统计,所以我们要把它映射成一组可量化的指标。下面这几个指标是分析“男乒危机”时最常用的:

指标定义说明什么
晋级轮次球员在该站比赛打到了第几轮直接反映该站成绩
对手世界排名当场比赛对手的 ITTF 排名判断输球含金量,输给第1名和第50名意义完全不同
平均对手排名整个征程遇到的对手排名均值衡量签运硬度和晋级含金量
外战/内战对手是否为协会外选手考察外战抗压能力
参赛密度单位时间内参赛站数过高说明赛程紧张,过低说明锻炼不足
年龄结构参赛球员平均年龄判断梯队是否合理
胜率趋势近12个月 vs 近3个月的胜率分辨短期波动和长期趋势

分析时,对比维度也很重要。正确的做法是纵向比历史同期、横向比外协同龄选手,而不是只看“最近两站输了几个人”。

2.3 一个容易犯的分析错误

只看“晋级轮次”下结论,是最常见的错误。比如“止步16强”看起来很差,但如果这名球员是从资格赛一路打上来,连续淘汰了两位世界前20选手,最后2比3惜败给世界第一,那这个“止步16强”的质量就非常高。

反过来,“晋级八强”听起来不错,但如果三场比赛的对手排名都在50开外,那也只能说明签运好。所以指标一定要组合使用,晋级轮次负责描述结果,对手排名负责描述难度,年龄结构负责描述趋势。

3. 环境准备与数据源合法性

分析项目不需要重型的依赖,一个 Python 3.9+ 的环境就够了。我用的是虚拟环境方式,避免污染全局环境。

3.1 创建虚拟环境并安装依赖

mkdir wtt_analysis cd wtt_analysis python -m venv venv source venv/bin/activate # Windows 使用 venv\Scripts\activate

然后安装以下依赖:

pip install requests beautifulsoup4 pandas matplotlib lxml

版本说明:以上库使用当前主流版本即可,本文重点演示通用思路,不绑定某个具体版本。

  • requests:发送 HTTP 请求,获取页面或接口内容;
  • beautifulsoup4 + lxml:解析 HTML 页面结构;
  • pandas:清洗数据、计算指标、生成透视表;
  • matplotlib:绘制趋势图和对比图。

3.2 数据采集的合规底线

写爬虫之前,先想清楚三个问题:

  1. 这个网站允许不允许采集?查看/robots.txt和用户协议;
  2. 采集频率会不会影响对方服务?建议请求之间至少间隔 2 到 5 秒;
  3. 采集后的数据能不能公开传播?个人学习分析可以,商用和二次分发要谨慎。

合法合规采集是底线,不要为了跑完脚本把目标网站搞挂,也不要绕过登录、验证码等访问控制机制。下面示例中的 URL 均为示意,实际使用时请替换为官方公开数据地址。

3.3 项目目录结构

wtt_analysis/ ├── crawler.py # 页面抓取 ├── parser.py # HTML 解析 ├── analyze.py # 数据清洗与统计 ├── visualize.py # 可视化出图 ├── data/ # 原始数据与清洗后数据 │ ├── raw/ │ └── processed/ └── output/ # 图表输出

这样拆分的好处是每一步可以单独运行和验证,出问题的时候不用从头排查。

4. 核心流程拆解:抓取、解析、清洗、统计、可视化

一套完整的赛事数据分析流程,可以拆成五个环节。每个环节都有自己容易踩的坑。

4.1 抓取:先看接口,再考虑页面

打开赛事官网,第一步别急着写解析正则,先按 F12 看 Network 面板。很多现代网站的数据其实是后端返回的 JSON,浏览器通过异步请求渲染页面。如果能直接拿到 JSON 接口,解析成本会低很多。

判断依据很简单:在 Network 面板筛选 XHR,刷新页面,看请求列表里有没有返回比赛数据、球员列表、比分信息的 JSON 文件。如果有,直接用 requests 模拟这个请求,加上必要的请求头即可。

如果找不到 JSON 接口,只能解析 HTML 页面,那就要有一套“先用浏览器看结构,再写解析器”的流程。HTML 结构经常改,写解析器时要尽量选择稳定的语义化标签,比如比赛卡片上的># 文件路径:crawler.py import time import requests from bs4 import BeautifulSoup BASE_URL = "https://example.com/wtt/events" HEADERS = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0 Safari/537.36" } def fetch_page(url: str) -> str: """请求页面并返回 HTML 文本""" resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding return resp.text def parse_event_list(html: str) -> list[dict]: """解析赛事列表,返回事件基本信息""" soup = BeautifulSoup(html, "lxml") events = [] # 这里的 .event-card 只是示意,请对照真实页面结构调整 for card in soup.select(".event-card"): title_el = card.select_one(".event-title") date_el = card.select_one(".event-date") link_el = card.select_one("a.event-link") if title_el is None or link_el is None: continue events.append({ "title": title_el.get_text(strip=True), "date": date_el.get_text(strip=True) if date_el else "", "url": link_el.get("href"), }) return events if __name__ == "__main__": html = fetch_page(BASE_URL) event_list = parse_event_list(html) print(f"共抓取 {len(event_list)} 条赛事记录") for event in event_list[:5]: print(event) time.sleep(2)

这里关键逻辑是:先用fetch_page把 HTML 拿到,再用 BeautifulSoup 解析。设置resp.encoding = resp.apparent_encoding是为了避免中文乱码。解析时先判断title_el是否为 None,防止某个卡片结构缺失导致整个脚本崩溃。

5.2 解析单场比赛结果

拿到赛事详情页后,需要从 HTML 中提取对阵双方、比分和轮次。示例代码先定义了一个数据模型,再写解析函数。

# 文件路径:parser.py from dataclasses import dataclass, asdict from bs4 import BeautifulSoup from crawler import fetch_page, BASE_URL, HEADERS @dataclass class MatchRecord: event_name: str round_name: str player_a: str player_b: str player_a_nation: str player_b_nation: str score: str winner: str def parse_match_page(event_url: str, event_name: str) -> list[MatchRecord]: """解析赛事详情页,返回全部比赛记录""" html = fetch_page(event_url) soup = BeautifulSoup(html, "lxml") records = [] # 每场比赛通常在一个 .match-card 容器内 for card in soup.select(".match-card"): round_el = card.select_one(".match-round") player_a_el = card.select_one(".player-a .name") player_b_el = card.select_one(".player-b .name") nation_a_el = card.select_one(".player-a .nation") nation_b_el = card.select_one(".player-b .nation") score_el = card.select_one(".match-score") if not (player_a_el and player_b_el): continue player_a = player_a_el.get_text(strip=True) player_b = player_b_el.get_text(strip=True) score = score_el.get_text(strip=True) if score_el else "" round_name = round_el.get_text(strip=True) if round_el else "" # 简单判断胜者:取局分领先方 winner = player_a # 实际应根据比分解析,这里简化 records.append(MatchRecord( event_name=event_name, round_name=round_name, player_a=player_a, player_b=player_b, player_a_nation=nation_a_el.get_text(strip=True) if nation_a_el else "", player_b_nation=nation_b_el.get_text(strip=True) if nation_b_el else "", score=score, winner=winner, )) return records if __name__ == "__main__": # 示例:替换为真实赛事详情页 demo_url = BASE_URL + "/event-2024" matches = parse_match_page(demo_url, "横滨冠军赛") for m in matches[:10]: print(asdict(m))

真实项目里,胜者不能简单地写成player_a,需要解析比分,比如4:2中局分高的一方获胜。这里为了演示结构做了简化,实际代码里应该写一个parse_winner函数,根据比分字段拆解后判断。

5.3 数据清洗与指标计算

解析出来的原始记录是脏数据,我们要在analyze.py里完成清洗、去重、类型转换和指标计算。

# 文件路径:analyze.py import pandas as pd from parser import MatchRecord def records_to_dataframe(records: list[MatchRecord]) -> pd.DataFrame: """将比赛记录列表转为 DataFrame,并做基础清洗""" df = pd.DataFrame([r.__dict__ for r in records]) # 去除全空白行 df = df.dropna(how="all") # 去首尾空格 string_cols = ["event_name", "round_name", "player_a", "player_b", "player_a_nation", "player_b_nation", "score", "winner"] for col in string_cols: if col in df.columns: df[col] = df[col].astype(str).str.strip() # 统一日期解析为年份,便于按年聚合 if "date" in df.columns: df["year"] = pd.to_datetime(df["date"], errors="coerce").dt.year return df def compute_round_index(round_name: str) -> int: """将轮次文本映射为数值,便于排序和比较""" mapping = { "资格赛": 0, "1/32决赛": 1, "1/16决赛": 2, "1/8决赛": 3, "1/4决赛": 4, "半决赛": 5, "决赛": 6, } return mapping.get(round_name, -1) def add_round_index(df: pd.DataFrame) -> pd.DataFrame: """增加轮次数值列""" df["round_index"] = df["round_name"].map(compute_round_index) return df def filter_players(df: pd.DataFrame, player_names: list[str]) -> pd.DataFrame: """筛选指定球员参与的比赛""" mask = df["player_a"].isin(player_names) | df["player_b"].isin(player_names) return df[mask] def summarize_by_event(df: pd.DataFrame) -> pd.DataFrame: """按赛事汇总晋级轮次和比赛数量""" grouped = df.groupby(["event_name", "round_name"]).size().reset_index(name="match_count") return grouped if __name__ == "__main__": # 真实运行时,从解析好的 records 构建 # records = parse_match_page(demo_url, "横滨冠军赛") # df = records_to_dataframe(records) # df = add_round_index(df) # summary = summarize_by_event(df) # print(summary.head()) pass

pandas 的str.strip()会一次性清理字符串列的首尾空格,pd.to_datetime(..., errors="coerce")会在日期无法解析时转换为 NaT,避免报错。compute_round_index的价值在于把“1/8决赛”“半决赛”这种文本变成可排序的数字,这样就能计算“平均晋级轮次”。

5.4 可视化:生成晋级轮次散点图

最后一步是把统计结果画出来。这里用散点图展示各站比赛中国乒男单选手的最好晋级轮次,颜色代表赛事级别,越大代表越深。

# 文件路径:visualize.py import matplotlib.pyplot as plt import pandas as pd plt.rcParams["font.sans-serif"] = ["PingFang SC", "SimHei", "Arial Unicode MS"] plt.rcParams["axes.unicode_minus"] = False def plot_best_round(df: pd.DataFrame, output_path: str = "output/best_round_trend.png"): """绘制各站赛事最好晋级轮次散点图""" # 假设 df 包含 event_date, best_round, event_level 列 fig, ax = plt.subplots(figsize=(12, 6)) scatter = ax.scatter( df["event_date"], df["best_round"], c=df["event_level"], cmap="coolwarm", s=80, alpha=0.7, edgecolors="black", ) ax.set_xlabel("赛事日期") ax.set_ylabel("晋级轮次指数") ax.set_title("国乒男单各站 WTT 赛事最好晋级轮次趋势") ax.grid(True, linestyle="--", alpha=0.4) cbar = plt.colorbar(scatter) cbar.set_label("赛事级别") plt.tight_layout() plt.savefig(output_path, dpi=200) print(f"图表已保存至 {output_path}") if __name__ == "__main__": # 演示数据,真实使用时替换为统计结果 demo_df = pd.DataFrame({ "event_date": pd.date_range("2024-01-01", periods=8, freq="2M"), "best_round": [5, 4, 5, 3, 3, 4, 3, 3], "event_level": [4, 3, 4, 3, 3, 4, 3, 3], }) plot_best_round(demo_df)

注意plt.rcParams["font.sans-serif"]一定要设置中文字体,否则图表标题和坐标轴中文会显示成方块。macOS 下用PingFang SC,Windows 下用SimHei,Linux 下可能需要安装中文字体。

6. 运行结果与效果验证

项目跑通后,按顺序运行以下命令:

python crawler.py python parser.py python analyze.py python visualize.py

预期输出分两部分。

crawler.py运行后输出:

共抓取 12 条赛事记录 {'title': '横滨冠军赛', 'date': '2024-10-01', 'url': 'https://example.com/wtt/events/event-2024'} ...

visualize.py运行后会在output/目录下生成best_round_trend.png。如果图片里能看出“最近两站的最好晋级轮次明显低于前面几站”,说明分析流程生效;如果数据点很分散,看不出规律,需要回到清洗环节检查轮次映射是否准确。

验证成功的标准有三个:

  1. 明细数据里没有空字符串和 NaN;
  2. “晋级轮次”字段能正确映射成数值;
  3. 图表横轴时间有序,纵轴范围符合预期。

如果analyze.py报错,先检查parser.py返回的字段名是否和records_to_dataframe里访问的一致。字段名是大多数数据链路的第一个坑。

7. 常见问题与排查思路

写这类爬虫加分析项目,最容易出问题的不是算法,而是数据链路上的各种小意外。下面整理了几个高频问题。

问题现象可能原因排查方式解决方案
请求被拒绝,返回 403缺少请求头或触发反爬风控查看响应状态码和错误信息设置合理 User-Agent,控制请求频率,必要时增加 Referer 请求头
中文乱码页面编码识别错误打印 HTML 前 500 字节,检查 charset使用resp.apparent_encoding,或显式指定resp.encoding = "utf-8"
解析结果为空,但页面打开有数据页面是异步渲染,数据在 XHR 请求里F12 打开 Network,筛选 XHR改为直接请求 JSON 接口,再解析 JSON
HTML 结构选择器失效目标网站改版用浏览器检查当前页面结构跟随页面结构调整 CSS 选择器,并缓存原始 HTML
日期解析失败,出现大量 NaN日期格式混合输出不合法日期样本errors="coerce"定位,再按格式清洗
字段缺失,导致None参与计算某个卡片结构不完整在解析函数里加入空值判断解析时对关键字段做 None 检查,缺失字段给默认值
图表中文显示成方块系统缺少中文字体查看字体警告信息安装中文字体,或在 matplotlib 中指定已有字体
样本太少,结论不可信只抓取了一两站比赛统计赛事数量横向扩展抓取范围,至少覆盖 12 个月以上数据再下结论

排查建议是:永远先打印原始数据,再看清洗后的数据,最后才看图表。数据链路每一环都可能出错,跨过原始数据直接看结果,很难定位问题。

8. 数据分析之外:如何用工程手段持续跟踪赛事

“连续两站无人晋级八强”这个热点,本质上是一个“小时间窗口”事件。如果只靠手动跑一次脚本,很难判断它是偶然还是趋势。更工程化的做法是建立一个持续跟踪任务。

8.1 定时采集与增量更新

完整方案至少包含三层:

  1. 定时任务:用 cron 或 Windows 计划任务,每周赛事结束后自动触发抓取脚本;
  2. 存储层:把原始 HTML 存入data/raw/,清洗后的结构化数据存入 SQLite 或 CSV,方便后续追加;
  3. 统计层:固定统计口径,单独写indicators.py,让每次跑出来的指标可对比。
# 每周一早上 8 点执行采集和更新 0 8 * * 1 cd /path/to/wtt_analysis && /usr/bin/python crawler.py && /usr/bin/python analyze.py && /usr/bin/python visualize.py

8.2 日志与告警

不要等到发现图表没更新才去查原因。在抓取函数里增加日志输出,在解析记录数为 0 时发送报警。一个简单的做法是,在parse_event_list返回空列表时,写日志并发送邮件或企业微信机器人通知。这样页面结构一改,你能第一时间知道,而不是带着脏数据继续跑一个月。

8.3 统计口径版本化

“平均晋级轮次”“外战胜率”这些指标,一旦口径变了,历史数据就不可比。建议在指标计算函数里加一个version参数,或者至少把计算逻辑的注释写在文件头部。团队协作时,口径变更要走变更记录,而不是默默改代码。

9. 实践建议与后续方向

如果你只是想回答“男乒是不是真的出问题了”,这篇文章的代码已经够用。但如果你想把体育数据分析做成一个长期可用的工程能力,我有几条建议。

第一,别只盯着晋级轮次。晋级轮次是结果指标,它受签运、对手临场发挥影响很大。更稳定的指标是“面对排名前20选手的胜率”“关键分上得分率”这类过程指标,但这类数据采集难度会高不少,需要从单局比分中二次计算。

第二,建立自己的历史数据库。赛事网站不会永远保留历史页面,真正有价值的是你持续积累的本地数据库。每次比赛结束后定期抓取,把原始数据存下来,半年后再回看,你手里的数据就是别人拿不到的“时间序列”。

第三,不要用两站比赛下结论。统计学里,小样本的结论置信度很低。至少积累 6 到 12 个月的赛事数据,再谈趋势。数据分析的价值不是制造焦虑,而是把“感觉不行了”变成“哪类对手、哪些环节、什么时间点出现了问题”。

后续如果想继续深入,可以考虑三个方向:用 Elo 评分体系为球员建立动态实力模型;用预测模型评估不同签表下的晋级概率;把可视化脚本扩展成自动生成的中文赛事报告,这样每次比赛结束,你都能第一时间看到“数据版复盘”,而不是陷入热搜情绪里。

回到最开始的问题:男乒连续两站 WTT 无人晋级八强,真说明梯队崩溃吗?数据分析不会替球员站上赛场,但能帮我们把讨论从情绪拉回事实。你需要做的,就是先把数据集建起来。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/2 11:09:49

Nextcloud 文件夹 ZIP 打包下载全指南:一键打包与空间优化实操

Nextcloud 文件夹 ZIP 打包下载全指南:一键打包与空间优化实操 【免费下载链接】server ☁️ Nextcloud server, a safe home for all your data 项目地址: https://gitcode.com/GitHub_Trending/se/server 要把一个装着 300 多张交付照片的项目文件夹发给客…

作者头像 李华
网站建设 2026/9/2 11:07:34

用Go重写wafw00f:WAF识别工具的跨平台实践

简介:go-wafw00f 是一款使用 Go 语言重新实现的 WAF 指纹识别工具,面向渗透测试、安全运维与红队人员,目标是提供免 Python 环境依赖的轻量替代方案。该工具目前处于开发阶段,核心思路是复用 wafw00f 官方的大量 Python 规则文件&…

作者头像 李华
网站建设 2026/9/2 11:06:39

基于SpringBoot框架的智慧养老平台设计与实现(源码+文档+部署+讲解)

温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片! 温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片! 温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台…

作者头像 李华
网站建设 2026/9/2 11:06:27

体育教育开题报告怎么写?模板结构、选题方法与答辩准备指南

体育教育开题报告这件事,很多学生把它当成格式作业来处理。模板收到了,导师签字栏看到了,框架复制过来了,但真正要研究什么,反而放到了最后才想。这也是答辩现场最容易被问住的原因:导师问一句“你这个题到…

作者头像 李华
网站建设 2026/9/2 11:06:23

电竞数据复盘:从一句感叹到可复用的分析体系

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华