用Python分析乒乓球赛事数据:从pandas清洗到可视化报表

发布时间:2026/9/3 8:24:55
用Python分析乒乓球赛事数据:从pandas清洗到可视化报表 如果你平时喜欢看乒乓球比赛应该会留意到赛后球迷最想看的除了冠军归属还有选手一路晋级的轨迹从八强到四强从半决赛到决赛每一场的比分、用时、对阵关系都值得回味。近期横滨冠军赛男单决赛中张本智和在自己的主场以 4:1 击败吴晙诚拿下男单冠军。作为技术开发者除了关注赛果我们还可以用 Python 把这些比赛记录整理成结构化数据再做一次完整的统计分析输出图表和报表。这篇文章不打算停留在“看比分”的层面而是把这场赛事当作一个实战场景带大家完成一个可以运行的乒乓球赛事数据采集与分析小项目。你会掌握如何设计比赛数据字段、用 pandas 清洗数据、统计选手胜率与净胜局、绘制横向对比柱状图与选手间胜率热力图最后把分析结果导出为 CSV 报表。文章中的示例数据以可复现为主技术流程可以复用到足球、篮球、羽毛球等各类体育赛事分析中。1. 背景与核心概念1.1 从一场乒乓球决赛说起横滨冠军赛男单决赛张本智和在家门口作战最终以大比分 4:1 击败吴晙诚。对普通观众来说这是一场精彩的比赛对数据分析从业者来说这就像一个微型的“赛事指标集”赢了几局、输了几局、打了多久、在哪个场馆比赛这些信息汇总在一起就是最基础的体育数据。我们可以进一步思考如果只记录“冠军”和“亚军”信息量是远远不够的。数据分析的一个重要习惯是把事件拆成可量化的字段。比如一场比赛至少包含对阵双方、赛段、总比分、比赛时长、比赛场地。有了这些字段我们才能继续回答“主场胜率是否真的更高”“进入决赛前哪名选手消耗更大”“张本智和与吴晙诚的直接交手记录如何”这类问题。1.2 乒乓球赛事数据能分析什么乒乓球比赛的记录方式简洁很适合入门数据分析。每场比赛的比分、局数、赛程阶段都可以快速整理成表格。常见分析维度包括胜场数与胜率衡量一名选手在一段时间内的整体表现。净胜局总得分局与总失分局的差值能反映比赛统治力。主客场胜率区分在“横滨体育馆”这样的主场场馆与其他场馆的胜率差异。对阵关系矩阵统计 A 选手面对 B 选手的胜率寻找“克星”关系。比赛用时不同阶段消耗的体能差异。这些指标在真实的职业赛事分析中很常见。本文会用模拟数据把整套流程跑通。1.3 本文的技术目标本文的核心技术目标是设计比赛数据模型构造可复现的示例数据集。使用 pandas 完成数据加载、清洗、类型转换。按选手统计胜场、胜率、净胜局、平均用时。使用 matplotlib 输出柱状图与胜率热力图。将统计结果导出为 CSV 报表。整个过程只需要 Python 环境和两个依赖库没有复杂的外部服务适合初学者照着敲一遍。2. 准备工作环境与项目结构2.1 运行环境与版本说明本文示例代码依赖以下环境操作系统Windows 10/11、macOS、Linux 均可。Python 版本建议 3.9 或以上。依赖库pandas、matplotlib。可选依赖openpyxl仅在你需要导出 Excel 时安装。版本不需要刻意追求最新。pandas 2.x 和 matplotlib 3.x 都能正常运行本文代码。如果你的环境是 Python 3.7 或更低建议先升级 Python 版本否则部分语法和数据操作方式可能不兼容。2.2 安装 Python 依赖推荐使用虚拟环境避免污染系统 Python。打开终端执行mkdir pingpong_data_analysis cd pingpong_data_analysis python -m venv venvWindows 下激活虚拟环境venv\Scripts\activatemacOS 或 Linux 下激活虚拟环境source venv/bin/activate然后安装依赖pip install pandas matplotlib如果你想导出 Excel 报表可以再安装pip install openpyxl安装完成后可以用下面的命令确认版本python -c import pandas as pd; import matplotlib; print(pd.__version__, matplotlib.__version__)只要命令能正常输出版本号环境就准备好了。2.3 项目目录结构为了让代码清晰建议按下面的结构组织文件pingpong_data_analysis/ ├── make_sample_data.py ├── analyze.py ├── match_records.csv ├── player_stats.csv ├── win_chart.png └── head_to_head_matrix.png其中make_sample_data.py负责构造示例比赛数据并写入match_records.csv。analyze.py主分析脚本读取 CSV、清洗、统计、画图、导出报表。match_records.csv比赛数据源文件由脚本生成。player_stats.csv选手统计报表。win_chart.png胜场数柱状图。head_to_head_matrix.png选手间胜率热力图。后面我们逐文件编写。3. 数据获取与数据合规3.1 数据从哪里来真实乒乓球赛事数据可以来自官方网站、赛事新闻、体育数据平台。常见字段包括选手名称、比赛日期、赛段、比分、场馆等。在把数据交给程序分析之前我们通常要完成两步人工或脚本采集原始数据。把原始数据整理成统一结构的表格。如果你的数据来源是网页可以用 requests 抓取 HTML再用 BeautifulSoup 解析页面。但这篇文章不直接写爬虫逻辑因为不同网站的反爬策略和页面结构差异很大写死某一家网站的解析规则反而容易过时。3.2 爬取公开数据时的边界公开网页数据不等于可以随意大批量抓取。开发者在做数据采集时至少要关注几点查看网站的robots.txt了解服务端是否允许爬取。遵循网站的服务条款不用于商业用途。控制请求频率避免对目标服务器造成压力。尊重版权尤其是转述和引用比赛数据时要注明来源。如果只是自己做学习研究建议用少量数据做技术验证不要在公网上高频请求。3.3 为什么本文使用模拟数据很多初学者卡在“找不到数据”这一步。其实对数据分析项目来说第一步是打通代码流程而不是纠结数据是否绝对真实。本文构造一份模拟数据集字段结构尽量接近真实比赛数据这样你可以完整跑通“读取-清洗-统计-可视化-导出”的全流程。等代码验证完毕再把数据源替换成真实的官方赛程数据即可。需要特别说明的是本文数据集中出现的“张本智和”“吴晙诚”仅用于关联赛事背景所有比分、日期、场馆记录均为演示用途不代表该赛事真实统计结果。4. 数据建模与示例数据集4.1 字段设计建表之前先想清楚需要哪些字段。本文设计一个match_records.csv字段如下字段名类型含义datestr比赛日期格式为 YYYY-MM-DDtournamentstr赛事名称stagestr赛段例如 1/4决赛、半决赛、决赛player1str对阵选手一player2str对阵选手二score1int选手一赢得的局数score2int选手二赢得的局数duration_minfloat比赛时长单位分钟venuestr比赛场馆为什么字段这样设计原因在于后续统计需要区分胜方和负方而“选手一”“选手二”这种对称结构可以保留原始对阵信息不会在采集阶段就丢失顺序。总比分用两个独立的数字字段记录后续计算净胜局时非常方便。4.2 构造示例数据集新建make_sample_data.py写入以下代码# 文件路径make_sample_data.py import pandas as pd def build_demo_data(): columns [ date, tournament, stage, player1, player2, score1, score2, duration_min, venue, ] rows [ [2025-04-01, 横滨冠军赛, 1/4决赛, 张本智和, 选手A, 3, 0, 25, 横滨体育馆], [2025-04-02, 横滨冠军赛, 半决赛, 张本智和, 选手B, 3, 2, 48, 横滨体育馆], [2025-04-03, 横滨冠军赛, 决赛, 张本智和, 吴晙诚, 4, 1, 42, 横滨体育馆], [2025-03-15, 新加坡大满贯, 1/4决赛, 吴晙诚, 选手C, 3, 1, 36, 新加坡室内体育馆], [2025-03-16, 新加坡大满贯, 半决赛, 吴晙诚, 选手D, 1, 3, 39, 新加坡室内体育馆], ] df pd.DataFrame(rows, columnscolumns) return df if __name__ __main__: df build_demo_data() df.to_csv(match_records.csv, indexFalse, encodingutf-8-sig) print(示例数据已生成到 match_records.csv) print(df.to_string(indexFalse))这段代码使用 pandas 的DataFrame构造表格数据。注意to_csv中的encodingutf-8-sig参数它会在文件开头写入 BOM 头让 Excel 打开 CSV 时中文不乱码。如果你在命令行里运行脚本可以看到表格内容打印在终端上。4.3 运行数据生成脚本在项目目录下执行python make_sample_data.py预期输出中你会看到一张包含 5 场比赛记录的表。同时项目目录下多出match_records.csv文件。有一点需要提醒示例数据只有 5 条记录真实项目里数据量会大得多。不过本文重点是演示流程数据量小反而更容易排查问题。5. 核心代码实现赛事数据分析接下来编写主分析脚本analyze.py。为了讲解清晰我按功能拆成几个代码块最终合并在同一个脚本中。5.1 读取与清洗数据数据分析第一步永远是“清洗”。原始数据可能存在空值、字符串空格、错误类型等问题。下面这段代码负责读取 CSV并做基础清洗# 文件路径analyze.py片段一加载与清洗 import pandas as pd def load_and_clean(pathmatch_records.csv): df pd.read_csv(path, dtype{date: str}) # 删除对阵双方或比分为空的行 df df.dropna(subset[player1, player2, score1, score2]) # 比分必须转成整数否则后续计算会报错 df[score1] df[score1].astype(int) df[score2] df[score2].astype(int) # 比赛时长如果是空值暂时转为 NaN df[duration_min] pd.to_numeric(df[duration_min], errorscoerce) # 去掉选手名两侧空格 df[player1] df[player1].str.strip() df[player2] df[player2].str.strip() return df这里有几个关键点dtype{date: str}设置日期字段读入时按字符串处理避免被 pandas 自动解析成时间格式。dropna会删除关键字段为空的行防止统计时出现错误。astype(int)把比分强制转为整数因为 CSV 里的3可能被读成字符串。pd.to_numeric(..., errorscoerce)是常见的容错写法遇到无法转成数字的值时记为NaN而不是让程序崩溃。5.2 添加派生字段清洗完成后我们需要在表中增加一些方便统计的派生字段比如净胜局、总比分、胜方标记# 文件路径analyze.py片段二派生字段 def add_result_columns(df): df[score_gap] (df[score1] - df[score2]).abs() df[total_score] df[score1] df[score2] df[is_player1_win] df[score1] df[score2] return dfscore_gap表示两名选手赢得的局数差距。比如张本智和 4:1 击败吴晙诚净胜局为 3。total_score是双方总局数这项指标可以粗略反映比赛激烈程度。is_player1_win是一个布尔值后续判断胜负时可以直接使用。5.3 按选手统计核心指标现在实现一个通用的选手统计函数。它能在“这名选手出现在 player1 还是 player2”两种情况下正确统计出场次数、胜场数、胜率、净胜局和平均用时# 文件路径analyze.py片段三选手统计 def player_stats(df, player): sub df[(df[player1] player) | (df[player2] player)].copy() sub[win] sub.apply( lambda r: (r[player1] player and r[score1] r[score2]) or (r[player2] player and r[score2] r[score1]), axis1, ) games len(sub) wins int(sub[win].sum()) win_rate wins / games if games else 0 total_gap 0 for _, r in sub.iterrows(): if r[player1] player: total_gap r[score1] - r[score2] else: total_gap r[score2] - r[score1] avg_duration sub[duration_min].mean() return { player: player, games: games, wins: wins, win_rate: round(win_rate, 3), total_score_gap: total_gap, avg_duration: round(avg_duration, 1) if pd.notna(avg_duration) else None, }这段代码的思路是先用布尔条件筛选出包含该选手的所有比赛再判断每一场该选手是否获胜。apply接收一个匿名函数逐行处理逻辑直观。计算总净胜局时如果选手在player1列就用score1 - score2如果在player2列就用score2 - score1。这里提醒一点用apply和iterrows处理几千行数据没问题但如果数据量达到十几万行性能会明显下降。真实项目中建议用groupby或numpy做向量化计算。本文示例数据量小先用最易读的方式。5.4 主客场胜率统计张本智和是横滨的主场选手那么“主场胜率是否真的更高”是很有意思的问题。下面函数判断选手是否在横滨体育馆比赛并分别统计主客场战绩# 文件路径analyze.py片段四主客场统计 def venue_stats(df, player): sub df[(df[player1] player) | (df[player2] player)].copy() sub[is_home] sub[venue] 横滨体育馆 result {} for home_flag, label in [(True, 主场), (False, 客场)]: part sub[sub[is_home] home_flag] games len(part) if games 0: result[label] {games: 0, wins: 0, win_rate: None} continue wins 0 for _, r in part.iterrows(): if (r[player1] player and r[score1] r[score2]) or ( r[player2] player and r[score2] r[score1] ): wins 1 result[label] { games: games, wins: wins, win_rate: round(wins / games, 3), } return result在实际项目中“主场”不一定只看固定场馆名有时需要维护一个“主场场馆列表”。示例代码直接把“横滨体育馆”判定为主场逻辑简单一目了然。5.5 绘制胜场柱状图接下来使用 matplotlib 绘制各选手胜场数柱状图# 文件路径analyze.py片段五柱状图 import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei, PingFang SC, Arial Unicode MS] plt.rcParams[axes.unicode_minus] False def draw_win_chart(stats_df): df stats_df.sort_values(wins, ascendingTrue) plt.figure(figsize(8, 4)) plt.bar(df[player], df[wins], color#4C72B0) plt.xlabel(选手) plt.ylabel(胜场数) plt.title(模拟赛事选手胜场统计) for i, v in enumerate(df[wins]): plt.text(i, v 0.05, str(v), hacenter) plt.tight_layout() plt.savefig(win_chart.png, dpi120) plt.show()sort_values(wins, ascendingTrue)让胜场最少的选手排在左侧最多的在右侧柱子按递增顺序排列视觉效果更好。plt.text用于在柱子上方标注具体数值。中文字体是 Windows 和 macOS 开发者经常会遇到的问题。示例代码提供了几种常见中文字体具体使用哪一种取决于系统中是否安装。如果画出来的图是方框字符可以按第 7 章的排查方法处理。5.6 绘制选手间胜率热力图最后一类可视化是胜率热力图。它用来回答“张本智和面对吴晙诚的胜率是多少”“吴晙诚面对其他选手的胜率是多少”这类问题# 文件路径analyze.py片段六胜率矩阵与热力图 def draw_matrix(df, players): matrix pd.DataFrame(indexplayers, columnsplayers, dtypefloat) for p1 in players: for p2 in players: if p1 p2: matrix.loc[p1, p2] None continue sub df[ ((df[player1] p1) (df[player2] p2)) | ((df[player1] p2) (df[player2] p1)) ] if len(sub) 0: matrix.loc[p1, p2] None else: wins 0 for _, r in sub.iterrows(): if (r[player1] p1 and r[score1] r[score2]) or ( r[player2] p1 and r[score2] r[score1] ): wins 1 matrix.loc[p1, p2] wins / len(sub) return matrix def draw_heatmap(matrix): fig, ax plt.subplots(figsize(7, 6)) im ax.imshow(matrix.values, cmapYlOrRd) ax.set_xticks(range(len(matrix.columns))) ax.set_xticklabels(matrix.columns) ax.set_yticks(range(len(matrix.index))) ax.set_yticklabels(matrix.index) for i in range(len(matrix.index)): for j in range(len(matrix.columns)): v matrix.iloc[i, j] if pd.notna(v): ax.text(j, i, f{v:.2f}, hacenter, vacenter) fig.colorbar(im, axax) ax.set_title(选手间胜率矩阵行选手面对列选手) plt.tight_layout() plt.savefig(head_to_head_matrix.png, dpi120) plt.show()胜率矩阵的每个格子表示“行选手”面对“列选手”的胜率。对角线表示选手与自己比赛没有意义因此设置为空。这里要明确说明矩阵里只有两位真实选手的示例记录其余多为占位符选手。真实项目中矩阵会比较大颜色深浅能直观反映选手之间的“克制关系”。5.7 导出统计报表统计完成后把各选手的核心指标导出为 CSV# 文件路径analyze.py片段七导出报表 def export_report(stats_df, pathplayer_stats.csv): stats_df.to_csv(path, indexFalse, encodingutf-8-sig)indexFalse表示不把行号写进文件encodingutf-8-sig继续解决中文乱码问题。5.8 组装完整脚本把上面的函数组合进main函数形成完整的analyze.py文件# 文件路径analyze.py完整版 import pandas as pd import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei, PingFang SC, Arial Unicode MS] plt.rcParams[axes.unicode_minus] False def load_and_clean(pathmatch_records.csv): df pd.read_csv(path, dtype{date: str}) df df.dropna(subset[player1, player2, score1, score2]) df[score1] df[score1].astype(int) df[score2] df[score2].astype(int) df[duration_min] pd.to_numeric(df[duration_min], errorscoerce) df[player1] df[player1].str.strip() df[player2] df[player2].str.strip() return df def add_result_columns(df): df[score_gap] (df[score1] - df[score2]).abs() df[total_score] df[score1] df[score2] df[is_player1_win] df[score1] df[score2] return df def player_stats(df, player): sub df[(df[player1] player) | (df[player2] player)].copy() sub[win] sub.apply( lambda r: (r[player1] player and r[score1] r[score2]) or (r[player2] player and r[score2] r[score1]), axis1, ) games len(sub) wins int(sub[win].sum()) win_rate wins / games if games else 0 total_gap 0 for _, r in sub.iterrows(): if r[player1] player: total_gap r[score1] - r[score2] else: total_gap r[score2] - r[score1] avg_duration sub[duration_min].mean() return { player: player, games: games, wins: wins, win_rate: round(win_rate, 3), total_score_gap: total_gap, avg_duration: round(avg_duration, 1) if pd.notna(avg_duration) else None, } def venue_stats(df, player): sub df[(df[player1] player) | (df[player2] player)].copy() sub[is_home] sub[venue] 横滨体育馆 result {} for home_flag, label in [(True, 主场), (False, 客场)]: part sub[sub[is_home] home_flag] games len(part) if games 0: result[label] {games: 0, wins: 0, win_rate: None} continue wins 0 for _, r in part.iterrows(): if (r[player1] player and r[score1] r[score2]) or ( r[player2] player and r[score2] r[score1] ): wins 1 result[label] { games: games, wins: wins, win_rate: round(wins / games, 3), } return result def draw_win_chart(stats_df): df stats_df.sort_values(wins, ascendingTrue) plt.figure(figsize(8, 4)) plt.bar(df[player], df[wins], color#4C72B0) plt.xlabel(选手) plt.ylabel(胜场数) plt.title(模拟赛事选手胜场统计) for i, v in enumerate(df[wins]): plt.text(i, v 0.05, str(v), hacenter) plt.tight_layout() plt.savefig(win_chart.png, dpi120) plt.show() def draw_matrix(df, players): matrix pd.DataFrame(indexplayers, columnsplayers, dtypefloat) for p1 in players: for p2 in players: if p1 p2: matrix.loc[p1, p2] None continue sub df[ ((df[player1] p1) (df[player2] p2)) | ((df[player1] p2) (df[player2] p1)) ] if len(sub) 0: matrix.loc[p1, p2] None else: wins 0 for _, r in sub.iterrows(): if (r[player1] p1 and r[score1] r[score2]) or ( r[player2] p1 and r[score2] r[score1] ): wins 1 matrix.loc[p1, p2] wins / len(sub) return matrix def draw_heatmap(matrix): fig, ax plt.subplots(figsize(7, 6)) im ax.imshow(matrix.values, cmapYlOrRd) ax.set_xticks(range(len(matrix.columns))) ax.set_xticklabels(matrix.columns) ax.set_yticks(range(len(matrix.index))) ax.set_yticklabels(matrix.index) for i in range(len(matrix.index)): for j in range(len(matrix.columns)): v matrix.iloc[i, j] if pd.notna(v): ax.text(j, i, f{v:.2f}, hacenter, vacenter) fig.colorbar(im, axax) ax.set_title(选手间胜率矩阵行选手面对列选手) plt.tight_layout() plt.savefig(head_to_head_matrix.png, dpi120) plt.show() def export_report(stats_df, pathplayer_stats.csv): stats_df.to_csv(path, indexFalse, encodingutf-8-sig) def main(): df load_and_clean() df add_result_columns(df) players [张本智和, 吴晙诚, 选手A, 选手B, 选手C, 选手D] stats_rows [player_stats(df, p) for p in players] stats_df pd.DataFrame(stats_rows) print( 选手统计 ) print(stats_df.to_string(indexFalse)) export_report(stats_df) draw_win_chart(stats_df) matrix draw_matrix(df, players) draw_heatmap(matrix) print(\n 张本智和主客场表现 ) print(venue_stats(df, 张本智和)) print(\n 张本智和 vs 吴晙诚 ) head_df df[ ((df[player1] 张本智和) (df[player2] 吴晙诚)) | ((df[player1] 吴晙诚) (df[player2] 张本智和)) ] print(player_stats(head_df, 张本智和)) if __name__ __main__: main()6. 运行与结果说明6.1 运行方式确保项目目录下已经有match_records.csv然后在虚拟环境中执行python analyze.py程序会依次完成数据加载、清洗、统计和画图。执行过程中终端会弹出两个 matplotlib 窗口一个显示胜场柱状图一个显示选手间胜率热力图。如果没有显示说明当前环境缺少图形界面你可以把plt.show()注释掉只保留plt.savefig()图片会保存到本地文件。6.2 预期输出终端中会显示类似下面的选手统计结果 选手统计 player games wins win_rate total_score_gap avg_duration 张本智和 3 3 1.000 6 38.3 吴晙诚 2 1 0.500 0 37.5 选手A 1 0 0.000 -3 25.0 选手B 1 0 0.000 -2 48.0 选手C 1 0 0.000 -2 36.0 选手D 1 1 1.000 2 39.0从模拟数据可以看到张本智和 3 场比赛全部获胜胜率 1.000净胜局为 6说明他在示例数据中的统治力较强。吴晙诚出战 2 场1 胜 1 负胜率 0.500。“张本智和 vs 吴晙诚”的单独统计会输出张本智和对阵吴晙诚 1 场、胜率 1.000 的结果与标题中的 4:1 赛果一致。同时项目目录下会生成两个图片文件和一个 CSV 报表win_chart.png各选手胜场柱状图。head_to_head_matrix.png选手间胜率热力图。player_stats.csv选手统计报表。7. 常见问题与排查思路运行数据分析脚本时最常遇到的并不是分析逻辑问题而是环境、编码、中文字体这些细节。下面整理一份排查表问题现象常见原因解决思路运行pip install报错网络源不稳定或权限不足使用国内镜像源或加--user参数读取 CSV 后中文变成乱码CSV 编码不一致写入时使用utf-8-sig读取时检查encodingmatplotlib 图片中的中文显示为方框系统中没有匹配的中文字体修改plt.rcParams[font.sans-serif]为已安装字体图表负号显示异常未关闭 Unicode 负号设置plt.rcParams[axes.unicode_minus] Falseastype(int)报错CSV 中存在空值或文本比分先dropna再使用pd.to_numeric调用plt.show()没有窗口弹出服务器或无图形界面环境改为只savefig保存图片数据量大时程序运行缓慢使用大量apply和iterrows改用groupby、merge等向量化操作从工程角度说遇到报