Python第三次作业实战:数据清洗、统计分析与可视化
2026/9/15 6:15:00 网站建设 项目流程

在 Python 的教学节奏里,第三次作业往往是最有意思的一道坎。前两次作业,大家基本还在跟变量、类型、printiffor打交道,属于“语法验证”阶段;到了第三次,画风突然就变了——老师不再告诉你每一步调哪个函数,而是丢给你一个要自己拆解、自己设计方案、还要能抗住脏数据的完整任务。我见过太多同学在这份作业上第一次体会到“明明语法都会,组合起来就报错”的挫败感。这份“Python第三次作业”的核心,其实不再是某个语法点,而是把变量、循环、函数、文件操作、异常处理、面向对象和可视化串成一条线,去解决一个有实际意义的小问题。这篇文章就把它从头到尾拆开讲:作业怎么设计、代码怎么写、坑在哪里,以及怎么从这份作业里偷师到一点工程经验。

1. 第三次作业,到底在练什么:从抄语法到解决问题的第一次转折

1.1 前两次作业的积累,和第三次作业的分水岭在哪

前两次作业的典型形态,是“照着教材敲一遍”就能过的。第一次作业无非是打印个字符串、做个加法器、练练类型转换;第二次作业开始写判断、循环、猜数字,本质还是在验证“我会不会用if”和“我会不会用for”。这些题目最大的问题在于:解题路径是唯一的,老师把坑都填平了,你只需要把代码填进去。

第三次作业不一样。它通常会突然给你一个“开放式需求”,比如“请读取某个文件的数据,完成统计分析,把结果展示出来”。这意味着你第一次需要自己回答几个问题:数据存成什么结构?程序分几步走?中间出错了怎么办?是先写数据处理还是先写界面?这几个问题,恰恰是真实项目里程序员每天都要面对的东西。所以我说,第三次作业是“从读代码到组织代码”的转折点,也是很多人在这个阶段第一次意识到:会写语法和会写程序,是两码事。

1.2 一份贴近真实场景的作业要求:学生成绩统计与分析系统

为了让讨论不悬空,我直接给一份典型的第三次作业题目,后面所有内容都以它为例展开。这份题目我见过很多学校在用,也经常被拿来当课程设计的入门题,非常适合用来练手。

题目要求大概是这样的:

  • 从本地文本文件scores.txt中读取学生成绩,每行格式为姓名,学号,成绩,成绩可能是数字,也可能出现“缺考”或乱写的非法值。
  • 使用类来组织代码,至少包含一个学生类和一个管理类。
  • 统计全班总人数、有效成绩人数、平均分、最高分、最低分、及格率,以及优秀、良好、中等、及格、不及格几个分数段的人数。
  • 将统计结果同时输出到控制台和一个report.txt文件中。
  • 加分项:用 matplotlib 绘制成绩分布直方图。

这份题目好在哪里?它模拟了真实项目的最小闭环:输入(读文件)→ 处理(校验和统计)→ 输出(展示和落盘)。文件可能不干净、数据可能有缺失,这些在真实工作中太常见了。很多同学第一次写它的时候会觉得“这也太难了”,但拆开看,每一步的知识点你都学过,只是没人帮你把它串起来。

2. 核心开工前必须想清楚的三件事:数据结构、异常边界、输出约定

2.1 数据怎么存:类、字典还是 dataclass

动手写代码之前,第一件要想清楚的事是“学生数据用什么结构表示”。很多新手会习惯性地把所有信息塞进字典里,比如{"name": "张三", "id": "2024001", "score": 88}。这样做不是不行,但有个问题:字典的键是字符串,敲错了不会报错,程序只会默默返回None,排查起来很费劲。

我给的方案是用dataclass定义一个学生类。它写起来比普通类简洁,又能享受类型提示的好处:

from dataclasses import dataclass @dataclass class Student: name: str student_id: str score: float | None # None 表示缺考 @property def is_pass(self) -> bool: return self.score is not None and self.score >= 60

如果你用的 Python 版本比较老(低于 3.10),float | None这种写法会报错,可以用Optional[float]代替,也就是from typing import Optional之后写成score: Optional[float]

dataclass最大的好处是:数据结构和业务逻辑绑在一起了,以后想加个“是否及格”的判断,直接在类里加个属性方法就行,不需要在外面写一堆到处判断的函数。这时候“字典派”和“类派”的差距就出来了——代码量差不多,但可读性和扩展性差了一个量级。

2.2 哪些算异常:文件不存在、空行、缺考、非法分数

