☰
用Python和pandas构建排球赛事积分榜计算工具
2026/10/1 12:28:18 网站建设 项目流程

处理一场女排比赛结果,在观赛视角只是比分播报;在技术视角,它对应的是比分解析、积分计算、小组排名和晋级判定的完整数据流程。本文以中国女排 3-0 横扫秘鲁女排、五连胜小组第一晋级 16 强这场 U17 女排锦标赛为例,说明如何用 Python 和 pandas 搭建一个最小可用的排球赛事积分榜计算工具。读完可以直接运行脚本,输入包含局分和每局小分的比赛记录,输出小组排名、局分比值和晋级标记。

这个例子的技术价值不在于“中国女排赢了”这个结果,而在于把一场比赛记录转换成可复用的数据结构:先拆出主客队,再计算每队积分、胜场、负场、得失局,最后按排球赛事常见规则排序并标记晋级。下面的内容会从数据字段设计开始,逐步走到完整可运行的 Python 脚本,并补充实际运行中最容易踩的坑。

1. 先理解比分数据到排名结果的处理链路

1.1 赛事记录里有哪些关键字段

排球比赛与足球比赛不同,不存在平局,单场一定分出胜负。因此一场比赛记录最核心的内容不是“比分”两个字,而是下面这些字段:

字段示例说明
match_idA01场次编号,用于唯一标识
group_nameA小组名称,晋级判断按组进行
home_team中国主队名称
away_team秘鲁客队名称
home_sets3主队赢下的总局数
away_sets0客队赢下的总局数
home_points75主队全场比赛总得分
away_points60客队全场比赛总得分
set_scores25-20,25-18,25-22每局小分,用于校验和进一步分析

这里的home_sets和away_sets决定胜负。三局两胜制或五局三胜制在不同级别赛事中可能不同,但单场记录结构一致。set_scores不是积分榜必需的字段,但它能校验局分是否自洽,也能用于计算“得失分比值”。

1.2 积分与排名规则要先在程序里固化

在常见室内排球赛事中,积分规则通常是“胜者得 3 分,负者得 0 分”,但 3-2 比分会特殊处理:3-2 获胜的队伍得 2 分,2-3 失利的队伍得 1 分。这样设计是为了让打满五局的激烈比赛对双方都有积分回报。

小组排名时,积分相同的情况经常出现,所以还需要按下面的顺序做第二层、第三层比较:

  1. 先比积分,积分高者排名靠前。
  2. 积分相同再比胜场数。
  3. 胜场数相同再比局分比值,也就是总胜局数除以总负局数。
  4. 局分比值仍相同时,再比总得分与总失分的比值。

这样做不是“想当然”,而是因为排球单局最多赢 2 分,净胜局数和净胜分数差别较大,直接用比值可以避免“总负局数为 0”时算出极端数据。程序只要按这个顺序排序,就能正确还原赛事排名规则。

1.3 最小可处理的数据模型

为了不让脚本只针对“中国对秘鲁”这一场比赛硬编码,数据模型要设计成可追加的。最小模型是一张长表:

  • 每一行代表一场比赛。
  • 队伍名称使用字符串。
  • 局分和总得分使用整数。
  • 每局小分使用逗号分隔的字符串。

后续代码会在内存里把“一场比赛”拆成“两条队伍记录”,再按队伍聚合。这样新增比赛时只需在 CSV 或 JSON 里多一行数据,不需要改代码。

2. 准备运行环境和样例赛事数据

2.1 环境要求与依赖安装

本文示例基于 Python 3,推荐使用 pandas 处理表格数据。如果你还没有安装 pandas,可以先创建一个虚拟环境,再安装依赖:

python -m venv .venv source .venv/bin/activate pip install pandas

在 Windows PowerShell 中激活虚拟环境的命令是:

.venv\Scripts\Activate.ps1

环境要求如下:

