用Python脚本统计比赛最高分:从Pandas清洗到多轮扩展 2024-9-15那天我在整理一场内部友谊赛的得分记录时发现要从几十行杂乱的成绩数据里人工找出“最高分”远比想象中麻烦——不是看一眼就能完事而是要反复核对选手名称、场次编号和得分列稍不留神就看串行甚至把某个选手的登记分当成了最终成绩。也是那天我决定花点时间把这个“统计比赛得分最大”的小需求做成一个可复用脚本。这篇博文就记录这个项目的完整过程从需求定义、方案选型、代码实现到边界情况处理再到后续多轮赛制的扩展改造。不管你是刚开始学Python、还是经常要手工处理成绩统计的赛事组织者这篇内容都能给你一套可以照抄的解决方案。1. 为什么“找最大得分”这件事值得单独写一个脚本1.1 数十行成绩记录里的人工查找远没有想象中可靠我们很多时候觉得“最高分不就是MAX函数一拉嘛”但实际场景里的数据往往不是一张干净整齐的表格。我在2024-9-15处理的这份记录来自一次持续多半天的交流赛数据分散在几个不同的汇总表里每张表有比赛轮次、选手编号、选手姓名、各裁判打分、最终得分。当时要回答一个问题——“今天所有场次里谁的单场得分最高”如果用眼睛去看你得先把十几张表格合并到一张再确认“最终得分”这一列没有漏填、没有把“8.5”写成“8,5”然后才能谈得上比较大小。而一旦表格超过几十行人工查找就会出现两个问题一是容易漏看扫过一列数字时对中间的峰值不敏感二是容易看错行特别是在多列数字结构一致的情况下视线很容易从得分列滑到旁边的小分列。这时写一个脚本的价值就体现出来了——它不光是帮你“求一个最大值”更重要的是把整个统计流程固化成代码从“读数据、清洗数据、计算、输出”每一步都可复现下次再收集到新成绩时只要数据格式不变跑一遍脚本就能得到结果。1.2 “最大得分”的定义直接决定了代码怎么写动手写代码之前我第一件做的事情是确认“最大”到底指什么。虽然听起来有点小题大做但不同赛制下“最大得分”这个指标差别很大如果是单轮比赛直接对得分列求最大值拿到该轮得分最高的人。如果是多轮比赛“最高分”可能指每位选手在多轮中的个人最高分也可能指所有选手、所有轮次里的绝对最高分。还有一种常见口径先求每位选手的总分再在总分里找最大值。这个定义不确认好后面写的代码很可能在高高兴兴跑完脚本后发现结果根本不是业务方要的那个数。我当时的项目明确是“统计比赛得分最大”结合来源数据选手在每轮只有一个最终得分所以核心口径就是在给定数据范围内找得分列的最大值并同时输出该得分对应的选手和场次信息。确认这一点之后再做方案选型就顺了。2. 三种统计方案的取舍Excel函数、SQL脚本、Pandas脚本2.1 三个常用方案的优劣势对比下表是我在实际考虑时整理的对比直接列出来供参考。方案上手难度处理混合格式数据可复用性扩展性适合场景Excel 手工操作 MAX/MATCH函数低弱遇到文本型数字、乱码列名就很头疼低每次都要重新改区域弱一次性、单表、数据量小SQL 查询语句中中对CSV文件要先做导入建表较高SQL可保存中数据已经在数据库里Python Pandas 脚本中强可写代码处理各种脏数据高改改路径就能复用强多文件、多轮次、字段不规整对于Excel优点是“所见即所得”但有几个场景直接劝退当我需要把多张CSV合并后再统计Excel需要手工复制粘贴当得分列被识别为文本时MAX函数直接返回0或者无效结果排查起来又得去检查单元格格式。对于SQL如果数据已经在数据库中一条“SELECT MAX(score)”就结束了可问题是我们手上的初始数据就是几个Excel文件为了这个临时需求去建表导入反而绕远了。2.2 我为什么最终选了PandasPandas的优势不只是“会统计”而是把前面最痛苦的“脏数据规整”这个环节也收纳进来了。它可以直接读取CSV、Excel可以把“8,5”这种带逗号的文本修正为浮点数可以用dropna过滤空值也可以按照轮次和选手分组聚合。后面一旦要扩展统计口径比如多轮赛制下的最高分、每轮前三名、某个选手的最好成绩基本上都是在原脚本上增加几行代码。从另一个角度说Python脚本运行一次后可以用“文件日期”的方式保存脚本和数据比如项目标题里的“2024-9-15”就是当天专门建的项目记录目录名。这样以后有人问“这个最高分是从哪些数据算出来的时候”我可以直接把原始文件和运行脚本都拿给他保证统计结果可追溯。这比基于Excel手工操作的结果要严谨得多。如果你只是需要一次快速查询那完全可以停留在Excel不必为了一个最大值去装Python环境。但如果你的需求是“以后每次比赛结束都想复用同一个脚本”那我建议直接用Pandas把一次性处理升级成一个小工具。3. 核心脚本实现读取成绩表、清洗字段、输出最高得分3.1 第一步约定输入格式并读取数据在写任何代码之前我先把源数据整理成了统一的格式。因为原始比赛记录有几个散表我合并成了一张完整的CSV文件列结构如下字段名说明示例round_no轮次编号R1player_id选手编号P001player_name选手姓名某选手Araw_score各裁判小分用逗号分隔8.5, 9.0, 8.0final_score最终得分8.75之所以额外保留raw_score是为了在统计最高分时还能追溯“这个最终得分是怎么来的”。final_score才是我们真正要参与最大值计算的字段。读取数据用Pandas就两行import pandas as pd df pd.read_csv(scores_20240915.csv, encodingutf-8) print(df.head())这里有一个细节读取CSV时如果直接显示乱码多半是文件编码不是UTF-8而是GBK等常见中文编码。我当时第一次读取后就踩了这个坑后来改用参数动态处理def load_scores(path): try: df pd.read_csv(path, encodingutf-8) except UnicodeDecodeError: df pd.read_csv(path, encodinggbk) return df先把编码问题解决掉后面所有字段的规整才有讨论意义。3.2 第二步预处理得分列解决类型和空值问题读取完成后不要急着求max。我先检查了final_score列的实际类型print(df[final_score].dtype)如果dtype是object说明这一列在CSV里是文本格式直接求max会得到按字典序排序的结果这是新手最容易掉进的坑。比如“9.5”和“10”在文本比较时会被判断为“9.5”更大因为字符串比较按字符逐个比“9”确实比“1”大但作为数值显然10比9.5更高。所以要把这一列强制转成数值类型df[final_score] pd.to_numeric(df[final_score], errorscoerce)带上errorscoerce的意思是遇到无法转换的内容时不直接报错中断而是把那个位置填成NaN这样后续还能知道哪些行数据有问题再做针对性处理。处理完以后查看空值行bad_rows df[df[final_score].isna()] print(bad_rows)这一步的产出价值是很大的——我在整理数据时确实发现两行得分填写成了“8,75”英文逗号转成numeric后变NaN。如果你的目标是统计最大得分这类数据不处理的话要么直接报错要么被静默跳过结果可信度都打折扣。3.3 第三步用正确的方式求最大值并定位所在行Pandas里求最大值有两种用法很多人会混淆。第一种是直接用max()拿到最高分这个数字本身max_score df[final_score].max() print(max_score)第二种更常用因为我们不仅仅要一个数字还要知道“谁得了这个最高分”。这时要改用idxmax()它会返回最高分所在行的行标签idx df[final_score].idxmax() best_row df.loc[idx] print(best_row)为什么选idxmax而不是先排序再取第一行因为sort_values会生成一个完整的新副本数据量小的时候无所谓但假设有几千上万条成绩记录idxmax只需线性扫描一遍找到最大值的位置效率更高代码语义也更直接。拿到best_row之后我格式化成方便人读的文本output ( f最高得分{best_row[final_score]}\n f对应选手{best_row[player_name]}编号{best_row[player_id]}\n f轮次{best_row[round_no]} ) print(output)核心统计逻辑到这一步其实已经完成了运行一次可以得到类似最高得分9.80 对应选手某选手E编号P012 轮次R4这种输出适合直接粘贴到汇报文档里一眼就能看到关键信息。3.4 完整脚本怎么看从数据源头到结果的映射关系把前面几步连起来就是一个最小可用的版本import pandas as pd def load_scores(path): ... def preprocess(df): df[final_score] pd.to_numeric(df[final_score], errorscoerce) return df def find_max_score(df): cleaned preprocess(df) idx cleaned[final_score].idxmax() return cleaned.loc[idx] if __name__ __main__: df load_scores(scores_20240915.csv) best find_max_score(df) print(best)整个脚本的映射关系很清晰源数据CSV - DataFrame - 清洗后的得分列 - idxmax定位 - 完整结果行。即使你之后要改统计口径要改的也只是一处函数而不是在Excel里重新拖一遍公式。4. 实际跑数据时反复遇见的边界情况真实的数据永远比示例数据难看以下四类边界情况是我在2024-9-15这个项目里反复处理过的不处理好它们“统计最大得分”的结果就会被质疑。4.1 得分列是文本数字max()直接给出错误结果前面提到过文本型数字在max()时按字典序比较。这个问题的隐蔽性在于如果得分都在8分到9分之间没有超过10的分数文本比较还真能碰巧得出正确结果一旦出现10分、10.5这种两位数的数值结果立刻出错。最稳妥的防御性做法是读取后先统一执行pd.to_numeric而不是等到发现结果不对再回头检查。我在清洗过程中还遇到另一个衍生的麻烦某个单元格的得分带有不可见字符比如“8.5 ”后面跟了一个空格或者全角数字“.”。pd.to_numeric对空格其实能自动处理但全角数字会转换失败。这时候需要先做一次字符替换df[final_score] ( df[final_score] .astype(str) .str.replace(, ,) .str.extract(r(\d\.?\d*), expandFalse) ) df[final_score] pd.to_numeric(df[final_score], errorscoerce)这段代码先用正则从混乱文本中提取第一个合理的数字表达式再转换为数值。适用于那种“某人手工填表把逗号、中文逗号混着用”的典型情况。4.2 空值和0分的区别不能默认等同在比赛场次中选手可能因为缺席而没有成绩也可能正常参赛但得分为0。这两种情况在统计最高分时都需要区别对待缺席的空值参与max()会被Pandas默认跳过不干扰结果但0分是有效成绩必须保留参与比较。问题在于很多人在Excel阶段就会把0分和空缺混在同一个单元格比如用“-”或者“”表示没参赛。在Pandas里读取时“-”会被整列转成object类型。所以我在预处理时明确做了两步先把“-”、“NA”、“空文本”统一替换成NaN表示无成绩再把剩下的数值部分转为数值类型。missing_markers [-, NA, N/A, ] df.replace(missing_markers, pd.NA, inplaceTrue) df[final_score] pd.to_numeric(df[final_score], errorscoerce)这样的好处是后续统计的时候可以自由选择口径如果想要“参赛范围内的最高分”直接max()如果要把缺席视为“该选手此轮得0分”先fillna(0)再max()。两种口径一行代码切换。4.3 并列最高分时只输出一个结果是信息丢失idxmax()遇到多个并列最大值时只会返回第一个出现的位置。从业务角度看这可能不是一个Bug但确实存在信息丢失。比如一轮比赛中有两位选手同时拿到9.8分谁才是“最高分获得者”如果没有并列处理你可能只看到其中一位的名字。所以我在项目里专门加了一个并列检查逻辑max_score df[final_score].max() tied df[df[final_score] max_score] if len(tied) 1: print(f并列最高分 {max_score} 共{len(tied)}位选手) for _, row in tied.iterrows(): print(f - {row[player_name]}{row[player_id]}轮次{row[round_no]}) else: row tied.iloc[0] print(f最高得分{row[final_score]}对应选手{row[player_name]})这个检查的价值在于它把一个隐性问题显式化。比赛主办方拿到统计结果时最怕的就是漏掉并列人员。脚本主动检测并列并分别列出来后面就不会出现“谁最高”的争议。4.4 多张分表合并时的脏数据累积初始数据并非一张表而是按轮次分开的多个文件。如果每个文件都有不同的字段顺序或列名写法比如有的叫“选手姓名”有的叫“姓名”直接concat会产生多个重复列。我当时的做法是先统一列名映射再合并column_map {选手姓名: player_name, 姓名: player_name, 选手编号: player_id} for col, target in column_map.items(): if col in df.columns: df.rename(columns{col: target}, inplaceTrue)合并完先检查总行数再检查是否有缺失的姓名字段。这一步在统计前多做五分钟后面调用统计脚本时就能少很多莫名其妙的问题。5. 从“单次统计”升级到“多轮赛制”的扩展改造5.1 按选手分组统计各人的历史最高分第一版脚本跑通之后我开始想如果这个比赛要连续进行多轮那“统计比赛得分最大”的需求就不会只问一次而是会衍生出更多问题。最常见的是“每位选手在多轮比赛中的个人最高分是多少”。实现上用groupby就可以personal_best ( df.groupby(player_id, as_indexFalse) .agg(max_score(final_score, max), best_round(round_no, max)) ) print(personal_best.sort_values(max_score, ascendingFalse))这里用max去聚合round_no其实有个隐含假设如果某选手在同一轮次只存在一条记录那么取到的轮次和最大分来自同一行。如果原始数据中存在同一轮同一选手多条记录这个写法就可能把最高分和轮次错配。更严谨的做法是先按轮次求本轮最大分再按选手求跨轮最大值round_best ( df.groupby([player_id, round_no])[final_score] .max() .reset_index() ) personal_best ( round_best.sort_values(final_score, ascendingFalse) .drop_duplicates(subset[player_id]) .sort_values(final_score, ascendingFalse) )这才是经得起追问的统计链路。5.2 按指定轮次筛选输出该轮的最高分及对应选手另一种频繁出现的统计口径是“只看某一轮这一轮谁最高”。这个场景适合把轮次参数化免得每轮都改代码def max_of_round(df, target_round): sub df[df[round_no] target_round].copy() idx sub[final_score].idxmax() row sub.loc[idx] print(f轮次{target_round}最高分{row[final_score]})这样以后每一轮结束把新的得分表追加到总表后循环处理所有轮次即可for r in df[round_no].unique(): max_of_round(df, r)5.3 用自动化校验让统计结果可追溯脚本写多了之后我给自己加了一条规则所有统计输出必须能被验证。最简单的校验方法是用两种互相独立的方式求最大值然后对比结果是否一致。一种方式是max()另一种方式是sort_values后取第一条max_method1 df[final_score].max() max_method2 df[final_score].sort_values(ascendingFalse).iloc[0] assert max_method1 max_method2以及用计数校验检查输出结果对应的选手行数在源数据里确实存在assert len(df[df[player_id] best_id]) 1这些assert本质上不是为了防Pandas出错而是防“数据被意外改动”导致统计结果和业务对不上。赛后有人质疑分数时我能直接说这个结果是从原始数据哪个字段算出来的、经过了几道清洗每一步都有代码记录。把脚本和CSV文件放在同一天建好的目录里目录名就带当天日期比如2024-9-15。这个习惯帮你省掉了“这结果到底是哪一版数据算出来的”这类重复沟通时间久了会发现特别值得坚持。回看这个项目核心逻辑其实很简单——读取数据、洗数据、求最大值、输出结果——但真正让脚本有价值的是那些提前想清楚的统计口径、边界情况的处理办法以及可追溯的检查机制。2024-9-15之后这个小脚本我又在后续几场比赛中反复跑了很多次每次只需要把新的CSV文件放进去改一下路径就能用。如果你也经常和比赛得分表打交道建议按这个思路把它升级成自己顺手的小工具省下的时间足够你再去做更多有意义的事情。