ARTICLE DETAIL

资讯详情

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

用Python分析乒乓球比赛数据:从CSV清洗到可视化实战

用Python分析乒乓球比赛数据:从CSV清洗到可视化实战 一场乒乓球比赛的结果最终在媒体上可能只剩一行标题张本美和 4:2 力克陈幸同夺得横滨冠军赛女单冠军。但作为开发人员如果接到“把比赛结果变成数据报表”的需求看到的就不只是比分数值而是一整条从数据采集、清洗、统计到可视化输出的链路。这个链路在很多体育数据项目里都会反复出现值得用一篇完整文章把它讲透。我会以这场“4:2”的比赛数据作为切入点设计一个最小可运行的 Python 数据分析项目。读者可以按文章顺序准备好环境、构造示例数据然后通过 pandas 完成读取、清洗和统计再用 Matplotlib 画出选手胜场对比图和局分分布图。最后还会补充常见坑和生产环境落地时的差异方便你直接迁移到自己的赛事数据处理项目中。1. 先理解体育赛事数据分析要解决什么问题1.1 原始比分记录离可分析数据还差几步标题中的“4:2”是一句自然语言但程序并不能直接理解它。程序需要的是结构化字段选手 A 是谁、选手 B 是谁、两人各胜几局、比赛在哪一天、属于哪个赛事、最终获胜者是谁。只有把这些信息整理成表格才能用代码做统计。所以在实际项目中第一步不是写统计函数而是把零散比赛结果整理成规整的数据集。常见做法包括赛事运营团队手工维护一份 CSV 表格数据供应商提供 JSON 接口内部系统把比赛记录写入数据库。无论来源是什么最终都要落成统一的表结构。数据字段越规范后续统计和可视化越省事。反之如果一开始就堆很多自由文本后面每一步都会遇到清洗问题。1.2 不同数据来源的格式差异和选型不同来源的数据质量差异很大这里用一个表格来对比常见方案的适用场景数据来源优点缺点适用场景手工维护 CSV易于修改方便理解人工录入容易出错缺少约束小规模项目、爬坑学习数据商 API字段完整实时性好需要鉴权调用量有限制生产环境数据源稳定内部数据库查询灵活便于关联需要建表和权限管理已有业务系统的团队网页公开数据信息丰富格式不固定存在版权和合规风险仅用于验证思路不能随意抓取在本文的最小示例中我选择 CSV 作为数据载体。原因是 CSV 能用文本编辑器直接查看也便于在文章中展示。真实项目中如果接 API只需要把请求返回的 JSON 转成同样的 DataFrame后处理逻辑可以保持不变。需要注意的是生产环境接入数据源之前先确认数据授权和接口使用规范。公开数据可以用于学习和开发验证但不要绕过登录限制或高频抓取。1.3 技术选型为什么是 Python pandas MatplotlibPython 生态里做表格数据处理最直接的选择是 pandas。pandas 的 DataFrame 天然适合存储比赛记录可以方便地做筛选、分组、聚合。Matplotlib 则负责把统计结果画成图便于直接交给运营人员查看。这个组合的优势不是“性能最强”而是迭代快。体育赛事数据通常不会大到需要 Spark 或大数据框架单机 pandas 足够处理几万条比赛记录。先把逻辑在本地跑通再考虑是否迁移到数据库或实时管道符合大多数体育数据项目的成长路径。2. 准备 Python 开发环境并设计最小示例数据2.1 环境要求和依赖安装本文章使用 Python 3.9 以上版本。推荐先创建一个虚拟环境再安装依赖避免把全局 Python 环境弄乱。需要安装的依赖如下依赖库用途pandas数据读取、清洗、分组统计matplotlib绘制统计图表在终端执行以下命令python -m venv .venv source .venv/bin/activate pip install pandas matplotlib安装完成后可以用一段极短代码确认版本python -c import pandas as pd; print(pd.__version__) python -c import matplotlib; print(matplotlib.__version__)不同版本之间接口差异不大但如果你在旧版本上运行报错优先检查 pandas 和 matplotlib 是否太低。本文示例代码基于 pandas 2.x 和 matplotlib 3.x 编写其他版本需要微调。2.2 设计比赛记录表结构为了让示例数据既能体现“张本美和 4:2 陈幸同”这个结果又不编造更多真实比赛细节我会构造一个匹配需求的最小数据集。字段设计如下字段名示例值说明match_idM001比赛唯一标识match_date2025-04-12比赛日期tournament横滨冠军赛赛事名称player_a张本美和左侧选手player_b陈幸同右侧选手score_str4:2双方各自胜局数winner张本美和最终胜者这里把总比分保存为字符串4:2是为了演示“解析字符串”这一常见工程处理。真实数据源里比分可能来自 JSON也可能来自数据库的两个整数字段但解析思路是一致的。创建matches.csv文件内容如下match_id,match_date,tournament,player_a,player_b,score_str,winner M001,2025-04-12,横滨冠军赛,张本美和,陈幸同,4:2,张本美和 M002,2025-04-12,横滨冠军赛,孙颖莎,王曼昱,4:3,孙颖莎 M003,2025-04-11,横滨冠军赛,陈幸同,早田希娜,4:1,陈幸同 M004,2025-04-11,横滨冠军赛,张本美和,孙颖莎,2:4,孙颖莎 M005,2025-04-10,横滨冠军赛,王曼昱,伊藤美诚,4:0,王曼昱 M006,2025-04-10,横滨冠军赛,张本美和,申裕斌,4:2,张本美和 M007,2025-04-09,横滨冠军赛,陈幸同,平野美宇,4:3,陈幸同 M008,2025-04-09,横滨冠军赛,孙颖莎,伊藤美诚,4:1,孙颖莎这些记录用于演示处理逻辑不是官方统计。实际项目中数据需要以正式比赛记录为准。2.3 项目目录结构项目文件可以按下面的结构组织table_tennis_analysis/ ├── matches.csv ├── analysis.py └── output/analysis.py是主脚本output/用来存放生成的图表和统计结果。先创建目录mkdir -p table_tennis_analysis/output cd table_tennis_analysis将上面的 CSV 保存到项目根目录然后开始编写分析脚本。3. 读取比赛数据并解析比分字段3.1 用 pandas 读取 CSV在analysis.py中先导入依赖并读取 CSVimport pandas as pd import matplotlib.pyplot as plt df pd.read_csv(matches.csv) print(数据条数:, len(df)) print(\n前 3 行数据:) print(df.head(3))运行脚本python analysis.py正常情况下可以看到前 3 行数据。这里要注意pandas 默认会把中文字段读取为字符串match_date在读入时可能还是object类型。所以第二步需要确认字段类型避免后续日期处理出错。print(\n字段类型:) print(df.dtypes)如果match_date显示为object说明它还是字符串需要专门处理成日期类型。3.2 从 score_str 解析出双方胜局数4:2这种字符串不能直接参与数学运算需要拆成两个整数。可以用一个函数统一处理def parse_score(score_str): left, right score_str.split(:) return int(left), int(right) df[score_a] df[score_str].apply(lambda s: parse_score(s)[0]) df[score_b] df[score_str].apply(lambda s: parse_score(s)[1])也可以一次性生成两列效率更高score_parts df[score_str].str.split(:, expandTrue) df[score_a] score_parts[0].astype(int) df[score_b] score_parts[1].astype(int)拆分之后可以计算单场比赛的总局数和分差df[total_games] df[score_a] df[score_b] df[score_diff] abs(df[score_a] - df[score_b])这里score_diff表示局分差能反映比赛是接近还是悬殊。比如 4:3 的分差是 14:0 的分差是 4。3.3 数据清洗缺失值、日期和统一字段真实数据里最常见的清洗任务包括字段名不统一比如有时候叫player_a有时候叫player_a_name日期格式不一致比如2025-04-12和2025/04/12混用某个选手胜负字段为空比分字符串里有不可见字符比如4 : 2。先用代码检查缺失值print(\n缺失值统计:) print(df.isna().sum())处理缺失值的策略取决于业务不要一刀切删除。如果winner为空可以根据双方比分重新计算如果match_date为空可以考虑从比赛序号推导或者标记为未知。日期字段建议统一为 pandas 的 datetime 类型df[match_date] pd.to_datetime(df[match_date])如果出现解析异常通常会提示类似DateParseError或ValueError。可以先检查异常格式bad_dates df[pd.to_datetime(df[match_date], errorscoerce).isna()] print(解析失败的日期:, bad_dates)不要忽略这一步因为后续按周、按月统计时日期类型必须正确。4. 选手战绩与局分统计4.1 校验胜者字段是否和比分一致有了score_a和score_b后可以按比分重新计算胜者并和 CSV 中的winner字段对照df[actual_winner] df.apply( lambda row: row[player_a] if row[score_a] row[score_b] else row[player_b], axis1 ) diff_count (df[actual_winner] ! df[winner]).sum() print(胜者字段不一致的记录数:, diff_count)如果 CSV 里胜者与比分冲突说明数据录入有误。此时应该回到数据源确认而不是盲目信任某一个字段。4.2 计算每位选手的胜场、负场和胜率统计选手战绩时需要同时考虑选手出现在player_a和player_b的情况。一个简洁的做法是构造“选手维度”和“是否获胜”的长表records [] for _, row in df.iterrows(): records.append({ player: row[player_a], opponent: row[player_b], is_winner: row[actual_winner] row[player_a], score_self: row[score_a], score_opponent: row[score_b], }) records.append({ player: row[player_b], opponent: row[player_a], is_winner: row[actual_winner] row[player_b], score_self: row[score_b], score_opponent: row[score_a], }) long_df pd.DataFrame(records)然后按选手聚合stats long_df.groupby(player).agg( wins(is_winner, sum), matches(is_winner, count), ).reset_index() stats[losses] stats[matches] - stats[wins] stats[win_rate] stats[wins] / stats[matches] print(stats.sort_values(win_rate, ascendingFalse))这里win_rate会是一个小数如果希望显示成百分比可以再乘 100 并保留两位小数stats[win_rate_percent] (stats[win_rate] * 100).round(2)4.3 按对手和赛事维度继续下钻除了整体胜率项目里常需要看“某位选手对阵主要对手时的表现”。可以按player和opponent分组head_to_head long_df.groupby([player, opponent]).agg( wins(is_winner, sum), matches(is_winner, count), ).reset_index() head_to_head[losses] head_to_head[matches] - head_to_head[wins] print(head_to_head.sort_values([player, matches], ascending[True, False]))如果想看不同赛事的统计把tournament也加入分组即可。赛事维度的价值在于发现选手在不同赛事中的表现波动。tournament_stats df.groupby([tournament, actual_winner]).size().reset_index(namematch_count) print(tournament_stats)这种写法可以快速回答“哪个赛事里哪些选手赢过比赛”之类的问题。5. 把统计结果画成图表5.1 先解决 Matplotlib 中文显示问题Matplotlib 默认字体不含中文字体直接画中文标题会出现方框。最简单的解决方式是明确指定系统中可用的中文字体。import matplotlib import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [Microsoft YaHei] # Windows # Linux 可以改用 [Noto Sans CJK SC] 或 [WenQuanYi Zen Hei] plt.rcParams[axes.unicode_minus] False设置之后负号也能正常显示。不同系统的字体名不同如果设置了仍无效可以先查看当前可用字体from matplotlib import font_manager fonts [f.name for f in font_manager.fontManager.ttflist if Hei in f.name or CJK in f.name] print(fonts)5.2 绘制选手胜场对比条形图用前面得到的stats表绘制条形图fig, ax plt.subplots(figsize(8, 5)) plot_df stats.sort_values(wins, ascendingTrue) ax.barh(plot_df[player], plot_df[wins], label胜场) ax.barh(plot_df[player], plot_df[losses], leftplot_df[wins], label负场) ax.set_xlabel(比赛场次) ax.set_title(选手胜负场分布) ax.legend() plt.tight_layout() plt.savefig(output/wins_losses.png, dpi150)这里用了一个左右堆叠的条形图能同时展示胜场和负场。leftplot_df[wins]是关键参数它让负场柱状图从胜场柱状图的末端开始画形成堆叠效果。5.3 绘制局分分布图局分差可以反映比赛激烈程度。绘制直方图fig, ax plt.subplots(figsize(8, 5)) ax.hist(df[score_diff], bins4, edgecolorwhite) ax.set_xlabel(局分差) ax.set_ylabel(比赛数量) ax.set_title(局分差分布) plt.tight_layout() plt.savefig(output/score_diff_hist.png, dpi150)如果统计双方胜局数 4:3 的比赛最多说明赛事整体竞争激烈如果大量 4:0说明强弱分明。这个分布图在赛后总结中很实用。5.4 把汇总结果导出为文件统计结果不能只存在控制台里建议导出成 CSV 或 Excel 供后续使用stats.to_csv(output/player_stats.csv, indexFalse, encodingutf-8-sig) head_to_head.to_csv(output/head_to_head.csv, indexFalse, encodingutf-8-sig)保存 CSV 时utf-8-sig编码可以避免 Excel 打开中文乱码。如果是 Linux 环境且只用程序读取直接utf-8也可以。6. 运行验证与结果检查6.1 完整脚本的预期输出将上面的代码组合到analysis.py运行后会在终端看到数据条数: 8 胜者字段不一致的记录数: 0 选手统计: player wins matches losses win_rate win_rate_percent 0 孙颖莎 3 4 1 0.75 75.0 1 张本美和 2 3 1 0.67 66.7 2 陈幸同 2 3 1 0.67 66.7 3 王曼昱 1 2 1 0.50 50.0 4 伊藤美诚 0 2 2 0.00 0.0同时在output/目录下生成wins_losses.png和score_diff_hist.png两张图。6.2 用交叉验证确认统计口径统计结果是否正确不能只看脚本没报错。建议用两种方式交叉验证手动数一遍 CSV 中每位选手出现的场次和获胜场次用透视表再算一次胜场数和groupby结果对比。pivot_check df.apply( lambda row: pd.Series({ winner: row[actual_winner], loser: row[player_b] if row[actual_winner] row[player_a] else row[player_a], }), axis1 ) winner_counts pivot_check[winner].value_counts() print(winner_counts)如果winner_counts和stats[wins]对不上说明长表展开或胜负判断逻辑有误。6.3 检查图表是否合理打开图片后重点检查三处标题、坐标轴文字是否出现中文柱状图图例是否和图块顺序一致直方图的横轴范围是否符合分数差取值范围。如果中文出现方框回到 5.1 修改字体配置如果两张图空白检查是否调用了plt.show()之后再savefig()这会导致保存的图片没有内容。7. 常见问题与排查路径7.1 Matplotlib 中文乱码现象图表标题和坐标轴文字显示为方框。原因Matplotlib 默认字体不包含中文字形或系统缺少中文字体。排查fc-list :langzh在 Linux 上可以查看系统安装的中文字体。如果返回为空需要安装字体包。解决方案plt.rcParams[font.sans-serif] [Microsoft YaHei]或者改用系统中已有的中文字体名称。设置后最好重启 Python 进程再重新执行绘图代码。7.2 pandas 读取日期报错现象pd.to_datetime抛出ValueError或DateParseError。原因日期列中存在多种格式或包含无法识别的文本。排查bad df[pd.to_datetime(df[match_date], errorscoerce).isna()] print(bad)如果异常记录较少可以直接修正如果很多说明数据源格式不统一需要统一清洗规则。7.3 胜率出现除零错误现象某位选手没有比赛记录计算wins / matches时出现ZeroDivisionError或者统计表里出现 NaN。原因数据源缺失某位选手的比赛记录或者长表展开时没有覆盖所有选手。解决方案stats[win_rate] stats[wins] / stats[matches].replace(0, pd.NA)更合理的做法是过滤掉matches 0的行因为零场比赛的胜率没有业务意义。建议在统计前先检查print(stats[stats[matches] 0])7.4 CSV 字段错位或编码问题现象读取 CSV 后列名错位或者中文字段显示为乱码。原因CSV 文件头和数据行的分隔符不一致常见于表格软件导出的逗号分隔文件与中文逗号、分号混用或者是文件编码不是 UTF-8。排查head -3 matches.csv | cat -A如果没有中文乱码head -3能看到每一列的分隔符。读取时指定编码df pd.read_csv(matches.csv, encodingutf-8-sig)如果文件本来就是 GBK 编码就改成encodinggbk。7.5 图表保存后是空白现象脚本运行正常savefig也有输出但生成的图片是纯白背景。原因在plt.show()之后调用plt.savefig()图像窗口关闭导致画布内容被清空。解决方案先savefig()再show()或者直接注释掉show()。建议在脚本模式中只使用savefig()在 Jupyter 环境再显示绘图。8. 从学习环境到生产环境的落地建议8.1 学习环境怎么快速跑通学习环境的目标是理解逻辑不需要一开始就做得很重。推荐顺序是使用本文的 CSV 示例跑通读取、清洗、统计、画图全流程把示例数据改成自己的赛事数据观察哪些字段会变化把统计口径写清楚再逐步加入自动化脚本和参数配置。不要在学习阶段引入数据库、消息队列、分布式任务这些会分散对核心数据处理逻辑的注意力。8.2 生产环境还需要补充的模块如果要把这套逻辑放到团队或业务系统里至少还要补上以下内容模块需要处理的问题数据源接入从 API 或数据库读取而不是读 CSV配置外置数据库连接串、赛事 ID 等放到环境变量或配置中心日志监控记录每日处理条数、异常记录数、图表生成状态权限安全控制数据修改权限防止误改原始比赛记录调度使用定时任务每天更新战绩回滚每次跑批前备份上一版本报表失败时能恢复# 示例环境变量读取数据库连接 import os db_url os.getenv(DATABASE_URL) if not db_url: raise RuntimeError(缺少 DATABASE_URL 环境变量)这段代码说明了为什么生产环境要把敏感配置外置避免把连接串写死在代码仓库里。8.3 可复用交付清单当一个赛事数据统计脚本准备交付时可以对照下面这个清单逐项检查[ ] 输入文件路径是否为绝对路径或通过配置指定 [ ] 日期字段是否已经转成 datetime且没有解析失败记录 [ ] 胜者字段是否和比分交叉验证一致 [ ] 胜率除零问题是否处理 [ ] 中文图表是否正常显示 [ ] 统计结果是否导出为 CSV且编码适合目标系统 [ ] 是否记录本次处理的数据条数和异常条数 [ ] 是否在数据源变更时能快速定位字段映射问题 [ ] 是否明确区分示例数据和真实数据这个清单不需要覆盖所有业务但能避免最常见的数据质量事故。回到开头那场“4:2”的比赛。单纯看比分只能知道胜负把它放进结构化的比赛数据里就会有选手战绩、局分差、胜率、对抗关系、赛事分布等更多维度。对于开发人员来说真正有价值的不是写出某一段统计代码而是能识别数据问题、设计可靠的结构、并在出错时快速定位。把这套流程练熟再去对接真实赛事数据源或构建数据看板就会顺畅很多。
返回列表