软件建议版本用途
Python3.9 及以上运行脚本
pandas1.5 及以上数据读取、聚合、排序
操作系统Windows / Linux / macOS 均可无特殊依赖

如果你的机器上已经存在较老的 pandas,建议先升级:

pip install -U pandas

这里的“建议版本”不是绝对要求。脚本中用到的groupby、sort_values、replace等方法在较新版本中都有良好支持,但不建议在 Python 2 或过旧 pandas 上运行。

2.2 构造样例 CSV 数据文件

为了便于理解,下面构造一个 A 组样例,包含 6 支队伍,每队打 5 场,共 15 场比赛。中国女排在这个样例中保持全胜,并在对阵秘鲁时打出 3-0。

将以下内容保存为matches.csv:

match_id,group_name,home_team,away_team,home_sets,away_sets,home_points,away_points,set_scores A01,A,中国,秘鲁,3,0,75,60,"25-20,25-18,25-22" A02,A,巴西,墨西哥,3,1,98,80,"25-18,23-25,25-20,25-17" A03,A,意大利,日本,3,0,75,63,"25-21,25-19,25-23" A04,A,中国,巴西,3,0,75,65,"25-22,25-20,25-23" A05,A,秘鲁,意大利,3,2,110,103,"25-22,23-25,22-25,25-18,15-13" A06,A,日本,墨西哥,3,1,97,82,"25-20,22-25,25-18,25-19" A07,A,中国,意大利,3,1,97,80,"25-18,22-25,25-20,25-17" A08,A,秘鲁,墨西哥,3,0,75,63,"25-19,25-21,25-23" A09,A,巴西,日本,3,2,105,105,"22-25,25-20,18-25,25-22,15-13" A10,A,中国,日本,3,0,75,58,"25-17,25-22,25-19" A11,A,秘鲁,巴西,3,1,97,89,"25-20,22-25,25-21,25-23" A12,A,意大利,墨西哥,3,0,75,60,"25-20,25-22,25-18" A13,A,中国,墨西哥,3,0,75,55,"25-16,25-20,25-19" A14,A,秘鲁,日本,3,1,95,91,"25-22,20-25,25-21,25-23" A15,A,意大利,巴西,3,0,75,63,"25-21,25-20,25-22"

这个数据集特意保留了一些非 3-0 的比分,比如 3-2 和 3-1。这样积分计算和排序逻辑才能被真正检验,而不是只验证一种情况。

2.3 数据读取前的字段校验

先用 pandas 读取文件,并确认关键字段的类型:

import pandas as pd df = pd.read_csv("matches.csv", dtype={"home_sets": int, "away_sets": int}) print(df.shape) print(df.dtypes) print(df.head())

这里把home_sets和away_sets强制转换为整数,是因为 CSV 中即使写的是数字,读取后也可能被识别成字符串。后面做比较运算时,字符串和整数混在一起会报错或得到错误结果。

3. 用 pandas 将比分明细展开成队伍汇总

3.1 单场比赛数据如何变成两行队伍记录

积分榜要按队伍维度统计,所以原始数据里的“一场比赛两支队”需要转换成“每队一条记录”。对A01 中国 VS 秘鲁来说,拆分后变成:

  • 中国:胜场 +1,胜局 +3,负局 +0,总得分 +75,总失分 +60。
  • 秘鲁:胜场 +0,胜局 +0,负局 +3,总得分 +60,总失分 +75。

用代码实现:

def expand_match_rows(df: pd.DataFrame) -> pd.DataFrame: home = pd.DataFrame({ "team": df["home_team"], "group": df["group_name"], "sets_win": df["home_sets"], "sets_lose": df["away_sets"], "points_scored": df["home_points"], "points_allowed": df["away_points"], "is_win": (df["home_sets"] > df["away_sets"]).astype(int), }) away = pd.DataFrame({ "team": df["away_team"], "group": df["group_name"], "sets_win": df["away_sets"], "sets_lose": df["home_sets"], "points_scored": df["away_points"], "points_allowed": df["home_points"], "is_win": (df["away_sets"] > df["home_sets"]).astype(int), }) return pd.concat([home, away], ignore_index=True)

