文件操作与异常处理——让你的数据“流“进来、程序“活“下去
引子:为什么 AI 新手要啃文件操作与异常?
大家好,你们的小洛。
上一篇文章讲了闭包和生成器——那是让程序内部"有记忆"、数据内部"按需流"。但现实中,你的训练数据不可能直接写在代码里。真正跑线性回归时,我遇到了两个更基础、也更致命的问题:
- 我的训练数据在 CSV 文件里——100 万行数据躺在硬盘上,怎么把它读进
numpy数组?读错一行格式全崩怎么办? - 我的程序一运行就报错——文件路径写错了、权限不够、数据里混了个
NaN、训练到一半模型保存失败……程序直接崩溃,损失了半小时训练进度。
这两个问题,分别对应 Python 的两个最基础但最容易被轻视的特性:文件操作(File I/O)和异常处理(Exception Handling)。
📌 这篇文章的目标 我不会堆砌 API 列表,而是带你继续用那套5 阶段思维框架彻底搞懂这两个概念: 1. 定位 —— 它是什么?解决什么问题?边界在哪? 2. 拆结构 —— 核心术语、底层假设、内部协作关系 3. 看流程 —— Hello World 跑一遍,看清每一步 4. 找关联 —— 和已会的东西类比,触类旁通 5. 验证 —— 费曼测试 + 反例,真懂了才往下走
每学完一个概念,我们都会落到加载训练数据、保存模型这个真实场景上——这是每个 AI 项目的第一步。
第一部分:文件操作——让数据"流"进你的程序
阶段一:定位——它是什么?
父类挂接
文件操作属于编程语言的输入输出(I/O)基础设施层。
Python 语言层级
├── 语法层(变量、循环、函数...)
├── 标准库层
│ ├── os 模块
│ ├── pathlib
│ └── csv / json / pickle
└── 操作系统接口层
├── 文件操作 File I/O ✅
├── 网络 I/O
└── 数据库连接
核心锚点
文件操作 = 程序和硬盘之间的"读写通道"
内存是程序的工作台——速度快,但断电就没。硬盘是程序的仓库——容量大,但速度慢。文件操作就是在工作台和仓库之间搭一条通道,让数据来回搬运。
它要解决什么根本问题?
你写了一个线性回归训练器,但训练数据不可能硬编码在代码里:
| 方案 | 代码 | 问题 |
|---|---|---|
| 硬编码数据 | X = [[1,2], [3,4], ...] |
100 万行代码,谁维护? |
| 全靠 pandas | pd.read_csv('data.csv') |
✅ 好用,但你知道 pandas 底层在干嘛吗? |
| 用 Python 原生文件操作 | with open('data.csv') as f: ... |
✅ 理解底层,能处理任何格式 |
本质动机:让程序能持久化地读写数据——断电了数据还在,下次还能用。
它的边界在哪?什么不属于文件操作?
文件操作 = 打开文件 → 读写内容 → 关闭文件
| 操作 | 属于文件操作? | 说明 |
|---|---|---|
open('a.txt') |
✅ | 打开文件 |
f.read() |
✅ | 读取文件内容 |
f.write('hello') |
✅ | 写入内容 |
print('hello') |
❌ | 输出到终端,不是文件 |
requests.get(url) |
❌ | 网络请求,不是本地文件 |
db.execute(sql) |
❌ | 数据库操作,有自己的协议 |
阶段二:拆结构——它怎么构成?
核心术语大白话翻译
| 术语 | 大白话 | 类比 |
|---|---|---|
| 文件对象 / 文件句柄 | Python 和硬盘文件之间的"电话线" | 你拨通文件的电话,之后通过这根线说话 |
open() |
拨通电话 | f = open('data.csv') |
read() |
"给我全部内容" | 让对方一次念完整本数据 |
readline() |
"给我下一行" | 让对方每次只念一行 |
write() |
"我要写这些内容" | 你往电话那头念 |
| 模式(mode) | 打电话的方式:只读 / 只写 / 读写 | 'r' 只听不能说,'w' 只说不听,'a' 追加说 |
上下文管理器 with |
"打完自动挂电话" | 你用了 with,离开代码块时文件自动关闭 |
| 缓冲(buffer) | "攒够一车再运" | 内存里先攒一批数据,攒够了再写硬盘,更快 |
底层假设
文件操作能正常工作,依赖操作系统的三个设计:
假设 1:文件是字节序列
不管是 CSV、图片、视频还是 Word 文档,在硬盘上全是一堆 0 和 1。Python 负责帮你按"文本"或"二进制"来解读这些字节。
CSV 文件 "age,height\n18,175\n20,180"
↓ 存在硬盘上
字节序列:61 67 65 2C 68 65 69 67 68 74 0A 31 38 2C 31 37 35 ...
↓ Python 用 'utf-8' 解码
你看到的文本:age,height
18,175
20,180
假设 2:操作系统会帮你管理权限
你能不能读/写/执行一个文件,操作系统说了算。Python 只是照做——权限不够就抛异常。
假设 3:文件要用完就关
打开文件相当于"锁定"了它——Windows 上其他程序改不了,Linux 上可能数据不一致。用完必须
close()。但手动关容易忘,所以用with自动关。
结构协作关系
┌─────────────────────────────────────────────────────────┐
│ 文件操作三步曲 │
│ │
│ ① open(path, mode) │
│ 拨通文件的电话,拿到"文件对象" f │
│ ┌───────────────────────────────────────────┐ │
│ │ f = open('data/train.csv', 'r', encoding='utf-8') │
│ └───────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ② read / write │
│ 通过文件对象 f 读写数据 │
│ ┌───────────────────────────────────────────┐ │
│ │ content = f.read() # 读全部 │ │
│ │ line = f.readline() # 读一行 │ │
│ │ f.write('hello\n') # 写入 │ │
│ │ f.writelines(list) # 写入多行 │ │
│ └───────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ③ close() ← 最好用 with 自动做 │
│ 挂断电话,释放文件锁 │
│ ┌───────────────────────────────────────────┐ │
│ │ # 手动方式(容易忘) │ │
│ │ f.close() │ │
│ │ │ │
│ │ # 推荐方式(with 自动关) │ │
│ │ with open(...) as f: │ │
│ │ content = f.read() # 缩进块结束自动关 │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
阶段三:看流程——它怎么运作?
Hello File:读写你的第一个 CSV
# ========= 写入:生成一份模拟训练数据 =========
with open('linear_data.csv', 'w', encoding='utf-8') as f:
f.write('x,y\n') # 写表头
for i in range(100):
x = i * 0.1
y = 2 * x + 1 + (i % 7) * 0.05 # y = 2x + 1 + 噪声
f.write(f'{x:.1f},{y:.3f}\n')
# ========= 读取:逐行读进 numpy =========
import numpy as np
data = []
with open('linear_data.csv', 'r', encoding='utf-8') as f:
header = f.readline() # 跳过表头:'x,y\n'
for line in f: # 逐行遍历文件对象(迭代器!)
x_str, y_str = line.strip().split(',')
data.append([float(x_str), float(y_str)])
data = np.array(data)
X = data[:, 0].reshape(-1, 1) # 特征
y = data[:, 1].reshape(-1, 1) # 标签
print(f"已加载 {len(X)} 条训练数据")
print(f"前 3 条:\n{data[:3]}")
运行全过程拆解
with open('linear_data.csv', 'w') as f:
│
▼
open() 内部做了什么?
├── 检查文件是否存在 → 不存在就创建
├── 检查你有没有写权限
├── 以 'w' 模式打开 → 清空旧内容(注意!)
├── 创建文件对象 f,指向操作系统的文件描述符
└── 返回 f
│
▼
f.write('x,y\n')
├── 字符串编码成 utf-8 字节
├── 先写进内存缓冲区(buffer)
├── 缓冲区满了才真正写到硬盘
└── 返回写入的字节数
│
▼
with 代码块结束 → 自动触发 f.close()
├── 把缓冲区里剩下的字节全部刷到硬盘(flush)
├── 关闭文件描述符
└── 释放文件锁
AI 实战:用 csv 模块稳健读取训练数据
手写 split(',') 解析 CSV 容易翻车(字段里有逗号怎么办?有引号怎么办?)。Python 标准库 csv 模块专门干这个:
import csv
import numpy as np
def load_csv_dataset(filepath, has_header=True):
"""
稳健读取 CSV 训练数据
用 csv 模块处理引号、转义等边界情况
"""
X_list, y_list = [], []
with open(filepath, 'r', encoding='utf-8') as f:
reader = csv.reader(f)
if has_header:
next(reader) # 跳过表头
for row_num, row in enumerate(reader, start=2): # 从第2行开始计
try:
# 假设最后一列是标签,前面的是特征
features = [float(val) for val in row[:-1]]
label = float(row[-1])
X_list.append(features)
y_list.append(label)
except ValueError as e:
print(f"[警告] 第 {row_num} 行数据格式错误,已跳过: {e}")
continue
X = np.array(X_list)
y = np.array(y_list)
print(f"成功加载 {len(X)} 条样本,{X.shape[1]} 个特征")
return X, y
# ========= 使用 =========
X, y = load_csv_dataset('linear_data.csv')
# 输出:
# 成功加载 100 条样本,1 个特征
写入进阶:保存训练好的模型参数
训练完线性回归,你总得把学到的权重 w 和偏置 b 存下来,下次直接加载用——这就是"模型持久化":
import json
import numpy as np
# ========= 保存模型 =========
def save_linear_model(filepath, w, b, metadata=None):
"""
把线性回归模型保存成 JSON 文件
权重 w 可以是一维或二维 numpy 数组
"""
model = {
'weights': np.array(w).ravel().tolist(),
'bias': float(b),
'metadata': metadata or {}
}
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(model, f, indent=2, ensure_ascii=False)
print(f"模型已保存到 {filepath}")
# ========= 加载模型 =========
def load_linear_model(filepath):
"""从 JSON 文件加载模型"""
with open(filepath, 'r', encoding='utf-8') as f:
model = json.load(f)
w = np.array(model['weights'])
b = model['bias']
print(f"已加载模型: w={w}, b={b}, metadata={model['metadata']}")
return w, b
# ========= 用起来 =========
# 假设你训练完了
w_trained = np.array([[2.03]])
b_trained = 0.98
save_linear_model('model.json', w_trained, b_trained,
metadata={'epochs': 1000, 'final_loss': 0.0031})
# 模型已保存到 model.json
# 下次使用
w, b = load_linear_model('model.json')
# 已加载模型: w=[2.03], b=0.98, metadata={'epochs': 1000, 'final_loss': 0.0031}
容易踩的坑
⚠️ 坑 1:
'w'模式会清空原有文件!
python with open('important_data.csv', 'w') as f: # ⚠️ 打开的瞬间,文件就被清空了! f.write('新数据')想追加内容用
'a'模式,想读写用'r+'。⚠️ 坑 2:编码问题——"UnicodeDecodeError: 'gbk' codec can't decode..."
Windows 上默认编码是
gbk(中文),但绝大多数数据集是utf-8:```python
❌ 没指定 encoding,Windows 默认用 gbk
with open('chinese_data.csv', 'r') as f: f.read() # UnicodeDecodeError!
✅ 永远显式指定 encoding='utf-8'
with open('chinese_data.csv', 'r', encoding='utf-8') as f: f.read() ```
⚠️ 坑 3:
read()vsreadline()vsfor line in f
方法 内存 适用场景 f.read()一次性读全部进内存 小文件(< 10MB) f.readline()只存一行 手动控制读取逻辑 for line in f只存一行 绝大多数情况!文件对象本身是迭代器 ```python
❌ 100 万行数据,一次性全读进来
content = open('big_data.csv').read() # 可能爆内存
✅ 迭代器方式,内存恒定
with open('big_data.csv') as f: for line in f: # 每次只加载一行 process(line) ```
⚠️ 坑 4:忘记用
with,手动close()又忘了调用```python
❌ 忘记关文件 → 数据可能丢失!
f = open('output.txt', 'w') f.write('hello')
程序崩溃了...f.close() 永远不会被执行 → 数据没写入硬盘
✅ with 保证不管正常结束还是异常都关闭
with open('output.txt', 'w') as f: f.write('hello')
缩进块结束,自动 flush + close
```
阶段四:找关联——触类旁通
类比:文件操作 ≈ 快递收发站
| 文件操作 | 快递站类比 |
|---|---|
open(path, 'r') |
到站门口说:"我要取这个包裹" |
open(path, 'w') |
到站门口说:"我要寄一个新包裹"(旧包裹会被销毁) |
read() |
把包裹里所有东西一次性倒出来 |
readline() |
每次从包裹里拿一件东西 |
write() |
把东西塞进包裹 |
close() |
关门走人(必须的,不然包裹可能没封好) |
with 语句 |
快递站提供的"自助暂存柜"——你用完自动帮你锁门 |
| 缓冲区 | 快递员攒够一车货再发车,而不是一件发一趟 |
文件操作 vs pandas.read_csv
pandas 的 read_csv 本质就是对 Python 原生文件操作的高级封装:
# pandas 一行搞定(内部做了很多事)
import pandas as pd
df = pd.read_csv('data.csv')
# 它内部大概在做什么:
import csv, io, numpy as np
with open('data.csv', 'r', encoding='utf-8') as f:
# csv.reader 解析每行
# 推断每列的数据类型
# 处理缺失值
# 把数据转成 numpy 数组
# 包装成 DataFrame
💡 经验法则 - 小数据、简单格式 → 用 Python 原生文件操作(轻量、不依赖第三方库) - 大数据、复杂格式 → 用 pandas / numpy(自动处理类型推断、缺失值、性能优化) - 想知道 pandas 为什么这么快 → 先搞懂原生文件操作
底层原理:open() 到底返回了什么?
f = open('data.csv', 'r')
print(type(f)) # <class '_io.TextIOWrapper'>
print(f.name) # 'data.csv' —— 文件名
print(f.mode) # 'r' —— 打开模式
print(f.encoding) # 'cp936'(Windows)或 'UTF-8'
# 底层还有一层:BufferedReader
print(type(f.buffer)) # <class '_io.BufferedReader'>
# 再底层:操作系统级别的 FileIO
print(type(f.buffer.raw)) # <class '_io.FileIO'>
你的代码:
for line in f: # f 是 TextIOWrapper
...
│
▼ 调用 __next__()
TextIOWrapper(文本层:编码/解码)
│
▼ 调 read()
BufferedReader(缓冲层:攒够一批再读)
│
▼ 调 read()
FileIO(原始层:操作系统 syscall)
│
▼
操作系统内核
│
▼
硬盘上的 0 和 1
阶段五:验证——真的懂了吗?
费曼测试:一句话讲给小白听
文件操作就是用
open()打开一个通道,用read()或write()搬运数据,用with保证用完自动关好。
预测新场景
如果文件操作能读写 CSV 和 JSON,那能不能用 pickle 模块直接序列化整个 numpy 数组?这比存成 JSON 再加载快得多。
import pickle
import numpy as np
# 保存(dump)
with open('training_data.pkl', 'wb') as f: # 注意:'wb' 二进制写入
pickle.dump({'X': X, 'y': y}, f)
# 加载(load)
with open('training_data.pkl', 'rb') as f: # 注意:'rb' 二进制读取
data = pickle.load(f)
X = data['X']
y = data['y']
你应该能预测出: - pickle 只能在 Python 之间用(不像 JSON 通用) - 用了 'wb' / 'rb' 模式(二进制) - pickle 能直接序列化任何 Python 对象(包括函数,但要小心版本兼容)
局限与反例
| 局限 | 说明 | 应对 |
|---|---|---|
'w' 会清空文件 |
打开即清空,数据救不回来 | 用 'a' 追加,或先 os.path.exists() 检查 |
| 编码易出错 | Windows 默认 gbk,数据多是 utf-8 | 永远显式写 encoding='utf-8' |
| 大文件内存占用 | 用 read() 一次性读完可能爆内存 |
用 for line in f 迭代器逐行处理 |
| pickle 不安全 | 加载恶意 .pkl 文件会执行任意代码 |
永远不要加载来源不明的 pickle 文件 |
| 跨平台路径 | Windows 用 \,Linux 用 / |
用 pathlib.Path('data/train.csv') 自动处理 |
第二部分:异常处理——让程序"活"下来
阶段一:定位——它是什么?
父类挂接
异常处理属于编程语言的错误控制机制。
程序运行时
├── 正常执行 happy path
└── 出错了!
└── 异常 Exception ✅
├── 内置异常(ValueError, FileNotFoundError...)
└── 自定义异常
└── 用 try-except 捕获 ✅
核心锚点
异常 = 程序"生病"了,但还能抢救
如果文件不存在、数据格式不对、除以零……程序不会像 C 语言那样直接 crash 掉,而是抛出一个信号——异常。你可以选择"接住"它(try-except),给程序"治治病",然后继续跑。
它要解决什么根本问题?
跑线性回归训练时,哪些地方会炸?
读文件 → 文件不存在
→ 编码不对
→ 权限不够
→ 磁盘满了
数据处理 → 某行是 NaN
→ 某列类型不对(字符串当数字用)
→ 特征数不匹配
训练过程 → 学习率太大导致 NaN
→ 权重爆炸
→ save 模型时磁盘满了
| 方案 | 代码 | 问题 |
|---|---|---|
| 不处理 | 让程序崩 | 训练了一小时白跑了 |
到处写 if |
if os.path.exists() + if type(x) is int |
代码臃肿,漏检查还是崩 |
| try-except | try: 正常代码; except: 兜底处理 |
✅ 只在出错时处理,正常代码保持干净 |
本质动机:把"正常逻辑"和"出错处理"分开,让主流程更清晰。
它的边界在哪?什么不是异常?
异常 = 程序运行时发生的"不正常事件",可以被 try-except 捕获
| 情况 | 是异常? | 说明 |
|---|---|---|
1/0 |
✅ ZeroDivisionError | 运行时除零 |
open('不存在.txt') |
✅ FileNotFoundError | 运行时文件不存在 |
代码语法写错了 if a = 1 |
❌ SyntaxError | 还没运行就报错了,try-except 抓不住 |
print 拼成 pritn |
❌ NameError | 也是运行时,但 try 能抓住 |
| 用户关电脑 | ❌ KeyboardInterrupt | 特殊的异常,try 能捕获但建议只做清理 |
阶段二:拆结构——它怎么构成?
核心术语大白话翻译
| 术语 | 大白话 | 类比 |
|---|---|---|
| 异常 Exception | 程序抛出的"我生病了"消息 | 病人喊"疼!" |
| 抛出 raise | 主动制造异常 | 你去医院说"我疼" |
| 捕获 except | 接住异常,给程序"治病" | 医生接住病人,开始治疗 |
| try 块 | "这里可能出错了,我准备好接" | 医生说"这个治疗室随时准备救病人" |
| finally 块 | "不管出不出事,这步一定要做" | 不管手术成功失败,都要清理手术室 |
| else 块 | "没出错时才执行这个" | 手术成功了才给病人开补品 |
| traceback | 异常的"犯罪现场报告" | 病人的病历:哪一行代码、什么问题、调用链 |
底层假设
假设 1:异常是对象
在 Python 里,异常本身就是一个类(
Exception的子类)。你可以raise ValueError('输入错了'),也可以raise MyCustomException('xxx')。
假设 2:异常会"冒泡"
如果函数 A 调函数 B,B 抛异常但没捕获,异常会冒泡到 A。A 也没捕获就继续往上冒——直到主程序还没捕获,程序崩溃并打印 traceback。
def low_level():
raise ValueError("我炸了")
def mid_level():
low_level() # low_level 抛的异常会冒泡到这里
def high_level():
try:
mid_level() # 最终在这里被捕获
except ValueError as e:
print(f"捕获到了: {e}")
假设 3:BaseException 是根类,不是 Exception
Python 有个容易踩的坑:直接
except:会捕获一切,包括KeyboardInterrupt(Ctrl+C)和SystemExit(程序退出)。正确做法是except Exception:——它会跳过这两个系统级异常。
结构协作关系
┌──────────────────────────────────────────────────────┐
│ try-except 完整结构 │
│ │
│ try: │
│ # 正常逻辑(可能出错) │
│ do_something() │
│ │
│ except ValueError: │
│ # 特定异常的处理 │
│ print("值不对") │
│ │
│ except (IOError, FileNotFoundError) as e: │
│ # 多种异常,或者捕获具体信息 │
│ print(f"文件/IO 错误: {e}") │
│ │
│ else: │
│ # ✅ 没有任何异常时才执行 │
│ print("完美!一切正常") │
│ │
│ finally: │
│ # 🧹 无论如何都会执行 │
│ close_resources() │
│ │
└──────────────────────────────────────────────────────┘
执行路径:
try 块正常执行 → 没异常 → 走 else → 走 finally
try 块抛异常 → 匹配 except → 走 except → 走 finally
try 块抛异常 → 没匹配的 except → 异常继续冒泡(finally 仍执行)
阶段三:看流程——它怎么运作?
Hello Exception:稳健的除法函数
def safe_divide(a, b):
"""能应对各种烂输入的除法"""
try:
result = a / b
except ZeroDivisionError:
print("[错误] 除数不能为 0,返回 None")
return None
except TypeError as e:
print(f"[错误] 类型不对: {e}")
return None
else:
print(f"[成功] {a} / {b} = {result}")
return result
finally:
print("[清理] 除法函数执行完毕")
safe_divide(10, 3)
# [成功] 10 / 3 = 3.3333333333333335
# [清理] 除法函数执行完毕
safe_divide(10, 0)
# [错误] 除数不能为 0,返回 None
# [清理] 除法函数执行完毕
safe_divide(10, "hello")
# [错误] 类型不对: unsupported operand type(s) for /: 'int' and 'str'
# [清理] 除法函数执行完毕
运行全过程拆解
调用 safe_divide(10, 0)
│
▼
进入 try 块,执行 10 / 0
│
▼
Python 检测到除零 → 构造 ZeroDivisionError 对象 → 抛出
│
▼
异常冒泡 → 回到 try-except 结构 → 找匹配的 except
│
▼
except ZeroDivisionError: ← 匹配上了!
│
▼
执行 print + return None
│
▼
⚠️ return 会先执行 finally!
│
▼
执行 finally 块 → print("[清理]")
│
▼
finally 执行完 → 真正 return None
AI 实战:给数据加载器穿上"防弹衣"
把上一节的 load_csv_dataset 加上完整的异常处理——让它能应对各种脏数据:
import csv
import numpy as np
from pathlib import Path
def robust_load_dataset(filepath):
"""
健壮的数据加载器——能处理:
- 文件不存在
- 编码错误
- 数据行格式错误
- 列数不一致
- 数值解析失败
"""
path = Path(filepath)
# ---------- 第一步:检查文件 ----------
if not path.exists():
raise FileNotFoundError(f"找不到数据文件: {filepath}")
if path.suffix != '.csv':
raise ValueError(f"只支持 .csv 文件,当前文件: {path.suffix}")
X_list, y_list = [], []
skipped_rows = 0
# ---------- 第二步:逐行读取 ----------
try:
with open(path, 'r', encoding='utf-8') as f:
reader = csv.reader(f)
header = next(reader, None) # 跳过表头(如果有)
for row_num, row in enumerate(reader, start=2):
try:
# 检查列数
if len(row) < 2:
raise ValueError(f"列数不够(至少2列,实际{len(row)}列)")
# 解析数值
features = [float(val) for val in row[:-1]]
label = float(row[-1])
# 检查 NaN / Inf
if np.any(np.isnan(features)) or np.isnan(label):
raise ValueError("包含 NaN 值")
if np.any(np.isinf(features)) or np.isinf(label):
raise ValueError("包含 Inf 值")
X_list.append(features)
y_list.append(label)
except ValueError as row_error:
skipped_rows += 1
print(f" ⚠️ 第 {row_num} 行已跳过: {row_error} | 内容: {row[:3]}...")
except UnicodeDecodeError:
# 文件编码不是 utf-8
try:
# 尝试 gbk(中文 Windows 默认)
with open(path, 'r', encoding='gbk') as f:
# 递归调用自己(简化示例,实际应该抽成内部函数)
return robust_load_dataset_with_encoding(path, 'gbk')
except Exception:
raise UnicodeDecodeError(
'utf-8/gbk', b'', 0, 1,
f"文件 {filepath} 不是有效的 utf-8 或 gbk 编码"
)
# ---------- 第三步:汇总报告 ----------
if len(X_list) == 0:
raise RuntimeError("没有成功加载任何有效数据!请检查文件格式")
X = np.array(X_list)
y = np.array(y_list)
print(f"\n✅ 加载完成: {len(X)} 条有效样本,{X.shape[1]} 个特征,跳过 {skipped_rows} 行")
return X, y
容易踩的坑
⚠️ 坑 1:裸
except:捕获了一切```python
❌ 不要这么写
try: train_model() except: # 会捕获 Ctrl+C、程序退出……一切! print("出错了")
✅ 只捕获你能处理的异常类型
try: train_model() except Exception as e: print(f"训练出错: {e}") ```
记住:
except:是上帝视角,只有except Exception:才是凡人视角。⚠️ 坑 2:异常吞没——捕获了但什么也不做
```python
❌ 问题被藏起来了,调试时找不着北
try: X = np.load('data.npy') except: pass # 静默失败,X 根本没加载成功
✅ 至少打个日志
try: X = np.load('data.npy') except Exception as e: print(f"[警告] 加载数据失败,使用空数据代替: {e}") X = np.zeros((0, 1)) ```
⚠️ 坑 3:try 块太大——"大网捕小鱼"
```python
❌ try 包了几百行,出问题不知道是哪行
try: load_data() preprocess() train() evaluate() save_model() except Exception as e: print(f"哦不,出错了: {e}") # 哪一步?什么错?Traceback 被吃掉了
✅ 缩小范围,每个步骤单独处理
try: data = load_data('data.csv') except FileNotFoundError: print("数据文件丢了") exit(1)
try: model = train(data) except RuntimeError as e: print(f"训练失败: {e}") exit(1) ```
⚠️ 坑 4:用异常做正常流程的控制
异常是为了不正常的事准备的。别用它来做"正常的事":
```python
❌ 不要用异常做正常的分支判断
try: user_input = int(input("请输入数字: ")) except ValueError: print("你输入的不是数字")
✅ 上面这个例子其实是合理的!因为用户输入错是"正常中的异常"
❌ 但下面这个就不合理了:
try: next(iterator) # 只想看看有没有下一个 except StopIteration: # ...用 len() 或别的方式判断 pass ```
阶段四:找关联——触类旁通
类比:异常处理 ≈ 医院分诊系统
| 异常处理概念 | 医院分诊类比 |
|---|---|
| 程序运行正常 | 病人没生病,一切顺利 |
raise 异常 |
病人突然晕倒(主动呼救) |
try 块 |
急诊室"待命区" |
except 块 |
医生"接住"病人并治疗 |
finally 块 |
不管救不救得活,都要做消毒清理 |
| 异常冒泡 | 急诊医生处理不了 → 送到专科 → 专科处理不了 → 送上级医院 |
| traceback | 完整病历:谁在什么时候、在哪个环节、得了什么病 |
| 没捕获的异常 | 一路冒泡没人管 → 病人"死亡"(程序崩溃) |
异常 vs if 判断
这两种方式都能处理"出错",到底用哪个?
# 方式 1:先检查(LBYL —— Look Before You Leap)
if os.path.exists('data.csv'):
f = open('data.csv')
data = f.read()
f.close()
else:
print("文件不存在")
# 方式 2:直接做,错了再说(EAFP —— Easier to Ask for Forgiveness than Permission)
try:
with open('data.csv') as f:
data = f.read()
except FileNotFoundError:
print("文件不存在")
💡 Python 风格是 EAFP——"先做再说"
原因很简单: 1. 性能:正常情况没异常,比每次都
if检查更快 2. 简洁:不需要提前知道所有可能的错误 3. Python 哲学:"It's better to ask for forgiveness than permission"但要注意:不要用异常处理正常的控制流(比如循环中的
StopIteration是正常的,不该 raise 你自己的)。
底层原理:Python 异常处理机制
异常的本质就是栈展开(Stack Unwinding):
正常调用栈:
┌─────────────────────┐
│ main() │ ← 当前在这里
│ └── train() │
│ └── load() │ ← load() 抛了 ValueError
└─────────────────────┘
抛异常后:
load() 没处理 → 栈展开 → 回到 train()
train() 没处理 → 栈展开 → 回到 main()
main() 有 try-except → 捕获!
# 可以用 traceback 模块获取完整堆栈信息
import traceback
try:
risky_operation()
except Exception:
traceback.print_exc() # 打印完整的错误链
阶段五:验证——真的懂了吗?
费曼测试:一句话讲给小白听
try-except 就是说"这里可能出错了,如果真的出错了,接住它并做相应处理,而不是让程序直接崩掉"。
预测新场景
如果异常可以被捕获,那能不能自定义异常类型来标记你自己程序里的错误?
class DatasetError(Exception):
"""数据集相关的错误(你的自定义异常)"""
pass
class InsufficientDataError(DatasetError):
"""样本数量不够"""
pass
class InconsistentFeatureError(DatasetError):
"""特征数不一致"""
pass
def load_training_data(path):
if not os.path.exists(path):
raise DatasetError(f"数据文件不存在: {path}")
data = parse_csv(path)
if len(data) < 100:
raise InsufficientDataError(f"只有 {len(data)} 条样本,至少要 100 条")
n_features = len(data[0])
for i, row in enumerate(data):
if len(row) != n_features:
raise InconsistentFeatureError(f"第 {i} 行有 {len(row)} 列,期望 {n_features} 列")
return data
你应该能预测出: - 自定义异常就是继承 Exception 的类 - 可以形成异常的继承树(DatasetError → InsufficientDataError) - 使用时 except DatasetError: 能捕获所有子类
局限与反例
| 局限 | 说明 | 应对 |
|---|---|---|
裸 except 危险 |
捕获一切包括 Ctrl+C | 用 except Exception: |
| try 块太大 | 出问题不知道在哪 | 缩小 try 范围 |
| 异常吞没 | 静默失败 | 至少打日志,最好让异常向上冒泡 |
| 性能损耗 | 抛出异常比正常分支慢 | 别用异常做正常流程控制 |
| 滥用自定义异常 | 为不存在的问题造异常 | 内置异常够用时就用内置的 |
强强联合:文件操作 × 异常处理的 AI 实战例子
学了两个概念,现在把它们拼起来——写一个带完整容错的数据加载和模型保存管道:
import csv
import json
import numpy as np
from pathlib import Path
import logging
# 配置日志
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(levelname)s %(message)s')
logger = logging.getLogger(__name__)
class LinearRegressionDataLoader:
"""
带完整容错的数据加载器
文件操作 + 异常处理 =
- 稳健读取(文件存在性、编码、格式)
- 行级容错(脏数据跳过并计数)
- 加载报告(告诉你成功了多少、跳过了多少)
"""
def __init__(self, expected_features=None):
self.expected_features = expected_features
self.stats = {'total_lines': 0, 'skipped_lines': 0, 'loaded_samples': 0}
def load(self, filepath):
"""加载训练数据"""
path = Path(filepath)
# 1. 文件检查
if not path.exists():
logger.error(f"文件不存在: {filepath}")
raise FileNotFoundError(filepath)
if path.suffix.lower() != '.csv':
logger.error(f"非 CSV 文件: {path.suffix}")
raise ValueError(f"只支持 .csv 文件,收到: {path.suffix}")
# 2. 读取(带编码回退)
X_list, y_list = [], []
encodings = ['utf-8', 'gbk', 'latin-1']
f = None
for enc in encodings:
try:
f = open(path, 'r', encoding=enc)
logger.info(f"使用编码 {enc} 打开文件")
break
except UnicodeDecodeError:
logger.warning(f"{enc} 编码失败,尝试下一个...")
continue
if f is None:
raise UnicodeDecodeError(
'utf-8/gbk/latin-1', b'', 0, 1,
f"所有尝试的编码都无法解码 {filepath}"
)
# 3. 解析 CSV
try:
reader = csv.reader(f)
header = next(reader, None)
start_line = 2 if header else 1
logger.info(f"表头: {header}")
for row_num, row in enumerate(reader, start=start_line):
self.stats['total_lines'] += 1
try:
if len(row) < 2:
raise ValueError(f"列数不足({len(row)} < 2)")
features = [float(v) for v in row[:-1]]
label = float(row[-1])
if self.expected_features is not None:
if len(features) != self.expected_features:
raise ValueError(
f"特征数不匹配(期望 {self.expected_features},实际 {len(features)})"
)
if np.any(np.isnan(features + [label])):
raise ValueError("包含 NaN")
X_list.append(features)
y_list.append(label)
except Exception as row_err:
self.stats['skipped_lines'] += 1
logger.debug(f"第 {row_num} 行跳过: {row_err}")
finally:
# 🧹 无论成功还是失败,都关文件
f.close()
# 4. 汇总
if len(X_list) == 0:
raise RuntimeError("没有加载到任何有效数据!")
X = np.array(X_list)
y = np.array(y_list)
self.stats['loaded_samples'] = len(X)
logger.info(
f"✅ 加载完成: {len(X)} 条样本, {X.shape[1]} 个特征, "
f"跳过 {self.stats['skipped_lines']} 行"
)
return X, y
class LinearModelSaver:
"""
模型保存器
文件操作 + 异常处理 =
- 原子写入(先写临时文件再重命名,防止写一半崩溃丢失)
- 多种格式支持(JSON / pickle)
- 保存前检查磁盘空间
"""
def __init__(self, checkpoint_dir='checkpoints'):
self.checkpoint_dir = Path(checkpoint_dir)
self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
def save_json(self, w, b, name='model', metadata=None):
"""保存为 JSON(人类可读,跨语言可用)"""
model_data = {
'weights': np.array(w).ravel().tolist(),
'bias': float(b),
'metadata': metadata or {}
}
target_path = self.checkpoint_dir / f'{name}.json'
self._atomic_write_json(model_data, target_path)
logger.info(f"💾 模型已保存: {target_path}")
def save_pickle(self, w, b, name='model', metadata=None):
"""保存为 pickle(Python 专用,更快)"""
import pickle
model_data = {'weights': w, 'bias': b, 'metadata': metadata or {}}
target_path = self.checkpoint_dir / f'{name}.pkl'
self._atomic_write_pickle(model_data, target_path)
logger.info(f"💾 模型已保存: {target_path}")
def _atomic_write_json(self, data, target_path):
"""原子写入:先写 .tmp,再 rename 覆盖"""
import tempfile, os
tmp_path = target_path.with_suffix('.tmp')
try:
with open(tmp_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
os.replace(tmp_path, target_path) # rename 是原子操作
except OSError as e:
logger.error(f"保存失败: {e}")
if tmp_path.exists():
tmp_path.unlink() # 清理临时文件
raise
def _atomic_write_pickle(self, data, target_path):
"""pickle 版本的原子写入"""
import pickle, os
tmp_path = target_path.with_suffix('.tmp')
try:
with open(tmp_path, 'wb') as f:
pickle.dump(data, f)
os.replace(tmp_path, target_path)
except OSError as e:
logger.error(f"保存失败: {e}")
if tmp_path.exists():
tmp_path.unlink()
raise
# ========= 把两个类用在你的线性回归训练里 =========
if __name__ == '__main__':
try:
# 1. 加载数据
loader = LinearRegressionDataLoader(expected_features=1)
X, y = loader.load('linear_data.csv')
# 2. 训练(省略训练过程...)
w = np.array([[2.03]])
b = 0.98
# 3. 保存模型
saver = LinearModelSaver(checkpoint_dir='checkpoints')
saver.save_json(w, b, name='linear_model',
metadata={'epochs': 1000, 'final_loss': 0.0031})
saver.save_pickle(w, b, name='linear_model_fast')
print("\n🎉 全部完成!")
except FileNotFoundError as e:
logger.critical(f"找不到数据文件: {e}")
exit(1)
except RuntimeError as e:
logger.critical(f"运行时错误: {e}")
exit(1)
except Exception as e:
logger.exception(f"未预料到的错误: {e}")
exit(1)
# 输出:
# [2026-08-15 10:00:00] INFO 使用编码 utf-8 打开文件
# [2026-08-15 10:00:00] INFO 表头: ['x', 'y']
# [2026-08-15 10:00:00] INFO ✅ 加载完成: 100 条样本, 1 个特征, 跳过 0 行
# [2026-08-15 10:00:00] INFO 💾 模型已保存: checkpoints\linear_model.json
# [2026-08-15 10:00:00] INFO 💾 模型已保存: checkpoints\linear_model_fast.pkl
# 🎉 全部完成!
看这个例子里两个概念各自做了什么:
| 角色 | 用了什么 | 做了什么 |
|---|---|---|
LinearRegressionDataLoader |
文件操作 + 异常处理 | 用 open 读文件,用 with 管理资源;逐行 try 捕获脏数据,编码失败自动回退 |
LinearModelSaver |
文件操作 + 异常处理 | 用 open('w') 写模型;用原子写入防止半写状态;finally 清理临时文件 |
if __name__ == '__main__' 里 |
异常处理 | 顶层兜底,分类型记录日志,优雅退出 |
你训练线性回归时,只需要 loader.load('xxx.csv') 和 saver.save_xxx(...)——不需要关心文件编码、脏数据跳过、写入原子性这些破事。文件操作和异常处理把这些细节全藏起来了。
终极总结
一张表彻底对比
| 维度 | 文件操作 File I/O | 异常处理 Exception |
|---|---|---|
| 父类 | 操作系统接口层 | 语言的错误控制机制 |
| 核心语法 | open() + with |
try-except-finally-else |
| 解决什么问题 | 让程序和硬盘数据打通 | 让程序在"出事"时不崩溃 |
| "记住"什么 | 读写的文件位置、缓冲区状态 | 发生了什么错误、堆栈信息 |
| AI 里的用处 | 加载 CSV/JSON 训练数据、保存模型参数 | 脏数据跳过、文件不存在时优雅降级、训练中途保存 |
| 一句话 | 程序和硬盘的读写通道 | 程序的"安全气囊" |
再对比:文件操作 × 异常处理 强强联合 vs 单独使用
| 方案 | 效果 |
|---|---|
| 只有文件操作 | 能读写数据,但一遇错就崩 |
| 只有异常处理 | 能抓错,但没数据可读 |
| 两者结合 | 能读能写、能扛能救,生产级别的稳健 |
给 AI 新手的学习路线图
Python 基础(文件与异常之前)
├── 变量、循环、条件
├── 函数(参数、返回值)
├── 数据结构(list、dict、tuple)
└── 面向对象(class)
│
▼
Python 基础进阶(这篇文章讲的就是这里)
├── 文件操作 = 程序和硬盘的读写通道
├── 异常处理 = 程序的安全气囊
└── with 语句 = 文件操作的自动保险
│
▼
Python 进阶(上一篇文章讲的)
├── 闭包 = 让函数有记忆
└── 生成器 = 让数据按需流
│
▼
AI 框架源码里到处都是这些
├── pandas.read_csv → 文件操作 + 异常处理的集大成
├── PyTorch DataLoader → 文件操作 + 生成器 + 异常处理
└── sklearn Pipeline → 全链路的异常处理
最后一句话
学文件操作和异常处理,别先背 API。先想清楚:你跑线性回归训练时,数据从哪来?存到哪去?中间炸了怎么办?
带着这三个真实问题去读代码、写代码,这两个概念就不再是飘在空中的语法,而是你写每一个 AI 项目都会用到的基本功。
✅ 动手时间 看完这篇文章,打开你的 Python 环境,做三件事: 1. 写一个能生成 100 条模拟线性回归数据的 CSV 文件操作脚本 2. 写一个带完整 try-except 的数据加载器——能处理文件不存在、编码错误、脏数据行 3. 写一个模型保存器——用 JSON 格式保存你训练出的线性回归权重和偏置
跑通一次,这两个概念就真的是你的了。然后回头看上一篇的闭包与生成器,把这四样东西拼起来——你就有了一个生产级的线性回归训练管道。
参考链接: - Python 官方文档 - 文件操作 - Python 官方文档 - 异常处理 - Python 官方文档 - csv 模块 - 《流畅的 Python》第 8 章 - 继承与自定义异常 -
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)