第二件事,是明确“异常边界”。很多同学的代码能处理完美数据,但一遇到文件里有一行格式不对,整个程序就崩了。作业批改时,这一项往往是最容易拉开差距的地方。

一份真实的scores.txt长什么样?大概率长这样:

张三,2024001,88 李四,2024002,缺考 王五,2024003,abc 赵六,2024004,59.5

这四行数据里,后三行都有问题:李四缺考、王五的成绩不是数字、赵六的成绩是小数。那么你的程序就要明确回答这么几个问题:

  1. 文件不存在时,是崩溃还是给出友好提示?
  2. 空行要不要跳过?
  3. “缺考”算不算有效成绩?算不算总人数?
  4. 非法分数(abc、负数、大于 100 的数)按什么规则处理?

我建议在代码里明确做以下设计:空行直接跳过;缺考计入总人数,但不参与平均分等统计;非法分数抛出异常或者记录到错误列表里,然后继续处理后面的数据。我一般会选择记录错误列表而不是直接崩溃,因为真实场景里你不可能因为一条脏数据就放弃整个批次。

2.3 输出约定:屏幕、文件、图表用统一接口

第三件事是输出。统计结果至少有三个去处:控制台、report.txt、图表。很多同学的代码会写成“统计完马上print”的流水账,结果后面想同时输出到文件时,不得不把代码复制一遍。

正确的做法是分层:计算逻辑只负责算,输出逻辑只负责展示。统计结果统一放到一个结构里,比如字典或对象,然后由不同的输出方法去消费它。

stats = manager.get_statistics() console_printer.print(stats) # 控制台输出 file_printer.print(stats) # 文件输出 plotter.plot(stats) # 图表输出

这样做的好处是:以后你想加一种新的输出形式(比如导出 Excel),只需要新增一个方法,完全不用动统计逻辑。这个思想,就是工程上常说的“单一职责原则”。作业里不一定要求,但写出来之后,老师看代码的体验会完全不一样。

3. 代码落地全流程:从定义类到逐行排查的完整实现

3.1 类的设计与方法划分

明确了上面的三个决策后,代码的结构基本就出来了。我建议设计两个类:Student负责单条学生数据,ScoreManager负责整个班级的数据加载、统计和输出。ScoreManager的方法可以这样划分:

  • load_from_file(path):读取文件,逐行解析,构造Student列表。
  • get_statistics():基于学生列表计算各类统计指标。
  • output_console():打印到控制台。
  • output_file(path):写出到文件。
  • plot_histogram():绘制直方图。

之所以把“加载”“统计”“输出”拆成不同方法,是为了方便单独调试。比如你发现直方图画得不对,只需要去看plot_histogram,不用在几百行代码里找哪里在算平均分。这个习惯,是第三次作业能带给你的最值钱的东西之一。

3.2 数据加载与校验的编写顺序

数据加载这部分,是新手最容易翻车的地方。核心问题有两个:一是编码,二是字段解析。先看代码:

from pathlib import Path class ScoreManager: def __init__(self): self.students: list[Student] = [] def load_from_file(self, path: str | Path) -> None: path = Path(path) if not path.exists(): raise FileNotFoundError(f"找不到数据文件: {path}") with path.open("r", encoding="utf-8") as f: for line_number, line in enumerate(f, start=1): line = line.strip() if not line: continue student = self._parse_line(line, line_number) if student is not None: self.students.append(student)

这里有几个细节值得掰开讲。第一,用Path而不是直接拼接字符串路径,跨平台更安全。第二,encoding="utf-8"是必须显式声明的,否则在 Windows 上会默认用 GBK 读文件,遇到 UTF-8 编码的中文直接乱码或报错。第三,enumerate(f, start=1)是为了定位到具体是哪一行数据出了问题,这在排错时非常有用。

对应的_parse_line方法长这样:

def _parse_line(self, line: str, line_number: int) -> Student | None: parts = [p.strip() for p in line.split(",")] if len(parts) != 3: print(f"第 {line_number} 行格式错误,已跳过: {line}") return None name, student_id, score_text = parts if score_text == "缺考": return Student(name=name, student_id=student_id, score=None) try: score = float(score_text) except ValueError: print(f"第 {line_number} 行成绩不是数字,已跳过: {line}") return None if not (0 <= score <= 100): print(f"第 {line_number} 行成绩超出范围,已跳过: {line}") return None return Student(name=name, student_id=student_id, score=score)

