一场乒乓球比赛的结果,最终在媒体上可能只剩一行标题:张本美和 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 + Matplotlib
Python 生态里做表格数据处理,最直接的选择是 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_id | M001 | 比赛唯一标识 |
| match_date | 2025-04-12 | 比赛日期 |
| tournament | 横滨冠军赛 | 赛事名称 |
| player_a | 张本美和 | 左侧选手 |
| player_b | 陈幸同 | 右侧选手 |
| score_str | 4: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中先导入依赖并读取 CSV:
import 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(":", expand=True) 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 的分差是 1,4: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"], errors="coerce").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"], axis=1 ) 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", ascending=False))这里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(name="match_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", ascending=True) ax.barh(plot_df["player"], plot_df["wins"], label="胜场") ax.barh(plot_df["player"], plot_df["losses"], left=plot_df["wins"], label="负场") ax.set_xlabel("比赛场次") ax.set_title("选手胜负场分布") ax.legend() plt.tight_layout() plt.savefig("output/wins_losses.png", dpi=150)这里用了一个左右堆叠的条形图,能同时展示胜场和负场。left=plot_df["wins"]是关键参数,它让负场柱状图从胜场柱状图的末端开始画,形成堆叠效果。
5.3 绘制局分分布图
局分差可以反映比赛激烈程度。绘制直方图:
fig, ax = plt.subplots(figsize=(8, 5)) ax.hist(df["score_diff"], bins=4, edgecolor="white") ax.set_xlabel("局分差") ax.set_ylabel("比赛数量") ax.set_title("局分差分布") plt.tight_layout() plt.savefig("output/score_diff_hist.png", dpi=150)如果统计双方胜局数 4:3 的比赛最多,说明赛事整体竞争激烈;如果大量 4:0,说明强弱分明。这个分布图在赛后总结中很实用。
5.4 把汇总结果导出为文件
统计结果不能只存在控制台里,建议导出成 CSV 或 Excel 供后续使用:
stats.to_csv("output/player_stats.csv", index=False, encoding="utf-8-sig") head_to_head.to_csv("output/head_to_head.csv", index=False, encoding="utf-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"], }), axis=1 ) 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 :lang=zh在 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"], errors="coerce").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", encoding="utf-8-sig")如果文件本来就是 GBK 编码,就改成encoding="gbk"。
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”的比赛。单纯看比分,只能知道胜负;把它放进结构化的比赛数据里,就会有选手战绩、局分差、胜率、对抗关系、赛事分布等更多维度。对于开发人员来说,真正有价值的不是写出某一段统计代码,而是能识别数据问题、设计可靠的结构、并在出错时快速定位。把这套流程练熟,再去对接真实赛事数据源或构建数据看板,就会顺畅很多。