引子:为什么 AI 新手要啃文件操作与异常?

大家好,你们的小洛。

上一篇文章讲了闭包和生成器——那是让程序内部"有记忆"、数据内部"按需流"。但现实中,你的训练数据不可能直接写在代码里。真正跑线性回归时,我遇到了两个更基础、也更致命的问题:

  1. 我的训练数据在 CSV 文件里——100 万行数据躺在硬盘上,怎么把它读进 numpy 数组?读错一行格式全崩怎么办?
  2. 我的程序一运行就报错——文件路径写错了、权限不够、数据里混了个 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() vs readline() vs for 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 章 - 继承与自定义异常 - 

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