ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

用Python和pandas构建排球赛事积分榜计算工具

用Python和pandas构建排球赛事积分榜计算工具 处理一场女排比赛结果在观赛视角只是比分播报在技术视角它对应的是比分解析、积分计算、小组排名和晋级判定的完整数据流程。本文以中国女排 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 分。这样设计是为了让打满五局的激烈比赛对双方都有积分回报。小组排名时积分相同的情况经常出现所以还需要按下面的顺序做第二层、第三层比较先比积分积分高者排名靠前。积分相同再比胜场数。胜场数相同再比局分比值也就是总胜局数除以总负局数。局分比值仍相同时再比总得分与总失分的比值。这样做不是“想当然”而是因为排球单局最多赢 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.csvmatch_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_indexTrue)解释一下这样做的原因如果只在原始行上统计会出现“主队字段”和“客队字段”混在同一个聚合结果里代码会变得非常难读。拆成长表后每个队伍都只面对一组统一字段后面的groupby就变成标准的按队伍求和。3.2 用 groupby 聚合出积分榜核心字段队伍记录展开后按team和group做聚合def calc_team_stats(detail_df: pd.DataFrame) - pd.DataFrame: stats detail_df.groupby([group, team], as_indexFalse).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为 0sets_ratio会出现inf。inf在排序时不会报错但输出到终端或 CSV 时不够友好。常见处理方式是把无穷大替换成一个大数stats.replace([float(inf), float(-inf)], 99999, inplaceTrue)在实际赛事中一支队伍很难在整个小组赛中未输一局但代码必须具备这种防御能力。否则一个意外数据会让整个排序结果包含无穷大后续导出或展示会出问题。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, sortFalse): group_df group_df.reset_index(dropTrue) 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_indexTrue)把晋级名额参数化后脚本可以复用到其他赛事。如果需要改成前二晋级只需要改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.pyimport 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_indexTrue) 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_indexFalse).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, axis1).groupby( detail_df[[group, team]].apply(tuple, axis1) ).sum().reset_index(dropTrue) 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, inplaceTrue) 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, sortFalse): group_df group_df.reset_index(dropTrue) 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_indexTrue) 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(indexFalse)) 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-8Windows 下可能使用 GBK 或带 BOM 的 UTF-8。检查方式ls -la matches.csv或者在 Python 中打印当前工作目录import os print(os.getcwd())解决方案df pd.read_csv(CSV_PATH, encodingutf-8-sig)utf-8-sig能兼容带 BOM 的 UTF-8 文件。如果文件本身是 GBK则需要使用encodinggbk。建议统一把所有数据文件保存为 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 扩展方向定时更新、可视化、接口化这个脚本可以继续扩展。常见方向有三个定时更新数据用schedule库或系统cron定时拉取最新比分覆盖更新matches.csv然后重新计算积分榜。可视化展示把format_result的结果传给matplotlib或前端页面生成小组排名图、胜负趋势图。接口化把calc_team_stats和rank_groups封装成函数通过 FastAPI 提供查询接口前端传入比赛结果返回积分榜 JSON。扩展时不必重写逻辑只需要替换“数据入口”和“结果出口”。这也是把核心算法与外部依赖解耦的好处。7.4 留一份可复用的检查清单发布或复盘时可以按这个清单逐项核对[ ] 场次编号是否唯一小组名称是否统一。[ ] 队伍名称是否去空格是否存在同一队伍多种写法。[ ]home_sets和away_sets是否被正确解析为整数。[ ] 3-2 比分的积分逻辑是否单独处理。[ ]sets_lose和points_allowed是否为 0 时是否处理过除零。[ ] 排序字段顺序是否符合赛事规则。[ ] 晋级名额参数是否和官方赛制一致。[ ] 输出结果是否通过断言或人工抽查。[ ] 异常数据是否有日志和告警。这份清单既能用于开发也能用于比赛数据更新时的复核。比临时打开表格人工核对要可靠得多。通过这个女排积分榜的案例核心不是记住 pandas 的某个方法而是理解“原始比分记录”如何变成“可用于决策的排名结果”。下一次遇到类似的小组赛数据处理需求无论是排球、篮球还是其他赛事都可以沿用这套思路先定义字段再拆解记录再聚合统计最后逐级排序并输出结果。
返回列表