这段代码把“每条数据可能怎么坏”全部枚举了一遍:字段数量不对、缺考、非数字、超出范围。每一种情况都有对应的提示输出。到这一步,数据加载环节才算真正写完了。

3.3 统计逻辑的实现细节

数据加载完之后,统计逻辑其实很简单,但有一个口径问题必须提前定好:缺考算不算分母。

我在 2.2 里已经定过口径:缺考计入总人数,但不算有效成绩,不参与平均分计算。那么统计代码可以这样写:

def get_statistics(self) -> dict: total = len(self.students) scores = [s.score for s in self.students if s.score is not None] if not scores: return { "total": total, "valid_count": 0, "avg": 0, "max": 0, "min": 0, "pass_rate": 0, "levels": {}, } avg = sum(scores) / len(scores) pass_count = sum(1 for s in self.students if s.is_pass) levels = { "优秀(>=90)": sum(1 for s in scores if s >= 90), "良好(80-89)": sum(1 for s in scores if 80 <= s < 90), "中等(70-79)": sum(1 for s in scores if 70 <= s < 80), "及格(60-69)": sum(1 for s in scores if 60 <= s < 70), "不及格(<60)": sum(1 for s in scores if s < 60), } return { "total": total, "valid_count": len(scores), "avg": round(avg, 2), "max": max(scores), "min": min(scores), "pass_rate": round(pass_count / total * 100, 2), "levels": levels, }

这里有个特别容易踩的坑:avg = sum(scores) / len(scores)里如果scores为空,直接会抛ZeroDivisionError,所以在计算之前必须先判空。很多同学的代码在“全班都缺考”这种极端情况下崩溃,就是因为少了这个判断。另外,及格率的分母到底用总人数还是有效人数,一定要在注释里写清楚,这也是实际项目里“统计口径不一致导致吵架”的经典案例。

3.4 可视化部分:直方图与元素说明

可视化是加分项,但也最容易让人卡住。一个最基础的成绩分布直方图,代码非常简单:

import matplotlib.pyplot as plt def plot_histogram(self) -> None: scores = [s.score for s in self.students if s.score is not None] if not scores: print("没有有效成绩,无法绘图") return plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False plt.hist(scores, bins=10, edgecolor="black", alpha=0.7) plt.xlabel("成绩") plt.ylabel("人数") plt.title("成绩分布直方图") plt.grid(axis="y", linestyle="--", alpha=0.5) plt.show()

bins=10表示把 0 到 100 分成 10 个区间。edgecolor="black"是为了让柱子之间有分割线,看起来更清楚。alpha=0.7是透明度,纯属审美调整。真正容易踩坑的是那两行rcParams:第一行设置中文字体,第二行解决负号显示成方块的问题。如果不设置,图里中文全变成小方框,这是 matplotlib 的老毛病,跟你的代码逻辑没关系,但会直接影响作业印象分。

4. 作业里最容易扣分的隐藏考点:编码、路径、环境

4.1 中文乱码:源头在读取,不在 print

中文乱码这事,我在批改作业时见过太多次了。很多同学的代码在自己的电脑上跑得好好的,交到老师电脑上打开就乱码,最后发现是文件编码不一致。

问题的根源是:Windows 系统默认用 GBK 编码读写文本文件,而现代编辑器创建文件时默认用 UTF-8。如果你的scores.txt是 VSCode 创建的 UTF-8 文件,但代码里写的是open("scores.txt"),那么在 Windows 上就会用 GBK 去读 UTF-8 文件,中文自然全乱。

解法就是我在 3.2 里的写法:读写文件时显式声明encoding="utf-8"。写report.txt时同样要写:

with output_path.open("w", encoding="utf-8") as f: f.write("统计报告\n")

这里还有一个容易忽略的细节:report.txt如果用 Windows 自带的记事本打开,UTF-8 文件如果开头没有 BOM(字节序标记),记事本可能还是识别成乱码。如果你确定作业要交到 Windows 环境,可以退一步用encoding="utf-8-sig"写入,这样记事本就能正确显示了。这个技巧一般文档里不会写,但实际很管用。

4.2 相对路径还是绝对路径:让作业能换台电脑跑起来

路径问题,是第三次作业里另一个高频翻车点。典型场景是:你在 VSCode 里运行代码,scores.txt就在项目文件夹里,一切正常;换到 PyCharm 里运行,突然报FileNotFoundError。为什么?因为两个编辑器设置的“工作目录”不一样,相对路径scores.txt是相对于当前工作目录解析的,而工作目录变了,文件自然就找不到了。