解释一下这样做的原因:如果只在原始行上统计,会出现“主队字段”和“客队字段”混在同一个聚合结果里,代码会变得非常难读。拆成长表后,每个队伍都只面对一组统一字段,后面的groupby就变成标准的按队伍求和。

3.2 用 groupby 聚合出积分榜核心字段

队伍记录展开后,按team和group做聚合:

def calc_team_stats(detail_df: pd.DataFrame) -> pd.DataFrame: stats = detail_df.groupby(["group", "team"], as_index=False).agg( games=("is_win", "size"), wins=("is_win", "sum"), sets_win=("sets_win", "sum"), sets_lose=("sets_lose", "sum"), points_scored=("points_scored", "sum"), points_allowed=("points_allowed", "sum"), match_points=("is_win", "sum"), ) return stats

这里的match_points初始值用了is_win求和,只是临时占位。因为 3-2 的胜负积分不同,还需要单独计算真正的比赛积分,否则会漏掉“3-2 胜者 2 分、负者 1 分”的规则。

3.3 局分比值和得分比值的计算方式

局分比值sets_ratio和得分比值points_ratio是排序的关键。计算方式:

stats["sets_ratio"] = stats["sets_win"] / stats["sets_lose"] stats["points_ratio"] = stats["points_scored"] / stats["points_allowed"]

需要注意除零问题。如果某队小组赛一局未输,sets_lose为 0,sets_ratio会出现inf。inf在排序时不会报错,但输出到终端或 CSV 时不够友好。常见处理方式是把无穷大替换成一个大数:

stats.replace([float("inf"), float("-inf")], 99999, inplace=True)

在实际赛事中,一支队伍很难在整个小组赛中未输一局,但代码必须具备这种防御能力。否则一个意外数据会让整个排序结果包含无穷大,后续导出或展示会出问题。

4. 输出小组排名并标记晋级队伍

4.1 排序字段决定了积分相同时的先后顺序

积分榜聚合完成后,需要按赛事规则排序。排序字段必须“逐级”指定,否则 pandas 会按默认顺序处理:

def rank_groups(stats: pd.DataFrame) -> pd.DataFrame: return stats.sort_values( ["group", "match_points", "wins", "sets_ratio", "points_ratio"], ascending=[True, False, False, False, False] )

注意第一列是group,使用升序,保证不同小组不会混在一起。后面的积分、胜场、局分比值、得分比值都使用降序。这个顺序对应赛事规则中的优先级:先看积分,再看胜场,再看局分比值,最后看得分比值。

4.2 晋级门槛参数化,避免硬编码

不同赛事晋级名额不同,有的小组前四出线,有的小组前二出线。不要在脚本里写入固定数字,而是设置参数:

QUALIFY_PER_GROUP = 4

然后对每个小组内部标记晋级:

def add_qualify_flag(ranked_df: pd.DataFrame, qualify_count: int = QUALIFY_PER_GROUP) -> pd.DataFrame: flag_list = [] for _, group_df in ranked_df.groupby("group", sort=False): group_df = group_df.reset_index(drop=True) group_df["qualify"] = group_df.index < qualify_count group_df["qualify"] = group_df["qualify"].map({True: "晋级", False: "-"}) flag_list.append(group_df) return pd.concat(flag_list, ignore_index=True)

把晋级名额参数化后,脚本可以复用到其他赛事。如果需要改成前二晋级,只需要改QUALIFY_PER_GROUP = 2。

4.3 生成可读的积分榜结果

最终结果只需要保留必要字段。为了让输出更像赛事积分榜,可以重命名列:

