基于Python的WTT赛事数据采集、清洗与可视化分析
发布时间:2026/9/2 16:26:51 作者:尧图编辑部 阅读量:1,286

最近“国乒WTT欧洲赛半区失守”“日本队横滨赢麻了”这类话题在乒乓球圈讨论度很高。普通观众看到的是胜负结果和晋级走势技术开发者看到的是另一层问题这些热搜结论能不能用数据验证比赛结果散落在官网分页、直播比分、新闻稿里想要回答“半区为什么失守”“横滨站谁赢得多”“主将近期状态波动有多大”靠手动复制粘贴表格根本不现实。这篇文章不讨论具体比赛胜负只从数据工程角度演示如何把 WTT 赛事公开数据变成一套可以查询、统计、可视化和简单预测的本地分析系统。整个项目用 Python 完成涉及爬虫抓取、数据清洗、SQLite 存储、SQL 统计、Matplotlib 可视化以及一套简化 Elo 积分模型。学完之后你可以把同一套流程迁移到羽毛球、网球、篮球等任何有回合制和胜负结构的比赛数据上。1. 先理解比赛数据背后的技术问题数据从哪来、怎么变成可分析结果1.1 体育数据分析的通用链路任何体育数据分析项目都可以拆成五段数据采集、数据清洗、数据存储、数据分析、结果呈现。比赛结果类的数据尤其适合这条链路因为它的结构相对稳定谁和谁打、什么时候打、在哪里打、比分是多少、谁赢了。难点不在分析算法而在数据质量。WTT 赛事数据有几个典型特征。第一数据源分散官网、媒体页、社交媒体信息不一定一致。第二选手姓名有多套写法拼音、英文、大小写、缩写混用同一个选手可能对应“WANG Chuqin”“Wang C.”“王楚钦”等不同写法。第三比赛阶段字段不统一有 R32、R16、QF、SF、F也有中文的“八强”“半决赛”。如果不把这些字段标准化后面所有统计都会失真。1.2 先拆清楚“半区”“横滨站”“胜率”这些概念“半区失守”在竞技体育里通常指抽签表某个半区的选手全部出局比如上半区或下半区没有该队选手进入四强。要验证这种结论数据模型里必须保留“阶段”和“轮次”字段同时还要知道选手从哪个半区打进。仅保存“赢了几场”不够还要保存“输给谁”“在哪个阶段输的”“比赛场地在哪个城市”。“横滨站”是一个赛事地点概念。比赛数据里需要维护场地或城市字段否则无法回答“在这站比赛的胜率如何”。赛事数据表至少应该包含事件名、阶段、场地、日期、对手、比分、胜者这些字段。先从最小数据集开始不要把需求想复杂。我们真正要回答的问题其实只有三类谁赢了、在哪赢的、近期状态如何。后续所有表结构、清洗逻辑、统计口径都围绕这三类问题设计。2. 环境准备与依赖选择2.1 Python 版本与依赖清单本文示例基于 Python 3.10 以上版本开发。核心依赖如下表所示安装命令建议放在虚拟环境里执行。依赖库用途版本建议requests请求页面数据2.31 以上beautifulsoup4解析 HTML 表格4.12 以上lxmlBeautifulSoup 的解析器比内置 html.parser 更快4.9 以上pandas数据清洗和聚合2.0 以上sqlite3Python 内置数据库模块无需单独安装标准库matplotlib生成统计图表3.7 以上scikit-learn后续做建模实验时使用1.3 以上创建并激活虚拟环境后执行以下命令一次装齐python -m venv wtt_env source wtt_env/bin/activate pip install requests beautifulsoup4 lxml pandas matplotlib scikit-learn如果只是把基本流程跑通不训练模型scikit-learn 可以暂时不装。后面第 5 节的简化 Elo 积分计算只依赖 Python 标准库和 pandas。2.2 数据源选择与合规注意事项学习用途建议优先选择公开、明确允许非商业分析的页面并注意三点先查看目标网站的robots.txt确认采集路径是否被允许。请求频率要克制建议在两次请求之间加 1 到 2 秒随机延时。抓取后只用于本地学习不要二次分发原始页面内容。这里不绑定任何特定站点示例代码里用example.com占位真实项目落地前先把目标页面结构研究清楚。注意不要为了“多抓点数据”去绕过验证码、隐藏请求频率或伪造身份。合规采集比数据量重要得多。3. 用 Python 抓取和清洗 WTT 比赛数据3.1 先定义数据结构数据进入数据库之前先确定表的字段。比赛表matches的字段如下字段类型说明match_idTEXT全局唯一比赛ID由事件、日期、场次拼接event_nameTEXT赛事名称如 WTT Champions 横滨站stageTEXT阶段如 R32、R16、QF、SF、FvenueTEXT比赛城市或场馆match_dateTEXT比赛日期统一为 YYYY-MM-DDplayer_aTEXTA 选手标准化姓名player_bTEXTB 选手标准化姓名score_aINTEGERA 选手总得分score_bINTEGERB 选手总得分winnerTEXT胜者标准化姓名先设计好结构再去适配页面比抓回来一堆字段再临时改表要省事。3.2 抓取页面表格的通用逻辑使用requests获取页面BeautifulSoup定位表格行。下面这段代码展示了解析一个“日期、事件、阶段、选手A、选手B、比分”表格的基本逻辑import time import random import requests from bs4 import BeautifulSoup def fetch_match_rows(url, table_selectortable.match-list tr): headers { User-Agent: ( Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36 ) } resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() resp.encoding utf-8 soup BeautifulSoup(resp.text, lxml) rows [] for tr in soup.select(table_selector): cells [td.get_text(stripTrue) for td in tr.find_all(td)] if len(cells) 6: continue rows.append(cells) return rows def fetch_with_interval(urls, interval(1, 2)): all_rows [] for url in urls: all_rows.extend(fetch_match_rows(url)) time.sleep(random.uniform(*interval)) return all_rows几个关键点resp.encoding utf-8可以避免中文乱码具体编码以页面响应头为准。选择器和字段数量是硬编码的真实页面结构变化时需要调整。请求间隔用随机延时避免对目标站点造成压力。3.3 清洗规则姓名统一、比分转换、空值处理原始抓取结果不能直接入库。先用 pandas 做一轮清洗import pandas as pd def clean_matches(raw_rows): df pd.DataFrame( raw_rows, columns[match_date, event_name, stage, player_a, player_b, score] ) # 比分可能是 3-1 或 3:1统一拆成两列 score_split df[score].astype(str).str.split(r[-:], expandTrue) df[score_a] pd.to_numeric(score_split[0], errorscoerce) df[score_b] pd.to_numeric(score_split[1], errorscoerce) df df.dropna(subset[score_a, score_b]) # 姓名去除首尾空格并统一为全角转半角 df[player_a] df[player_a].str.strip().str.replace( , ) df[player_b] df[player_b].str.strip().str.replace( , ) # 同一场比赛 A/B 顺序混乱会导致后续胜负统计错误这里按名字排序后重新生成 match_id df[_key] df.apply( lambda r: tuple(sorted([r[player_a], r[player_b]])), axis1 ) df[match_id] ( df[match_date].astype(str) _ df[_key].apply(lambda x: _.join(x)) ) # 根据比分计算胜者 df[winner] df.apply( lambda r: r[player_a] if r[score_a] r[score_b] else r[player_b], axis1 ) df df.drop_duplicates(subset[match_id]) return df[[match_id, event_name, stage, venue, match_date, player_a, player_b, score_a, score_b, winner]]清洗逻辑需要注意三点。第一比分列拆分时如果页面同时存在“3-1”和“3:1”要用正则同时兼容。第二match_id 用日期加双方姓名生成可以起到天然去重作用。第三drop_duplicates不是万能去重如果同一场比赛在不同页面比分不同需要先做置信度判定比如以官方页面为准。3.4 常见坑姓名大小写和别名不一致这是比赛数据清洗最常见的坑。同一个选手的写法可能有大小写不一致Wang Chuqin和wang chuqin缩写不一致WANG C.和WANG Chuqin拼音风格不一致Harimoto Tomokazu和Tomokazu Harimoto建议单独维护一张player_dict映射表把已知别名映射到标准化姓名PLAYER_DICT { wang c.: WANG Chuqin, wang chuqin: WANG Chuqin, 王楚钦: WANG Chuqin, harimoto tomokazu: HARIMOTO Tomokazu, tomokazu harimoto: HARIMOTO Tomokazu, } def normalize_player(name: str) - str: return PLAYER_DICT.get(name.strip().lower(), name.strip())清洗时先统一转小写查映射表再回填标准化姓名。这个表只靠人工维护会越来越长后续可以接入姓名相似度匹配但不建议在一开始就做复杂算法。4. 把数据存入 SQLite 并做常规统计4.1 建表和写入逻辑SQLite 不需要单独启动服务适合做本地分析。先建表再批量写入import sqlite3 def init_db(db_path): conn sqlite3.connect(db_path) conn.execute( CREATE TABLE IF NOT EXISTS matches ( match_id TEXT PRIMARY KEY, event_name TEXT NOT NULL, stage TEXT, venue TEXT, match_date TEXT, player_a TEXT, player_b TEXT, score_a INTEGER, score_b INTEGER, winner TEXT ) ) conn.commit() return conn def insert_matches(conn, df): df.to_sql(matches, conn, if_existsappend, indexFalse)这里使用to_sql的append模式重复运行脚本时主键冲突会导致写入报错。更稳妥的方式是先查询已有 match_id再过滤出新增数据后写入。4.2 用 SQL 回答“谁赢得多”最基础的问题是胜场排行SELECT winner AS player, COUNT(*) AS wins FROM matches GROUP BY winner ORDER BY wins DESC LIMIT 10;如果只想看“横滨站”的数据就要再加venue条件SELECT winner AS player, COUNT(*) AS wins FROM matches WHERE venue Yokohama GROUP BY winner ORDER BY wins DESC LIMIT 10;注意“横滨站”可能在同一城市的不同场馆举行建议把venue字段标准化为“城市 场馆”例如Yokohama Arena否则同一城市的数据会分散。4.3 用 SQL 计算对阵记录和阶段表现想看“国乒选手输给日本选手集中在哪个阶段”可以按胜负双方国籍分类但表里没有国籍字段。最简单的方式是继续用选手字典维护国籍映射然后把国籍字段 join 进查询SELECT p.nationality AS winner_nation, s.nationality AS loser_nation, m.stage, COUNT(*) AS matches FROM matches m JOIN players p ON m.winner p.name JOIN players s ON m.loser s.name GROUP BY winner_nation, loser_nation, stage ORDER BY matches DESC;这里假设我们已经维护了players表包含名和国籍字段。没有这个映射“半区失守”这种偏群体结论就很难用数据表达。“半区失守”需要进一步结合抽签分区单靠比赛表不够。建议扩展matches表增加bracket_half字段值为upper或lower。这样查询“每个半区进入八强的选手属于哪个协会”就变得直接。4.4 更新修正入库前必须做完整性检查数据入库后不能直接信任建议每次抓取后执行一条校验语句SELECT COUNT(*) AS total, COUNT(DISTINCT match_id) AS unique_id, SUM(CASE WHEN winner IS NULL OR winner THEN 1 ELSE 0 END) AS missing_winner FROM matches;如果 total 和 unique_id 不一致说明数据有重复或 ID 生成有误。如果 missing_winner 大于 0说明比分列存在异常值。检查通过后再进入分析阶段。5. 用图表展示“半区失守”和“横滨站表现”这类热点结论5.1 统计口径先定清楚数据可视化只能反映统计口径不能替你说服别人。比如“国乒WTT欧洲赛半区失守”至少要先定义清楚统计范围是单打还是双打。统计周期是这一站比赛还是最近一年。“半区”按抽签表怎么划分。“失守”是指该半区没有选手进入四强还是指该半区全部出局。定义不同结果可能完全不同。在代码里把统计口径写成可配置参数不要写死在图表标题里。STATS_CONFIG { event: WTT Champions Yokohama, round_gate: QF, # 从四强往前看 bracket_half: upper, nation: CHN, }5.2 可视化代码示例用 matplotlib 画一个“各协会胜场分布”横向条形图import matplotlib.pyplot as plt import pandas as pd import sqlite3 plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei, Noto Sans CJK SC] conn sqlite3.connect(wtt.db) df pd.read_sql_query( SELECT p.nationality, COUNT(*) AS wins FROM matches m JOIN players p ON m.winner p.name GROUP BY p.nationality ORDER BY wins , conn, ) df.plot.barh(xnationality, ywins, figsize(8, 6), legendFalse) plt.xlabel(获胜场次) plt.title(各协会选手胜场分布) plt.tight_layout() plt.savefig(nation_wins.png) plt.show()中文字体在不同操作系统下表现不同。Linux 服务器通常没有 SimHei需要安装fonts-noto-cjk或者把字体路径直接指定给 matplotlib。盲目设置SimHei在 CI 环境会出现方块字。5.3 从数据到结论的注意事项可视化图表回答的是“是什么”不是“为什么”。横滨站胜率高可能因为参赛名单强手少也可能因为赛程密集度不同还可能因为场地适应、状态周期、对手抽样差异。样本量不足时不要轻率给出“赢麻了”或“失守”这种强结论。建议输出图表时同时输出样本量print(f样本量: {df[wins].sum()} 场)数据量小于 30 场时百分比波动非常大更要谨慎解释。6. 选型对比自己写爬虫 vs 使用现成体育 API6.1 方案对比表方案优点缺点适用场景自己写爬虫字段完全可控可深度定制免费页面改版要维护采集频率受限合规需自行确认学习项目、特定站点深度分析开源体育数据集结构规范省去清洗适合建模数据时效性差覆盖范围有限离线分析、算法实验官方或第三方 API数据权威、字段完整、更新及时可能有配额、费用、鉴权限制生产级数据分析服务6.2 如何判断该不该自己做如果只是验证一个热搜结论不值得写完整爬虫。先用 CSV 手动整理 50 场比赛样本跑通分析流程确认结论有意义后再考虑自动化采集。如果目标是长期维护建议优先寻找官方开放接口爬虫只作为补充数据源。7. 常见问题排查下面表格列出这个项目里最常遇到的五类问题。问题现象常见原因检查方式处理建议请求返回 403 或 418缺少 User-Agent或请求频率过高打印响应状态码和响应头添加合理 User-Agent降低抓取频率中文显示乱码页面编码不是 UTF-8查看页面响应头的 charset根据实际编码设置 resp.encoding表格解析结果为空目标表格是 JavaScript 动态渲染在无头浏览器里查看页面源码改用 Playwright/Selenium或寻找数据接口比分拆分后出现 NaN比分列混入 “VS” 或 “取消”打印 score 列唯一值清洗时先屏蔽非比分文本写入数据库报 UNIQUE 约束失败重复运行抓取脚本match_id 重复执行 COUNT 和 COUNT(DISTINCT)入库前先过滤已存在 ID或改为 UPSERT抓取类问题优先级先确认页面结构再调选择器不要一上来就怀疑网络。清洗类问题优先级先看样本数据再写规则规则写多了反而容易误杀。8. 把这个小项目变成可落地的生产级数据服务8.1 学习环境和生产环境的差异本地分析脚本可以容忍手动重跑、写死路径、不处理异常。生产环境需要解决更多问题配置外置化数据库路径、目标 URL、抓取间隔不能写死在代码里。日志和监控每次抓取成功多少条、失败多少条要有结构化日志。异常重试网络超时和数据解析失败要区分重试策略不能无限重试。数据版本管理同一场比赛数据被多次更新时需要记录更新时间和来源。推荐使用 YAML 配置文件管理采集参数source: base_url: https://example.com/matches interval_seconds: [1, 2] retry_times: 3 database: path: data/wtt.db schedule: time: 01:008.2 发布前检查清单在把脚本部署到定时任务之前至少确认以下项目[ ] 目标站点的采集条款和 robots 声明没有明确禁止。[ ] 数据采集过程有请求间隔不会对目标站点造成压力。[ ] 清洗脚本覆盖空值、重复值、姓名别名和比分格式差异。[ ] SQLite 表结构有主键和必要的索引。[ ] 抓取失败时能自动重试并记录日志。[ ] 重复运行时不会产生重复数据。[ ] 可视化图表在部署服务器上中文字体正常显示。[ ] 增量更新不会把旧数据错误覆盖。8.3 下一步扩展方向这套流程跑通后可以往三个方向扩展。第一引入简化 Elo 积分把选手长期状态变成可排序的数值。实现思路是每场比赛后按实际结果更新双方积分初始积分统一设为 1000K 值取 32。第二加入“近期状态”窗口统计比如近 10 场胜率、连续胜场数用滑动窗口替代全量统计。第三把 SQLite 替换成 PostgreSQL 或 ClickHouse支撑更大规模查询和更复杂的多维分析。回到最初的问题当热搜里讨论“半区失守”和“横滨赢麻了”时真正值得做的不是转发情绪而是把数据链路搭起来让每一个结论都能被查询、被复核、被更新。数据会过期结论也会变但采集、清洗、存储、分析、呈现这套流程在任何体育数据项目里都值得先搭一遍。