稳妥的做法是:不要直接写相对路径,而是基于代码文件本身的目录去定位数据文件。用pathlib很容易实现:

BASE_DIR = Path(__file__).resolve().parent DATA_FILE = BASE_DIR / "scores.txt"

__file__是当前代码文件的路径,resolve()会把它转成绝对路径,然后取它的父目录parent。这样无论你在哪个环境、从哪个目录启动,都能找到同一个scores.txt。这个写法在真实项目里也经常用,特别是写脚本和工具的时候,能省掉很多“换个目录就报错”的麻烦。

4.3 环境配置:第三方库装不上怎么办

作业一旦涉及 matplotlib,就绕不开安装第三方库这件事。很多同学的第一个拦路虎不是代码,而是pip install matplotlib装了几次都报超时或找不到包。

在国内网络环境下,默认的 PyPI 源经常很不稳定。我一般建议直接换国内镜像源,最省事的方式是在安装时指定镜像:

pip install -i https://pypi.tuna.tsinghua.edu.cn/simple matplotlib

如果只是临时使用这个源,上面的命令就够了。想长期生效,可以在用户目录下创建一个pip.ini(Windows)或.pip/pip.conf(macOS/Linux)配置文件,把镜像地址写进去。

另一个常见报错是命令提示符里输入python却提示python was not found。这通常不是没装 Python,而是 Windows 的 PATH 环境变量没配好。一种快速排查方式是输入py试试,Windows 的 Python 启动器一般能直接打开;如果py也不行,那就老老实实重新安装 Python,在安装向导第一步务必勾选“Add Python to PATH”。VSCode 里如果选不到解释器,可以按Ctrl+Shift+P,输入Python: Select Interpreter,手动指定 Python 安装路径。这些环境问题看着跟作业内容没关系,但它们消耗的时间一点不比写代码少,提前处理好能省下大量精力。

5. 画图横坐标太密集:matplotlib刻度问题的高频解法

5.1 为什么会出现“横坐标挤成一团”

成绩直方图里,横坐标是数值区间,一般不怎么会挤。但如果你把图形换成班级对比柱状图,比如横坐标是几十个班级的班号,问题就来了:matplotlib 默认会尽量把每个数据点都标一个刻度,当类别很多、画布又不够宽时,所有标签就会重叠成一团黑色。这就是搜索词里“python画图横坐标太密集”这个高频问题的来源。

还有一个常见场景是横坐标是日期。如果你处理的数据跨越了好几个月,默认刻度可能精细到每天,或者反过来只有几个点,都需要手动调整。

5.2 四种解法横向对比

解决“横坐标太密集”,没有万能的银弹,得看你的数据到底是什么类型。我把最常用的几种方案列成一张表,方便对照选择。

方案适用场景核心代码副作用
旋转标签类别少但文字长plt.xticks(rotation=45)只缓解,不根治
隔几个刻度显示类别多且均匀plt.xticks(range(0, len(x), step))需要手动算步长
用 MaxNLocator 限制数量数值型或日期型横轴ax.xaxis.set_major_locator(MaxNLocator(nbins=10))自动挑“好看”的刻度
增大画布所有类型plt.figure(figsize=(12, 6))治标,图太大可能变形

前两种方法对于作业里的字符串类别数据最常用。举个例子,如果横坐标是 30 个班号,你可以只显示其中的 5 个,避免全部挤在一起:

import matplotlib.pyplot as plt from matplotlib.ticker import MaxNLocator fig, ax = plt.subplots(figsize=(10, 5)) ax.bar(class_names, avg_scores) ax.xaxis.set_major_locator(MaxNLocator(nbins=6)) ax.tick_params(axis="x", rotation=30) plt.tight_layout() plt.show()

这一段代码同时做了三件事:把画布拉长到 10 英寸宽、让 x 轴最多出 6 个刻度、把标签旋转 30 度。配合tight_layout()自动调整布局,基本能解决 90% 的“挤成一团”问题。

5.3 踩坑备注:旋转后标签长、截断和负号方块

这里再提醒几个我实测中经常接着冒出来的后续问题。

第一,旋转标签后,如果标签文字太长,旋转 45 度或 90 度时左右两侧的标签会被截断。这时候单靠tight_layout()有时不够,可以再手动加一行plt.subplots_adjust(bottom=0.2),把整个绘图区域往上抬,给底部标签留出空间。

