ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

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

用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 数据采集的合规底线写爬虫之前先想清楚三个问题这个网站允许不允许采集查看/robots.txt和用户协议采集频率会不会影响对方服务建议请求之间至少间隔 2 到 5 秒采集后的数据能不能公开传播个人学习分析可以商用和二次分发要谨慎。合法合规采集是底线不要为了跑完脚本把目标网站搞挂也不要绕过登录、验证码等访问控制机制。下面示例中的 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, headersHEADERS, timeout10) 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(stripTrue), date: date_el.get_text(stripTrue) 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(stripTrue) player_b player_b_el.get_text(stripTrue) score score_el.get_text(stripTrue) if score_el else round_name round_el.get_text(stripTrue) if round_el else # 简单判断胜者取局分领先方 winner player_a # 实际应根据比分解析这里简化 records.append(MatchRecord( event_nameevent_name, round_nameround_name, player_aplayer_a, player_bplayer_b, player_a_nationnation_a_el.get_text(stripTrue) if nation_a_el else , player_b_nationnation_b_el.get_text(stripTrue) if nation_b_el else , scorescore, winnerwinner, )) 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(howall) # 去首尾空格 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], errorscoerce).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(namematch_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()) passpandas 的str.strip()会一次性清理字符串列的首尾空格pd.to_datetime(..., errorscoerce)会在日期无法解析时转换为 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], cdf[event_level], cmapcoolwarm, s80, alpha0.7, edgecolorsblack, ) ax.set_xlabel(赛事日期) ax.set_ylabel(晋级轮次指数) ax.set_title(国乒男单各站 WTT 赛事最好晋级轮次趋势) ax.grid(True, linestyle--, alpha0.4) cbar plt.colorbar(scatter) cbar.set_label(赛事级别) plt.tight_layout() plt.savefig(output_path, dpi200) print(f图表已保存至 {output_path}) if __name__ __main__: # 演示数据真实使用时替换为统计结果 demo_df pd.DataFrame({ event_date: pd.date_range(2024-01-01, periods8, freq2M), 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 SCWindows 下用SimHeiLinux 下可能需要安装中文字体。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。如果图片里能看出“最近两站的最好晋级轮次明显低于前面几站”说明分析流程生效如果数据点很分散看不出规律需要回到清洗环节检查轮次映射是否准确。验证成功的标准有三个明细数据里没有空字符串和 NaN“晋级轮次”字段能正确映射成数值图表横轴时间有序纵轴范围符合预期。如果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 接口再解析 JSONHTML 结构选择器失效目标网站改版用浏览器检查当前页面结构跟随页面结构调整 CSS 选择器并缓存原始 HTML日期解析失败出现大量 NaN日期格式混合输出不合法日期样本用errorscoerce定位再按格式清洗字段缺失导致None参与计算某个卡片结构不完整在解析函数里加入空值判断解析时对关键字段做 None 检查缺失字段给默认值图表中文显示成方块系统缺少中文字体查看字体警告信息安装中文字体或在 matplotlib 中指定已有字体样本太少结论不可信只抓取了一两站比赛统计赛事数量横向扩展抓取范围至少覆盖 12 个月以上数据再下结论排查建议是永远先打印原始数据再看清洗后的数据最后才看图表。数据链路每一环都可能出错跨过原始数据直接看结果很难定位问题。8. 数据分析之外如何用工程手段持续跟踪赛事“连续两站无人晋级八强”这个热点本质上是一个“小时间窗口”事件。如果只靠手动跑一次脚本很难判断它是偶然还是趋势。更工程化的做法是建立一个持续跟踪任务。8.1 定时采集与增量更新完整方案至少包含三层定时任务用 cron 或 Windows 计划任务每周赛事结束后自动触发抓取脚本存储层把原始 HTML 存入data/raw/清洗后的结构化数据存入 SQLite 或 CSV方便后续追加统计层固定统计口径单独写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.py8.2 日志与告警不要等到发现图表没更新才去查原因。在抓取函数里增加日志输出在解析记录数为 0 时发送报警。一个简单的做法是在parse_event_list返回空列表时写日志并发送邮件或企业微信机器人通知。这样页面结构一改你能第一时间知道而不是带着脏数据继续跑一个月。8.3 统计口径版本化“平均晋级轮次”“外战胜率”这些指标一旦口径变了历史数据就不可比。建议在指标计算函数里加一个version参数或者至少把计算逻辑的注释写在文件头部。团队协作时口径变更要走变更记录而不是默默改代码。9. 实践建议与后续方向如果你只是想回答“男乒是不是真的出问题了”这篇文章的代码已经够用。但如果你想把体育数据分析做成一个长期可用的工程能力我有几条建议。第一别只盯着晋级轮次。晋级轮次是结果指标它受签运、对手临场发挥影响很大。更稳定的指标是“面对排名前20选手的胜率”“关键分上得分率”这类过程指标但这类数据采集难度会高不少需要从单局比分中二次计算。第二建立自己的历史数据库。赛事网站不会永远保留历史页面真正有价值的是你持续积累的本地数据库。每次比赛结束后定期抓取把原始数据存下来半年后再回看你手里的数据就是别人拿不到的“时间序列”。第三不要用两站比赛下结论。统计学里小样本的结论置信度很低。至少积累 6 到 12 个月的赛事数据再谈趋势。数据分析的价值不是制造焦虑而是把“感觉不行了”变成“哪类对手、哪些环节、什么时间点出现了问题”。后续如果想继续深入可以考虑三个方向用 Elo 评分体系为球员建立动态实力模型用预测模型评估不同签表下的晋级概率把可视化脚本扩展成自动生成的中文赛事报告这样每次比赛结束你都能第一时间看到“数据版复盘”而不是陷入热搜情绪里。回到最开始的问题男乒连续两站 WTT 无人晋级八强真说明梯队崩溃吗数据分析不会替球员站上赛场但能帮我们把讨论从情绪拉回事实。你需要做的就是先把数据集建起来。
返回列表