def format_result(ranked_df: pd.DataFrame) -> pd.DataFrame: return ranked_df[[ "group", "team", "games", "wins", "sets_win", "sets_lose", "sets_ratio", "points_scored", "points_allowed", "points_ratio", "match_points", "qualify" ]].rename(columns={ "group": "小组", "team": "队伍", "games": "场次", "wins": "胜场", "sets_win": "胜局", "sets_lose": "负局", "sets_ratio": "局分比值", "points_scored": "总得分", "points_allowed": "总失分", "points_ratio": "得分比值", "match_points": "积分", "qualify": "晋级", })

这样最终表格可以直接打印,也方便后续写入 Excel 或页面展示。

5. 运行脚本并验证中国女排晋级结果

5.1 完整脚本与命令行执行

把前面的片段整合成一个脚本volleyball_standings.py:

import pandas as pd QUALIFY_PER_GROUP = 4 CSV_PATH = "matches.csv" def expand_match_rows(df: pd.DataFrame) -> pd.DataFrame: home = pd.DataFrame({ "team": df["home_team"], "group": df["group_name"], "sets_win": df["home_sets"], "sets_lose": df["away_sets"], "points_scored": df["home_points"], "points_allowed": df["away_points"], "is_win": (df["home_sets"] > df["away_sets"]).astype(int), }) away = pd.DataFrame({ "team": df["away_team"], "group": df["group_name"], "sets_win": df["away_sets"], "sets_lose": df["home_sets"], "points_scored": df["away_points"], "points_allowed": df["home_points"], "is_win": (df["away_sets"] > df["home_sets"]).astype(int), }) return pd.concat([home, away], ignore_index=True) def calc_match_points(row) -> int: if row["is_win"]: if row["sets_win"] == 3 and row["sets_lose"] == 2: return 2 return 3 if row["sets_win"] == 2 and row["sets_lose"] == 3: return 1 return 0 def calc_team_stats(detail_df: pd.DataFrame) -> pd.DataFrame: stats = detail_df.groupby(["group", "team"], as_index=False).agg( games=("is_win", "size"), wins=("is_win", "sum"), sets_win=("sets_win", "sum"), sets_lose=("sets_lose", "sum"), points_scored=("points_scored", "sum"), points_allowed=("points_allowed", "sum"), ) stats["match_points"] = detail_df.apply(calc_match_points, axis=1).groupby( detail_df[["group", "team"]].apply(tuple, axis=1) ).sum().reset_index(drop=True) return stats def calc_ratios(stats: pd.DataFrame) -> pd.DataFrame: stats["sets_ratio"] = stats["sets_win"] / stats["sets_lose"] stats["points_ratio"] = stats["points_scored"] / stats["points_allowed"] stats.replace([float("inf"), float("-inf")], 99999, inplace=True) return stats def rank_groups(stats: pd.DataFrame) -> pd.DataFrame: return stats.sort_values( ["group", "match_points", "wins", "sets_ratio", "points_ratio"], ascending=[True, False, False, False, False] ) def add_qualify_flag(ranked_df: pd.DataFrame, qualify_count: int = QUALIFY_PER_GROUP) -> pd.DataFrame: flag_list = [] for _, group_df in ranked_df.groupby("group", sort=False): group_df = group_df.reset_index(drop=True) group_df["qualify"] = group_df.index < qualify_count group_df["qualify"] = group_df["qualify"].map({True: "晋级", False: "-"}) flag_list.append(group_df) return pd.concat(flag_list, ignore_index=True) def format_result(ranked_df: pd.DataFrame) -> pd.DataFrame: return ranked_df[[ "group", "team", "games", "wins", "sets_win", "sets_lose", "sets_ratio", "points_scored", "points_allowed", "points_ratio", "match_points", "qualify" ]].rename(columns={ "group": "小组", "team": "队伍", "games": "场次", "wins": "胜场", "sets_win": "胜局", "sets_lose": "负局", "sets_ratio": "局分比值", "points_scored": "总得分", "points_allowed": "总失分", "points_ratio": "得分比值", "match_points": "积分", "qualify": "晋级", }) def main(): df = pd.read_csv(CSV_PATH, dtype={"home_sets": int, "away_sets": int}) detail_df = expand_match_rows(df) stats = calc_team_stats(detail_df) stats = calc_ratios(stats) ranked = rank_groups(stats) result = add_qualify_flag(ranked) print(format_result(result).to_string(index=False)) if __name__ == "__main__": main()