第二,如果中文标签变成方块,那就是 3.4 里提到的rcParams没设置。这个一定要放在绘图前面:

plt.rcParams["font.sans-serif"] = ["SimHei"] # Windows 用黑体 plt.rcParams["axes.unicode_minus"] = False

如果你在 macOS 上,SimHei不一定存在,可以换成["Arial Unicode MS"]或者系统里已有的中文字体。判断字体有没有生效,直接看图里中文是否还显示成方块就行。

第三,当横坐标是日期时,plt.hist不一定合适,更专业的方式是用pandas做分组,然后画柱状图,再用DateFormatter控制日期格式。作业里大概率用不到,但如果你在做个人小项目时遇到,可以记住关键词是mdates.DateFormatter,别自己硬拼字符串。

6. 从作业到真实项目的进阶:pandas、协程与多进程到底什么时候用

6.1 数据量大了:用pandas替换手写统计

作业里的数据量,撑死几百行,手写统计完全没问题。但如果你以后真的要处理几千个学生、几十个班的成绩,再用for循环一个个算,效率就有点不够看了。这时候就该 pandas 上场了。

pandas 处理这种结构化数据是降维打击。比如上面的统计需求,用 pandas 只需要几行:

import pandas as pd df = pd.read_csv("scores.txt", names=["name", "student_id", "score"]) df["score"] = pd.to_numeric(df["score"], errors="coerce") # 非法值自动变成 NaN print(df["score"].describe()) print(df["score"].isin(range(60, 101)).mean()) # 及格率,口径请自行调整

errors="coerce"这个参数尤其好用:它会把所有不能转成数字的值统一变成NaN,相当于把“校验非法成绩”这件事内置了。配合describe(),平均值、最值、分位数一次性全出来。我并不是说作业里一定要用 pandas——恰恰相反,作业的价值在于让你亲手实现一遍这些逻辑,理解底层发生了什么。但作业做完之后,去了解 pandas 怎么把这套流程写得更优雅,是很有价值的下一步。

6.2 协程和多进程,作业里别硬上

搜索词里“python协程”“python多进程”的热度一直很高,但请听我一句:在这份第三次作业里,协程和多进程都属于过度设计。数据量就几百行,读写文件和画图都是毫秒级,你用协程不会带来任何体感上的提升,反而会让代码复杂度爆炸。

那它们什么时候才真正有用?记住一个最粗的规则:IO 密集任务(比如爬虫、批量请求网络接口、读写大量小文件)用协程或异步 IO;CPU 密集任务(比如科学计算、图像处理)用多进程。给你一个最朴素的判断方法:如果程序大部分时间在“等”,那是 IO 密集,考虑asyncio;如果大部分时间在“算”,那是 CPU 密集,考虑ProcessPoolExecutor

以爬虫为例,几十个 URL 挨个请求可能要等好几秒,而用协程并发请求可以缩短到一秒内。这个场景才是协程真正的用武之地。在成绩统计这种“读取-计算-写入”的简单流程里,老老实实按顺序写就是最优解。

6.3 类型提示、PEP8与自测习惯:比抄代码更值钱的收获

最后再说一个作业本身不要求、但长远看最值钱的东西:代码规范和自测习惯。

我在批改这类作业时,经常看到两种风格截然不同的代码。一种是把所有逻辑从头写到尾堆在main里,中间穿插着几十个print;另一种是像我上面写的那样,拆成类和方法,每个方法做一件具体的事。前者功能也能跑通,但一旦中间要改个输出格式,整个函数都得大动干戈。后者改起来就轻松很多,因为每个模块是独立的。这也是为什么我反复强调“单一职责”——它不是面试八股,而是真的能降低你的心智负担。

自测习惯上,推荐一个最轻量的做法:把关键逻辑放进独立函数,然后写几个测试数据主动调用。比如我写过_parse_line,我会手动构造四五个不同情况的字符串,看看它是否按预期返回StudentNone。这种成本极低的自测,能帮你至少提前发现一半以上的逻辑 bug。等你以后接触pytest,会发现这其实就是测试的基本思路,只不过现在用最土的方式执行而已。


我自己在带这份作业时,最常说的一句话是:第三次作业的目标不是把功能跑通,而是让另一个没看过需求文档的人,拿到你的代码之后不问一句话也能看懂流程。能自觉做到这一点,比把附加题全做完都管用。如果你现在还在为这份作业发愁,别急着搜源码,先按这篇的思路把数据结构、异常边界、输出方式定下来,代码自然就顺了。

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

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

立即咨询