Python乒乓球数据分析实战:从数据清洗到可视化复盘 📅 发布时间:2026/9/2 10:16:37 👁 浏览次数: 关注过WTT冠军赛横滨站的球迷应该对男单赛场上那场讨论热度极高的“爆冷”记忆犹新。当国乒男单选手在正赛阶段接连出局时网上的讨论大多落在心态、状态、临场发挥这些不可量化的词上。作为一名技术博主我更关心另一个问题如果把这些“输球”放进数据模型里能不能拆出几个可量化的原因本文不写体育评论而是把“当横滨冠军赛国乒男子全部出局时……”当作一个数据分析项目选题从数据采集、清洗、指标计算到可视化完整演示如何用 Python 复盘一场比赛失利。你可以把它理解成一个“乒乓球比赛数据分析实战”也可以当作一个综合性的数据清洗与可视化练手项目。如果你对数据分析感兴趣想练习用 Python 处理结构化的比赛数据或者想了解体育赛事中常见的技术统计指标那么这篇文章会比较适合你。全文按照“背景概念 → 环境准备 → 数据获取 → 实战分析 → 常见问题 → 工程建议”的顺序展开每个代码块都可以直接复制运行。1. 背景与核心概念1.1 体育数据分析到底是什么体育数据分析并不是新鲜事物。早期的技术统计主要靠人工记录比如投篮命中率、失误次数、得分分布。随着传感器和实时数据接口的发展现在的比赛数据已经细化到“每一次发球后的衔接质量”“相持阶段第几拍开始丢分”“关键分时选手的落点选择”。对于乒乓球来说常见的可量化指标包括发球得分率发球局中直接得分或形成优势的比例。接发球得分率接发球环节后拿下这一分的比例。正手得分率、反手得分率不同技术动作的得分数与尝试次数之比。相持阶段得分率通常是第 6 拍以后的得分情况。非受迫性失误自己主动失误送掉的分数不包含对方高质量进攻造成的失误。关键分得分率局点、赛点等关键分上的表现。这些指标不能完全解释“为什么输”但可以把“输在哪”拆成更细的模块。当一支强队出现集体失利时数据可以帮助我们区分是普遍性的接发球质量下降还是某一轮次体能崩盘又或者是关键分的心理波动。1.2 WTT 冠军赛和“横滨站”背景WTTWorld Table Tennis世界乒乓球职业大联盟是国际乒联旗下的职业赛事体系。冠军赛是其中积分和奖金都比较高的分站赛之一男单和女单通常设 32 个正赛席位选手从 R32 开始单败淘汰。在这样短赛程、高强度的比赛中排名靠前的选手一旦状态不佳很容易被对手抓住机会。所以“横滨冠军赛国乒男子全部出局”这个场景本身就是一个很好的数据分析样本它意味着多个选手在各自轮次输球不是单一场次的偶然。不过在本文中我不会断言任何具体比赛的真实数据而是模拟一个结构完整的示例数据集用来演示分析方法。实际的比赛数据可能需要从公开渠道采集或由赛事统计团队提供分析思路是一致的。1.3 为什么用数据复盘比赛主观复盘容易受到情绪影响。比如“今天手感不好”这个说法很难验证。但如果数据里显示“接发球得分率只有 38%低于赛季平均的 46%”再结合对手发球质量的变化就能形成一个相对客观的结论。数据复盘的核心价值在于把“状态差”转化成具体的技术统计差异。发现单个选手和团队层面的共同特征。为后续训练提供优先级建议。让教练组、数据分析师、选手之间用同一套语言沟通。下面我们就用 Python 实现一个完整的分析流程。2. 环境准备与版本说明2.1 运行环境本文代码在以下环境中编写测试操作系统Windows 10 / macOS / Linux 均可Python 版本推荐 3.9 及以上开发工具Jupyter Notebook 或 VSCode 都可以第三方库pandas、numpy、matplotlib关于版本有一个需要说明的点第三方库的版本变化比较快建议以你自己环境中的稳定版本为准。本文代码使用的都是比较核心的 API在近几年的 pandas 和 matplotlib 版本上都能正常运行。2.2 安装依赖在命令行中创建虚拟环境并安装依赖python -m venv tt_env # Windows tt_env\Scripts\activate # macOS / Linux source tt_env/bin/activate pip install pandas numpy matplotlib requests beautifulsoup4如果只是做数据分析不需要保存到文件也可以直接使用 Jupyter Notebookpip install jupyter jupyter notebook2.3 项目结构本文建议的目录结构如下tt_data_analysis/ ├── data/ │ ├── raw_matches.json # 原始比赛数据 │ └── cleaned_matches.csv # 清洗后的数据 ├── output/ │ └── radar_chart.png # 输出的雷达图 ├── main.py # 核心分析脚本 └── requirements.txt # 依赖清单如果你只是在 Jupyter 里跑不建目录也可以但实际项目中还是推荐保持这种结构后续扩展会更方便。3. 数据从哪里来3.1 数据来源类型乒乓球比赛数据一般有以下几种来源赛事官网和官方数据接口部分赛事会提供 JSON 格式的技术统计。公开的比分网站提供每局比分和部分技术统计。赛事视频逐球标注人工或半自动方式从录像中提取每一分的得分原因。自建数据集团队分析师手动录入。从合规角度讲如果是爬取公开网站需要先检查网站的 robots.txt 和使用条款控制抓取频率只用于个人学习研究不要大规模抓取并二次分发。3.2 常见字段含义下面是一份模拟数据集会用到的字段字段名含义player_name选手名称opponent_name对手名称round_name轮次比如 R32 / R16 / QF / SF / Fresult本场结果win / lossset_wins赢得局数set_losses输掉局数total_points_scored本场总得分total_points_conceded本场总失分serve_points_won发球得分serve_points_total发球总次数receive_points_won接发球得分receive_points_total接发球总次数forehand_wins正手得分数forehand_attempts正手使用次数backhand_wins反手得分数backhand_attempts反手使用次数rallies_over_5_wins第 6 拍以后得分相持得分rallies_over_5_total第 6 拍以后总回合数unforced_errors非受迫性失误aces直接发球得分double_faults发球失误match_time_seconds比赛总时长秒这些字段还不是官方标准但覆盖了乒乓球比赛分析中最常见的技术维度。如果你拿到的是其他字段可以按同样思路做映射。4. 完整实战案例用 Python 复盘比赛失利4.1 创建示例数据集由于无法直接获取 WTT 官方内部接口的完整技术统计这里构造一个结构清晰的示例数据集。需要强调以下数据仅用于演示数据分析流程不代表真实比赛结果。import json sample_matches [ { player_name: Player A, opponent_name: Opponent X, round_name: R32, result: loss, set_wins: 2, set_losses: 4, total_points_scored: 89, total_points_conceded: 96, serve_points_won: 31, serve_points_total: 60, receive_points_won: 22, receive_points_total: 58, forehand_wins: 24, forehand_attempts: 51, backhand_wins: 18, backhand_attempts: 47, rallies_over_5_wins: 15, rallies_over_5_total: 38, unforced_errors: 12, aces: 3, double_faults: 1, match_time_seconds: 2880 }, { player_name: Player B, opponent_name: Opponent Y, round_name: R16, result: loss, set_wins: 1, set_losses: 4, total_points_scored: 74, total_points_conceded: 88, serve_points_won: 27, serve_points_total: 55, receive_points_won: 18, receive_points_total: 54, forehand_wins: 20, forehand_attempts: 46, backhand_wins: 14, backhand_attempts: 39, rallies_over_5_wins: 11, rallies_over_5_total: 32, unforced_errors: 15, aces: 1, double_faults: 2, match_time_seconds: 2410 }, { player_name: Player C, opponent_name: Opponent Z, round_name: QF, result: loss, set_wins: 3, set_losses: 4, total_points_scored: 101, total_points_conceded: 105, serve_points_won: 34, serve_points_total: 68, receive_points_won: 28, receive_points_total: 66, forehand_wins: 26, forehand_attempts: 58, backhand_wins: 22, backhand_attempts: 52, rallies_over_5_wins: 18, rallies_over_5_total: 44, unforced_errors: 10, aces: 4, double_faults: 2, match_time_seconds: 3560 }, { player_name: Player D, opponent_name: Opponent W, round_name: R16, result: win, set_wins: 4, set_losses: 1, total_points_scored: 95, total_points_conceded: 72, serve_points_won: 35, serve_points_total: 58, receive_points_won: 29, receive_points_total: 55, forehand_wins: 28, forehand_attempts: 49, backhand_wins: 20, backhand_attempts: 40, rallies_over_5_wins: 16, rallies_over_5_total: 30, unforced_errors: 6, aces: 5, double_faults: 0, match_time_seconds: 2100 } ] with open(data/raw_matches.json, w, encodingutf-8) as f: json.dump(sample_matches, f, ensure_asciiFalse, indent2) print(示例数据已写入 data/raw_matches.json)这里故意放入了 3 场失利和 1 场胜利目的是让后续分析有一个对照组。真实项目中你应该收集尽可能多的胜场和负场数据而不是只挑个别场次。4.2 导入数据并清洗接下来用 pandas 读取 JSON并计算派生指标。import pandas as pd df pd.read_json(data/raw_matches.json) # 派生指标发球得分率、接发球得分率、正手得分率、反手得分率 df[serve_win_rate] df[serve_points_won] / df[serve_points_total] df[receive_win_rate] df[receive_points_won] / df[receive_points_total] df[forehand_win_rate] df[forehand_wins] / df[forehand_attempts] df[backhand_win_rate] df[backhand_wins] / df[backhand_attempts] df[rally_win_rate] df[rallies_over_5_wins] / df[rallies_over_5_total] # 关键分衡量总得分率 df[total_win_rate] df[total_points_scored] / (df[total_points_scored] df[total_points_conceded]) # 显示前几行 print(df[[player_name, round_name, result, serve_win_rate, receive_win_rate, forehand_win_rate, backhand_win_rate, rally_win_rate, total_win_rate]].round(3))预期输出类似这样player_name round_name result serve_win_rate receive_win_rate ... rally_win_rate total_win_rate 0 Player A R32 loss 0.517 0.379 ... 0.395 0.481 1 Player B R16 loss 0.491 0.333 ... 0.344 0.457 2 Player C QF loss 0.500 0.424 ... 0.409 0.490 3 Player D R16 win 0.603 0.527 ... 0.533 0.569从这组模拟数据能隐约看出一个方向失利场次的接发球得分率普遍低于胜场同时相持得分率也偏低。这正好对应很多乒乓球比赛的实际规律——接发球环节一旦被动后续相持的主动权就很难建立。为什么先算得分率而不是直接用得分总数因为不同比赛的回合数不同总分差异会掩盖真实效率。得分率是一个标准化指标便于跨场次对比。4.3 对比胜场与负场的核心指标单个场次波动大只有分组对比才能看出整体趋势。下面按 result 分组计算每个核心指标的均值group_cols [ serve_win_rate, receive_win_rate, forehand_win_rate, backhand_win_rate, rally_win_rate, total_win_rate ] summary df.groupby(result)[group_cols].mean().round(3) print(summary)输出示例serve_win_rate receive_win_rate forehand_win_rate backhand_win_rate rally_win_rate total_win_rate result loss 0.503 0.379 0.468 0.426 0.383 0.476 win 0.603 0.527 0.571 0.500 0.533 0.569这个表格已经比较直观。在以整个团队为维度看时失利场次受到最大影响的指标是接发球得分率和相持得分率。这提示我们后续训练重点可能要放在接发球之后的衔接和相持段的稳定性上。不过需要警惕这里的示例数据只有 4 条记录统计意义有限。真实分析中至少要聚合同一个选手多站比赛、多个赛季的数据才有说服力。4.4 可视化雷达图展示技术短板雷达图适合展示多维度能力对比。我们用 matplotlib 画一个“失利场次平均能力”和“胜利场次平均能力”的对比图。import matplotlib.pyplot as plt import numpy as np # 设置中文字体避免乱码 plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei, PingFang SC] plt.rcParams[axes.unicode_minus] False labels [发球得分率, 接发球得分率, 正手得分率, 反手得分率, 相持得分率] num_vars len(labels) loss_values summary.loc[loss, [serve_win_rate, receive_win_rate, forehand_win_rate, backhand_win_rate, rally_win_rate]].values win_values summary.loc[win, [serve_win_rate, receive_win_rate, forehand_win_rate, backhand_win_rate, rally_win_rate]].values def draw_radar(loss_values, win_values, labels): angles np.linspace(0, 2 * np.pi, num_vars, endpointFalse).tolist() angles angles[:1] loss_values np.concatenate([loss_values, [loss_values[0]]]) win_values np.concatenate([win_values, [win_values[0]]]) fig, ax plt.subplots(figsize(8, 8), subplot_kwdict(polarTrue)) ax.plot(angles, loss_values, label失利场次, color#d62728) ax.fill(angles, loss_values, color#d62728, alpha0.15) ax.plot(angles, win_values, label胜利场次, color#1f77b4) ax.fill(angles, win_values, color#1f77b4, alpha0.15) ax.set_xticks(angles[:-1]) ax.set_xticklabels(labels, fontsize12) ax.set_ylim(0, 0.8) ax.set_title(横滨冠军赛国乒男单数据复盘示例数据, fontsize14, pad20) ax.legend(locupper right, bbox_to_anchor(1.3, 1.1)) plt.tight_layout() plt.savefig(output/radar_chart.png, dpi150, bbox_inchestight) plt.show() draw_radar(loss_values, win_values, labels)这张图可以明显看出失利场次的“接发球得分率”和“相持得分率”两项明显低于胜利场次。这正好呼应了前面表格里的结论。注意 matplotlib 在部分环境下中文会显示成方框解决方案是设置一个系统支持的中文字体。上面代码里已经加了SimHei、Microsoft YaHei、PingFang SC等多个备选如果你的系统字体不同需要替换成自己环境里存在的字体。4.5 输出结论与报告数据分析的终点不是画图而是输出结论。我们可以生成一份简单的文本报告loss_avg summary.loc[loss] win_avg summary.loc[win] diff (loss_avg - win_avg).round(3) print(失利场次与胜场之间的指标差异) print(diff)在示例数据中结果大约是serve_win_rate -0.100 receive_win_rate -0.148 forehand_win_rate -0.103 backhand_win_rate -0.074 rally_win_rate -0.150 total_win_rate -0.093差值最大的两项是“相持得分率”和“接发球得分率”。如果这是一个真实分析项目下一步就应该把比赛录像调出来重点标注这些失分回合看失分究竟是主动失误还是对手高质量逼迫。这种“数据 → 图表 → 文字结论”的流程就是体育数据分析的通用输出方式。5. 常见问题与排查思路在跑这类分析项目时大家经常会遇到下面几个问题。5.1 常见报错表格问题现象常见原因解决思路ModuleNotFoundError: No module named pandas虚拟环境未激活或未安装依赖确认已运行pip install pandas并检查当前解释器路径matplotlib 中文显示为方框系统缺少中文字体或未设置字体使用plt.rcParams[font.sans-serif]指定可用中文字体读取 JSON 报JSONDecodeError文件编码或格式不正确统一使用encodingutf-8先用文本编辑器检查 JSON 是否合法DataFrames 列名不匹配原始数据字段与代码不一致打印df.columns根据实际字段名调整代码雷达图超出边界或重叠雷达图角度、尺寸或标签设置问题适当增加画布大小调整set_ylim用bbox_inchestight保存爬虫请求被拒绝请求频率过高或缺少 User-Agent遵守 robots.txt控制请求间隔添加合法 User-Agent必要时改用手动下载数据文件5.2 数据量不足时怎么判断如果只有一两场比赛的数据不要急着下结论。体育比赛随机性很大一小段数据很可能被个别回合放大。建议先跑描述性统计观察数据分布是否稳定再考虑建模。一个比较稳妥的做法是先积累至少 10 到 20 场同级别比赛的数据再做分组均值对比如果要建预测模型还需要更大样本和特征工程。5.3 爬虫合规提醒如果你打算从公开网站获取真实比赛数据请务必记住先看网站的 robots.txt控制访问频率不绕过登录限制不抓取个人隐私数据。赛事网站在赛季进行中可能随时调整页面结构爬虫代码也需要维护。个人学习研究建议优先寻找官方提供的 CSV / JSON 下载入口。6. 最佳实践与工程建议6.1 数据来源留痕在数据分析项目中最容易被忽略的就是数据来源记录。建议在项目里增加一个data_source.md文件写入数据来源 URL 或接口文档。抓取时间。字段说明。版本或快照日期。这样当结论出现争议时可以回溯到原始数据。6.2 指标口径统一体育数据的字段口径如果不统一分析结果会很混乱。比如“相持”的定义有人算第 5 拍以后有人算第 6 拍以后正手得分的统计也可能包含发球抢攻或只是相持段正手。建议项目一开始就明确指标口径并写在 README 里。以下是一个口径示例发球得分率本方发球后该分最终得分 ÷ 本方发球总次数。接发球得分率对方发球后该分最终得分 ÷ 本方接发球总次数。相持从第 6 拍开始。非受迫性失误不含对方高质量进攻造成的失误。6.3 小样本避免强结论多场次聚合是减少波动的重要手段。如果个别选手只输了一场比赛那么“他接发球差”这个结论是站不住脚的。比较合理的做法是同时看赛季平均、近期 5 场状态、面对不同打法选手时的分项数据。6.4 代码可复现为了让分析报告可复现建议固定随机种子如果涉及随机抽样。固定数据文件版本不使用实时变动的 URL。将核心分析逻辑写成函数或脚本而不是全部堆在 Notebook 里。记录 pandas 和 matplotlib 等依赖的版本号到requirements.txt。pip freeze requirements.txt6.5 可视化的可读性不要为了炫技使用复杂图表。柱状图、雷达图、分组折线图已经能覆盖大部分场景。注意以下细节颜色不要过度鲜艳考虑色弱读者。图例要写清楚“胜场 / 负场”。标题要包含比赛和时间范围。保存图片时使用dpi150以上保证放大后仍清晰。7. 总结与学习路线这篇文章从“横滨冠军赛国乒男子全部出局”这个体育话题切入实际上完成了一个完整的数据分析闭环构造数据、清洗数据、派生指标、分组对比、可视化输出、得出结论。你可以复制代码替换成自己的比赛数据就能得到一份可用的比赛复盘报告。如果想把这条路继续走下去可以按下面的方向扩展数据采集方向学习用 requests 和 BeautifulSoup 从公开赛事网站抓取比分再用 pandas 做清洗。机器学习方向收集多场比赛后用逻辑回归、随机森林或 XGBoost 预测比赛胜负特征可以包含发球得分率、接发球得分率、近期胜率、比赛时长等。时间序列方向把选手的得分率按时间排序观察整个赛事周期的状态波动。可视化看板方向用 Streamlit 或 Plotly Dash 搭建一个交互式赛事数据看板。对于刚开始做体育数据分析的朋友我的建议是不要急着上模型。先把数据质量做好把指标口径定清楚把描述性统计和可视化跑透再考虑预测类项目。只有对“每一列数据代表什么、每一个指标怎么算”有足够的把握后续建模和报告才不会失真。如果你对某个具体的扩展方向感兴趣可以自己动手先跑一版遇到报错时拿错误信息去搜索引擎定向排查。体育数据分析最有趣的地方不是模型有多复杂而是你能从一个比分背后看出选手的技术偏好、状态节奏和比赛策略。