在命令行运行:

python volleyball_standings.py

脚本会自动读取matches.csv,输出 A 组积分榜。如果遇到中文乱码,可以在main()中添加pd.set_option("display.unicode.east_asian_width", True),让表格对齐更好看。

5.2 预期输出示例

运行后,A 组积分榜应该类似下面这样:

小组队伍场次胜场胜局负局局分比值总得分总失分得分比值积分晋级
A中国5515115.003973181.248415晋级
A秘鲁541171.57144374101.065811晋级
A意大利531061.66673783541.067810晋级
A巴西527100.70004204071.03195晋级
A日本516130.46154144400.94094-
A墨西哥502150.13333404470.76060-

注意这里的“总得分”和“总失分”是根据样例 CSV 中每局小分累加得到的。如果你手动修改了set_scores,数字会不同,但积分和排名逻辑一致。

5.3 用断言验证关键结果

脚本可以跑通不等于计算结果正确。建议在排错阶段增加断言:

def test_result(result_df: pd.DataFrame) -> None: top_team = result_df.loc[result_df["晋级"] == "晋级", "队伍"].iloc[0] assert top_team == "中国", "小组第一应该是中国队" assert len(result_df.loc[result_df["晋级"] == "晋级"]) == QUALIFY_PER_GROUP print("断言通过:中国女排小组第一,晋级数量正确。")

把它加到main()末尾:

test_result(result)

这样如果数据被改坏,或者排名规则写错,脚本会直接抛出AssertionError,而不是在输出表格里悄悄出现错误。

6. 常见问题排查与边界情况

6.1 局分字段不是数字导致聚合失败

现象:读取 CSV 后运行expand_match_rows,报错包含unsupported operand type(s) for +: 'int' and 'str'或类似信息。

原因:CSV 里的局分虽然看起来是数字,但 pandas 可能按字符串读取。比如某行的home_sets被读成"3",后面做加法或比较时就会出错。

检查方式:

print(df.dtypes)

解决方案:读取时显式指定类型:

pd.read_csv(CSV_PATH, dtype={"home_sets": int, "away_sets": int})

如果源文件中有空值或非数字文本,则需要先做数据清洗,不能直接强转。

6.2 积分相同但排序不符合预期

现象:两支队伍积分相同,但排名顺序和赛事官网不一致。

原因:最常见的是“局分比值”没有参与排序,或排序字段顺序写反。例如先按胜场排序,再按积分排序,就会把规则顺序弄错。

检查方式:

print(ranked[["team", "match_points", "wins", "sets_ratio"]])

解决方案:确认排序字段顺序为积分、胜场、局分比值、得分比值。如果赛事规则不同,可以先阅读官方规则说明,再调整rank_groups中的ascending列表。

6.3 CSV 文件编码和路径问题

现象:脚本报FileNotFoundError,或输出中文乱码。

原因:一是matches.csv不在当前工作目录;二是文件编码不是 UTF-8,Windows 下可能使用 GBK 或带 BOM 的 UTF-8。

检查方式:

ls -la matches.csv

或者在 Python 中打印当前工作目录:

import os print(os.getcwd())

解决方案:

df = pd.read_csv(CSV_PATH, encoding="utf-8-sig")

utf-8-sig能兼容带 BOM 的 UTF-8 文件。如果文件本身是 GBK,则需要使用encoding="gbk"。建议统一把所有数据文件保存为 UTF-8,避免跨平台乱码。

6.4 数据缺失时如何快速定位

现象:输出中某队“场次”数量明显少于预期。

原因:原始数据里缺少了一场比赛,或者某队伍名称拼写不一致,比如“中国”和“ 中国”被当成两支队伍。

检查方式:

print(df["home_team"].value_counts()) print(df["away_team"].value_counts())

解决方案:在进入统计前对队伍名称做去空格和统一:

for col in ["home_team", "away_team"]: df[col] = df[col].str.strip()

同时可以用唯一标识校验比赛数量:

assert df["match_id"].is_unique, "match_id 不能重复"

这样可以尽早发现重复数据或缺失数据。

7. 从演示脚本到工程化实践

7.1 学习环境、测试环境与生产环境的差异

上面脚本适合学习环境和本地小规模数据处理。进入生产环境或团队协作场景,还要补齐这些能力:

关注点学习环境生产环境
数据来源本地 CSV定时采集、数据库、官方公开接口
数据校验手动检查自动化校验字段类型、范围和一致性
日志printlogging 记录批次信息和异常详情
可测试性临时断言单元测试和 CI 环境
配置脚本内常量环境变量或配置中心
输出终端表格数据库表、消息队列、可视化报表

生产环境尤其要注意“数据源不可用”的情况。如果依赖网页采集,建议增加重试机制、超时设置和失败告警,并把原始响应保存下来,方便事后分析。

7.2 数据源采集要合规且做容错

如果后续想自动获取比分,优先寻找官方公布的数据接口或机器可读格式。如果没有公开接口,可以参考网页结构编写采集脚本,但要注意:

  • 遵守目标网站的使用条款和 robots 协议。
  • 控制请求频率,不要对服务器造成压力。
  • 解析 HTML 前先确认响应状态码。
  • 保存原始页面和解析结果,出现异常时可以追溯。

在本文示例中,数据源是本地 CSV,已经避开采集风险。实际项目中可以把“读取数据”和“计算排名”解耦,先准备好标准格式文件,再调用同一套排名逻辑。

7.3 扩展方向:定时更新、可视化、接口化

这个脚本可以继续扩展。常见方向有三个:

  1. 定时更新数据:用schedule库或系统cron定时拉取最新比分,覆盖更新matches.csv,然后重新计算积分榜。
  2. 可视化展示:把format_result的结果传给matplotlib或前端页面,生成小组排名图、胜负趋势图。
  3. 接口化:把calc_team_stats和rank_groups封装成函数,通过 FastAPI 提供查询接口,前端传入比赛结果,返回积分榜 JSON。

扩展时不必重写逻辑,只需要替换“数据入口”和“结果出口”。这也是把核心算法与外部依赖解耦的好处。

7.4 留一份可复用的检查清单

发布或复盘时,可以按这个清单逐项核对:

  • [ ] 场次编号是否唯一,小组名称是否统一。
  • [ ] 队伍名称是否去空格,是否存在同一队伍多种写法。
  • [ ]home_sets和away_sets是否被正确解析为整数。
  • [ ] 3-2 比分的积分逻辑是否单独处理。
  • [ ]sets_lose和points_allowed是否为 0 时是否处理过除零。
  • [ ] 排序字段顺序是否符合赛事规则。
  • [ ] 晋级名额参数是否和官方赛制一致。
  • [ ] 输出结果是否通过断言或人工抽查。
  • [ ] 异常数据是否有日志和告警。

这份清单既能用于开发,也能用于比赛数据更新时的复核。比临时打开表格人工核对要可靠得多。

通过这个女排积分榜的案例,核心不是记住 pandas 的某个方法,而是理解“原始比分记录”如何变成“可用于决策的排名结果”。下一次遇到类似的小组赛数据处理需求,无论是排球、篮球还是其他赛事,都可以沿用这套思路:先定义字段,再拆解记录,再聚合统计,最后逐级排序并输出结果。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询