▤书架 ← 返回索引
▶目录

阶段 10 讲义

数学 11h:线代 · 向量组与线性方程组
编程 9h:文件读写与异常处理


第一部分 · 数学

阶段 9 我们学了"一个矩阵怎么求逆"。这一阶段回答更根本的问题:方程组到底有几个解?

答案来自一个词,就是秩。秩告诉你"这组方程真正提供了多少独立信息"。理解了秩,你就能不解方程直接判断解的情况。

1 向量是什么

向量的两种身份(都要有):

身份 说法 用途
几何 有方向的箭头 直观理解"方向""共线"
代数 一列数 $\begin{pmatrix}1\\2\\3\end{pmatrix}$ 能计算

记法:$\alpha = (1, 2, 3)$ 是行向量,$\alpha = \begin{pmatrix}1\\2\\3\end{pmatrix}$ 是列向量。
默认"向量"指列向量。

向量的基本运算:

$$\alpha + \beta = (a_1+b_1,\ a_2+b_2,\ a_3+b_3), \qquad k\alpha = (ka_1,\ ka_2,\ ka_3)$$

2 线性组合与线性表示

线性组合:把几个向量各乘一个数再加起来。

$$k_1\alpha_1 + k_2\alpha_2 + \cdots + k_m\alpha_m$$

线性表示:如果向量 $\beta$ 能写成上面这种形式,就说 $\beta$ 可由 $\alpha_1,\dots,\alpha_m$ 线性表示。

几何直觉(二维):

情况 能表示出什么
两个向量不共线 整个平面上的所有向量
两个向量共线 只有那条直线上的向量

⭐ 这就是"张成"(span)的意思:一组向量"张开"的空间有多大。
阶段 16 的 SVD,本质上就是在问"这个矩阵把空间张成了什么样"。

3 线性相关与线性无关 ⭐(本阶段核心概念)

3.1 定义

对一组向量 $\alpha_1, \dots, \alpha_m$,看方程

$$k_1\alpha_1 + k_2\alpha_2 + \cdots + k_m\alpha_m = 0$$

3.2 直觉(这是关键)

术语 直觉 二维例子
线性无关 每个向量都贡献了新方向,谁也不能由别人凑出来 $(1,0)$ 和 $(0,1)$
线性相关 有人是多余的,能被别人线性表示 $(1,0)$ 和 $(2,0)$(后者是前者的 2 倍)

线性相关 = 队伍里有"冗余的人"。

3.3 完整例子

判断 $\alpha_1 = (1,2,3)$,$\alpha_2 = (2,4,6)$,$\alpha_3 = (1,0,1)$ 是否线性相关。

第一步:观察

$$\alpha_2 = (2,4,6) = 2\times(1,2,3) = 2\alpha_1$$

$\alpha_2$ 是 $\alpha_1$ 的 2 倍,它没提供任何新方向。

第二步:写出关系式

$$-2\alpha_1 + 1\cdot\alpha_2 + 0\cdot\alpha_3 = 0$$

系数 $(-2, 1, 0)$ 不全为零,等式却成立 → 线性相关 ✓

第三步(通用方法):用秩验证

把三个向量拼成矩阵,算秩:

$$M = \begin{pmatrix} 1 & 2 & 1 \\ 2 & 4 & 0 \\ 3 & 6 & 1 \end{pmatrix}, \qquad \text{rank}(M) = 2$$

秩 2 < 向量个数 3 → 线性相关 ✓(有一个是冗余的)

💡 为什么秩能判定:秩 = "独立的向量个数"(下面第 4 节详讲)。
秩 < 向量个数,说明有向量没能贡献新方向,就是冗余,就是线性相关。

3.4 两条特殊规则(考试很常用)

规则一:含有零向量的向量组一定线性相关。

因为 $1\cdot 0 + 0\cdot\alpha_1 + \cdots = 0$,系数 $(1,0,\dots)$ 不全为零。

规则二:向量个数 > 向量维数 → 一定线性相关。

直觉:在二维平面上,给你 3 个向量,必然有多余的,因为平面只有 2 个独立方向。

⭐ 这条规则很深刻:它说的是"你不能凭空造出新方向"。
在 $n$ 维空间里,独立方向最多只有 $n$ 个。这是后面"秩 ≤ min(行数, 列数)"的来源。

4 秩:一组向量"真正有多少个独立方向"

秩(rank)的几个等价说法:

说法 含义
矩阵的秩 化成阶梯形后,非零行的个数
向量组的秩 这组向量中极大线性无关组的向量个数
本质 独立方向的个数

关键不等式(必记):

$$\text{rank}(A) \le \min(m, n)$$

一个 $m\times n$ 矩阵,秩最多是行数和列数里小的那个。

满秩:$\text{rank}(A) = \min(m,n)$(达到了上限)。

4.1 完整例子

求 $A = \begin{pmatrix} 1 & 2 & 3 \\ 2 & 4 & 6 \\ 1 & 1 & 1 \end{pmatrix}$ 的秩。

观察:第 2 行 = 第 1 行 × 2(冗余),所以秩最多 2。

用行变换化阶梯形:

$$A = \begin{pmatrix} 1 & 2 & 3 \\ 2 & 4 & 6 \\ 1 & 1 & 1 \end{pmatrix} \xrightarrow{R_2 - 2R_1} \begin{pmatrix} 1 & 2 & 3 \\ 0 & 0 & 0 \\ 1 & 1 & 1 \end{pmatrix} \xrightarrow{R_3 - R_1} \begin{pmatrix} 1 & 2 & 3 \\ 0 & 0 & 0 \\ 0 & -1 & -2 \end{pmatrix}$$

交换第 2、3 行,得阶梯形:

$$\begin{pmatrix} 1 & 2 & 3 \\ 0 & -1 & -2 \\ 0 & 0 & 0 \end{pmatrix}$$

非零行有 2 行 → $\text{rank}(A) = 2$ ✓(sympy 实算确认)

5 高斯消元法与"行最简形"

高斯消元:反复用三种行变换把矩阵化简。

行变换 说明 对解的影响
交换两行 $R_i \leftrightarrow R_j$ 解不变
某行乘非零数 $R_i \times k$ 解不变
某行加另一行的 $k$ 倍 $R_i + kR_j$ 解不变

为什么这三种不改变解:它们对应"把方程换个顺序""方程两边同乘""两个方程相加",都是等价变形。

行最简形(rref):继续消元,让每个主元都是 1,且所在列其余元素都是 0。

$$A \xrightarrow{\text{行变换}} \begin{pmatrix} 1 & 0 & -1 \\ 0 & 1 & 2 \\ 0 & 0 & 0 \end{pmatrix}$$

这就是"解已经摆在眼前"的形式。

💡 rref 是 numpy/sympy 里算秩和求解的实际方法。
阶段 8 说的"计算机用高斯消元",具体就是它。

6 线性方程组解的结构 ⭐

6.1 齐次方程组 $AX = 0$

永远有零解 $X = 0$。问题是"有没有非零解"。

$$\boxed{\text{有非零解} \iff \text{rank}(A) < n \ (\text{未知数个数})}$$

直觉:$n$ 个未知数需要 $n$ 条独立的约束才能被完全钉死。
约束不够(秩 < $n$),就有自由度,解就不唯一。

6.2 完整例子(齐次)

解 $\begin{cases} x + y - z = 0 \\ 2x - y + z = 0 \\ 3x + 0y + 0z = 0 \end{cases}$

系数矩阵 $H = \begin{pmatrix} 1 & 1 & -1 \\ 2 & -1 & 1 \\ 3 & 0 & 0 \end{pmatrix}$,$\det(H) = 0$,$\text{rank}(H) = 2$。

秩 2 < 未知数 3 → 有非零解 ✓

化 rref:

$$H \to \begin{pmatrix} 1 & 0 & 0 \\ 0 & 1 & -1 \\ 0 & 0 & 0 \end{pmatrix}$$

读出来:$x = 0$,$y - z = 0$,即 $y = z$。

通解($z$ 是自由变量):

$$\begin{pmatrix} x \\ y \\ z \end{pmatrix} = t\begin{pmatrix} 0 \\ 1 \\ 1 \end{pmatrix}, \qquad t \in \mathbb{R}$$

$\begin{pmatrix}0\\1\\1\end{pmatrix}$ 叫基础解系,齐次方程组的解是它的所有倍数。(sympy 实算:通解 $\{(0, z, z)\}$ ✓)

💡 自由变量个数 = 未知数个数 − 秩 = $3 - 2 = 1$。
有几个自由变量,基础解系就有几个向量,这是最快的检查法。

6.3 非齐次方程组 $AX = b$

解的结构定理:

$$\text{通解} = \underbrace{\text{一个特解}}_{\eta^*} + \underbrace{\text{齐次通解}}_{k_1\xi_1 + \cdots}$$

直觉:非齐次方程组的解,是"一个具体的解"加上"所有让它等于 0 的扰动"。

几何图像:一条不过原点的直线(特解定位置)+ 它的方向(齐次解定方向)。

6.4 完整例子(非齐次,无穷多解)

解 $\begin{cases} x + 2y + 3z = 1 \\ 2x + 4y + 6z = 2 \\ x + y + z = 2 \end{cases}$

检查秩:$\text{rank}(A) = 2$,$\text{rank}([A \mid b]) = 2$,相等 → 有解。
未知数 3 > 秩 2 → 无穷多解。

通解(sympy 实算):

$$\begin{pmatrix} x \\ y \\ z \end{pmatrix} = \begin{pmatrix} 3 \\ -1 \\ 0 \end{pmatrix} + t\begin{pmatrix} 1 \\ -2 \\ 1 \end{pmatrix}$$

验算特解 $(3,-1,0)$:$3 + 2(-1) + 3(0) = 1$ ✓,$2(3)+4(-1)+6(0) = 2$ ✓,$3 - 1 + 0 = 2$ ✓

验算齐次部分 $(1,-2,1)$:$1 + 2(-2) + 3(1) = 0$ ✓,$1 - 2 + 1 = 0$ ✓

7 解的存在性判定(一张表解决所有情况)

记 $r_A = \text{rank}(A)$,$r_b = \text{rank}([A \mid b])$,$n$ = 未知数个数。

情况 判定 解
无解 $r_A < r_b$ 方程组矛盾
唯一解 $r_A = r_b = n$ 一组解
无穷多解 $r_A = r_b < n$ 有 $n - r_A$ 个自由变量

注意 $r_A \le r_b$ 恒成立,增广矩阵只多了"一列",秩不可能比原来小。

7.1 三个例子对照(都用 sympy 实算过)

例一(无解):$\begin{cases} x + 2y = 1 \\ 2x + 4y = 3 \end{cases}$

$r_A = 1$,$r_b = 2$,$r_A < r_b$ → 无解 ✓

几何:两条平行直线。

例二(无穷多解):$\begin{cases} x + 2y + 3z = 1 \\ 2x + 4y + 6z = 2 \end{cases}$

$r_A = r_b = 1 < 3$ → 无穷多解,自由变量 $3 - 1 = 2$ 个 ✓

几何:两个重合的平面。

例三(唯一解):$\begin{cases} 2x + y - z = 1 \\ x - y + z = 2 \\ 3x + 2y = 3 \end{cases}$

$\det = -6 \ne 0$,$r_A = r_b = 3 = n$ → 唯一解 $(1, 0, 1)$ ✓

验算:$2(1)+0-1 = 1$ ✓,$1-0+1 = 2$ ✓,$3+0+0 = 3$ ✓

8 常见的坑

坑 说明
把"有解"和"唯一解"混为一谈 有解包含唯一解和无穷多解,要分开判
忘记检查增广矩阵的秩 只算 $\text{rank}(A)$ 不够,必须算 $\text{rank}([A\mid b])$
以为秩可以大于 $\min(m,n)$ 不可能。$2\times3$ 矩阵秩最多 2
以为"向量多就一定相关" 要看维数:$n$ 维空间里超过 $n$ 个才必然相关
齐次方程组说"无解" 齐次永远有零解。要么唯一(零解),要么无穷多
自由变量个数算错 是 $n - \text{rank}$,不是 $n - $ 方程个数
行变换时忘了"某行乘 $k$ 要 $k \ne 0$" 乘 0 会把整行抹掉,解就变了
混淆"列满秩"和"行满秩" 要看秩等于的是行数还是列数

9 🧠 这跟认知科学有什么关系

这一节的对应关系是我验证过的(用代码实算),但心理学结论请对照文献。

秩亏(rank deficiency)= 参数不可识别(non-identifiability),实验设计里最致命的问题。

9.1 关键前提

回归系数 $\hat\beta = (X^TX)^{-1}X^Ty$ 能算出来的前提,是设计矩阵 $X$ 满列秩。
秩亏 = 你设计的实验根本区分不出你想区分的参数。

9.2 实算给你看

我模拟了 50 个被试的数据,故意让自变量 $x_2$ 几乎等于 $x_1$:

量 实算结果
设计矩阵 $X$ 形状 $50 \times 4$
4 个奇异值 $9.71,\ 7.17,\ 6.07,\ \mathbf{3.91\times10^{-10}}$
条件数 $\mathbf{2.48\times10^{10}}$

看最后那个奇异值:$3.91\times10^{-10}$,实际上是 0。

我把 $x_2$ 换成独立随机的变量再看:

量 实算结果
4 个奇异值 $8.06,\ 6.98,\ 6.19,\ 5.85$
条件数 $1.38$

对比:条件数从 $2.5\times10^{10}$ 掉到 $1.38$,差了十个数量级。

⚠️ 一个非常值得注意的细节:np.linalg.matrix_rank(X) 在上面那个共线例子里返回了 4(看似满秩)!
这是因为它用了默认容差判断。
教训:判断共线性不能只看 matrix_rank,一定要看条件数。
matrix_rank 说"满秩",条件数说"几乎奇异",后者才是你要信的。

9.3 秩亏在实验设计里长什么样

设计错误 数学后果 通俗说法
两个条件完全共线(如"条件 A"列 = "非条件 B"列) 秩亏 两个参数在抢同一份数据
忘了去掉截距列,又加了全 1 的"组间"列 秩亏 同一信息说了两遍
被试所有试次都是同一个条件 该被试的对比估计不出 没有变化,就没有信息
自变量线性相关(接近但不完全) 秩不满但数值上能算 能算,但结果不可信(阶段 8/9 讲过)

秩 = 你的数据里"独立的约束"有多少条。
约束不够,参数就定不下来;约束重复,参数就打架。这是所有统计模型的地基。

9.4 这对你做研究意味着什么

现象 检查方法
模型报错 "singular matrix" / "rank deficient" 算 $\text{rank}(X)$,看是否 < 列数
系数大得离谱、符号乱跳 算条件数,> 30 就警惕,> 1000 基本不可信
去掉一个自变量,其他系数剧烈变化 看自变量间的相关矩阵

💡 务实建议:分析数据前,先算一次设计矩阵的条件数。
这一步只要一行代码,却能提前发现最致命的错误,比你跑完模型再debug省事得多。

⚠️ 诚实说明:
- 上面的奇异值、条件数都是我实算的(可复现),"满列秩 ⇒ 可唯一识别"
"秩亏 ⇒ 参数不可识别"是数学/统计事实。
- 但"该怎么处理共线""什么条件下可以接受"是方法学问题,
请以回归分析教材(如 Fox 的 Applied Regression Analysis)和方法论文献为准。
我保证数学,不保证研究规范。


10 数学作业 S10

第 9 题参考代码:

import numpy as np

# --- 作业 4:秩 ---
A = np.array([[2., 1., -1.],
              [1., -1., 1.],
              [3., 2., 0.]])
print("A 的秩 =", np.linalg.matrix_rank(A))
print("det =", np.linalg.det(A))
# 秩 = 3 = 列数 -> 满秩

# --- 作业 7:通解 ---
M = np.array([[1., 2., 3.],
              [2., 4., 6.],
              [1., 1., 1.]])
b = np.array([1., 2., 2.])

print("rank(A)   =", np.linalg.matrix_rank(M))
print("rank([A|b]) =", np.linalg.matrix_rank(np.column_stack([M, b])))

# 用最小二乘解出一个特解(无穷多解时它给的是"最小范数解")
sol, residuals, rank, sv = np.linalg.lstsq(M, b, rcond=None)
print("最小范数解 =", sol)

# 手算验证通解 (3,-1,0) + t*(1,-2,1)
t = np.linspace(-1, 1, 3)
for ti in t:
    x = np.array([3., -1., 0.]) + ti * np.array([1., -2., 1.])
    assert np.allclose(M @ x, b), f"t={ti} 不满足方程"
print("通解族全部满足方程 ✓")

# --- 条件数:判断是否病态 ---
print("A 的条件数 =", np.linalg.cond(A))

# 对照:接近奇异的矩阵
S = np.array([[1., 2., 3.],
              [2., 4., 6.0000001],
              [1., 1., 1.]])
print("S 的条件数 =", np.linalg.cond(S), "  <- 大得离谱,不可信")

💡 np.linalg.lstsq 是个"兜底"工具:方程组无解时它给最小二乘解,
无穷多解时它给范数最小的那个解。做回归时它就是主力。

⚠️ 但注意:lstsq 会给"某个"解,不会告诉你解不唯一。
必须自己先检查秩,这是本阶段最重要的一课。


第二部分 · 编程

11 为什么要读写文件

到目前为止,你的数据都是直接写死在代码里的:

data = [1, 2, 3]        # 关了程序就没了

真实工作里,数据在文件里:实验软件导出的 .csv、仪器记录的 .txt、网页下载的 .json。
读文件 = 把数据从磁盘搬进内存;写文件 = 把结果存下来。

⚠️ 程序的内存是"易失"的,程序一关,内存里的东西全消失。
想让结果留到明天,就必须写进文件。这是你从"练习"进入"做研究"的分界线。

12 读文件:with open(...)

标准写法(一定要用 with):

with open("data.txt", "r", encoding="utf-8") as f:
    content = f.read()          # 一次读全部,得到一个字符串
    print(content)

逐个部分拆解:

部分 作用
open(路径, 模式, encoding=...) 打开文件,返回一个文件对象
"r" 模式:r 读、w 写(覆盖)、a 追加、b 二进制
encoding="utf-8" 编码。处理中文必须写,否则可能乱码或报错
as f 给文件对象起名 f
with ...: 离开缩进块时自动关闭文件,哪怕中途报错

12.1 三种读法

with open("data.txt", "r", encoding="utf-8") as f:
    whole = f.read()              # ① 整个文件 → 一个字符串

with open("data.txt", "r", encoding="utf-8") as f:
    lines = f.readlines()         # ② 所有行 → 列表(每行末尾带 \n)

with open("data.txt", "r", encoding="utf-8") as f:
    for line in f:                # ③ 逐行处理(大文件必须用这个)
        print(line.strip())

⚠️ readlines() 会把整个文件读进内存。文件几百 MB 时会卡死甚至崩掉。
大文件一定用 ③ 逐行读取,这是"数据科学"里第一个真正会踩的性能坑。

12.2 为什么一定要 with

不用 with 的写法也有:

f = open("data.txt", "r", encoding="utf-8")
content = f.read()
f.close()                          # 必须手动关!

问题:如果 f.read() 那行报错了,f.close() 就不会执行,
文件句柄一直占着,久了会耗尽系统资源。

with 保证无论如何都会关闭,所以永远用 with。

with open("data.txt", "r", encoding="utf-8") as f:
    content = f.read()
# 出了这个缩进块,f 自动关闭

⭐ 这就是"上下文管理器":进入时准备好资源,退出时自动清理。
阶段 12 讲面向对象时你会看到它的实现原理。

13 写文件

with open("output.txt", "w", encoding="utf-8") as f:
    f.write("第一行\n")            # ⚠️ write 不会自动换行!
    f.write("第二行\n")
    f.writelines(["a\n", "b\n"])   # 写一个列表,同样要自己加 \n

⚠️ 两个新手必踩的坑:
① write() 不会自动加换行,必须自己写 "\n"。这和 print() 不一样!
② "w" 模式会清空原文件,如果文件已存在,内容立刻全没了。
想追加内容用 "a" 模式。

with open("log.txt", "a", encoding="utf-8") as f:
    f.write("又追加了一行\n")      # 追加,不覆盖原有内容

⚠️ "w" 清空是不可撤销的!打开文件前先想清楚你要的是 "w" 还是 "a"。

14 路径:文件到底在哪

这是新手最容易懵的地方,代码明明没错,但报 FileNotFoundError。

两种路径:

类型 例子 特点
绝对路径 /Users/mamingyu/Desktop/data.txt 从根目录开始,换电脑就失效
相对路径 data.txt 或 ../data.txt 相对于当前工作目录

⚠️ "相对路径相对于谁"是所有人第一次都会搞错的点:
相对路径的基准是"运行时你所在的工作目录",不是脚本文件所在的目录。
同一个脚本,从不同目录运行,相对路径的结果完全不同。

推荐写法:用 pathlib 基于脚本自身位置构造路径

from pathlib import Path

# __file__ 是"当前脚本的路径",.resolve() 拿到绝对路径
这里 = Path(__file__).resolve().parent        # 脚本所在目录
数据文件 = 这里 / "data.txt"                   # / 运算符用来拼路径
print(数据文件)

with open(数据文件, "r", encoding="utf-8") as f:
    print(f.read())

这样写,无论从哪个目录运行脚本、无论把文件夹搬到哪,都能找到文件。

⭐ pathlib 是现代 Python 的推荐做法,比老的 os.path.join 更好读。
Path("a") / "b" 就是 a/b,跨平台自动选对分隔符(Windows 用 \)。

常用操作:

from pathlib import Path

p = Path("data") / "raw.txt"

print(p.exists())        # 文件/目录存在吗
print(p.is_file())       # 是文件吗
print(p.suffix)          # '.txt'     扩展名
print(p.stem)            # 'raw'      不含扩展名的名字
print(p.parent)          # Path('data')  所在目录

p.parent.mkdir(parents=True, exist_ok=True)   # 建目录(已存在也不报错)

💡 parents=True, exist_ok=True 这两个参数几乎总要带上:
parents=True 表示"父目录不存在就一起建",exist_ok=True 表示"已存在别报错"。
少了它们,重跑脚本就会因为"目录已存在"而崩。

15 异常处理:try / except

异常(exception) = 程序运行时出的错。程序会当场停下,除非你处理它。

x = int("abc")            # ValueError: invalid literal for int() with base 10: 'abc'

处理它:

try:
    x = int("abc")
except ValueError:
    print("转换失败,用了默认值")
    x = 0

print(x)                  # 0        ← 程序没崩,继续跑

结构:

try:
    ...                   # 可能出错的代码
except 某种错误 as e:
    ...                   # 出错时做什么
else:
    ...                   # 没出错时才做(可选)
finally:
    ...                   # 无论如何都做(可选,常用于清理)

15.1 常见异常类型(必须认识)

异常 什么时候出现 例子
FileNotFoundError 文件不存在 open("没有的文件.txt")
ValueError 类型对但值不对 int("abc")
TypeError 类型不对 1 + "a"
KeyError 字典键不存在 d["没有的键"]
IndexError 索引越界 [1,2][10]
ZeroDivisionError 除以零 1 / 0
PermissionError 没权限 写系统目录

💡 技能卡 1(读懂报错)的报错,最后一行就是异常类型和原因。
现在你能主动抓到它了,这是从"被动挨打"到"主动防守"的转折。

15.2 什么时候该用 try/except

该用:

# 读一个可能不存在的文件
try:
    with open(path, encoding="utf-8") as f:
        data = f.read()
except FileNotFoundError:
    print(f"文件不存在:{path},跳过")
    data = ""

不该用:

# ❌ 把大段代码包起来,"什么都抓"
try:
    result = 复杂计算(a, b, c)
    save(result)
    send_email(result)
except Exception:          # 抓了所有异常
    pass                   # 什么也不做

⚠️ 两种滥用必须避免:
① except Exception: pass,把所有错误静默吞掉。
程序不崩了,但结果是错的你也不知道。这是最有害的写法。
② 用 try/except 掩盖你该修的逻辑错误,异常处理是处理"意外",不是处理"我写错了"。

⭐ 原则:尽量抓具体的异常类型(ValueError 而不是 Exception),
并且在 except 里做点实事(记日志、给默认值、明确地跳过)。


16 🔧 技能卡 10:文件读写与路径

技能卡 9 教你处理字符串;这张卡教你"数据从哪来、到哪去",这是科研代码里最容易出错的一环。

16.1 决策一:我要读还是写?

需求 模式 注意
读 "r" 文件必须存在,否则 FileNotFoundError
覆盖写 "w" ⚠️ 立刻清空原内容
追加 "a" 在末尾加
读二进制(图片/模型) "rb" 不做编码转换

读文件一律用 with,没有例外。

16.2 决策二:路径怎么写才不会崩?

写法 什么时候用
Path(__file__).parent / "data.txt" 推荐。脚本要给别人用、要换电脑
"data.txt" 只在"确定工作目录"的临时脚本里
/Users/mamingyu/... ⚠️ 永远不要写死,换电脑就废

16.3 决策三:要不要 try/except?

情况 处理
文件可能不存在,且你能给合理默认 用 try/except FileNotFoundError
文件必须存在,没有它程序无意义 不要抓,让它崩,你才知道
网络请求 / 用户输入 一定要抓

16.4 三条新手铁律

① 编码永远写 encoding="utf-8"

不写的话,Python 用系统默认编码。处理中文时非常容易出问题,而且
在你的电脑上能跑、在别人电脑上崩,这种 bug 最难查。

② 打开文件前先 print 一下路径

from pathlib import Path
p = Path(__file__).parent / "data.txt"
print("准备读取:", p)
print("文件存在吗?", p.exists())          # ← 这一行能省你半小时

③ 写文件先确认是 "w" 还是 "a"

"w" 会清空原来的内容。处理原始数据时永远不要用 "w",
原始数据只读不写,这是数据安全的第一原则。

⚠️ 一个真实的灾难场景:你想把处理结果存进 data.txt,
写成了 open("data.txt", "w") 而不是 open("clean_data.txt", "w"),
原始数据当场被清空,无法恢复。

防呆做法:原始数据文件名里带 raw,处理结果带 clean,且从不往 raw 文件里写。


17 🧠 这跟认知科学有什么关系(编程侧)

概念性内容,无法用代码验证,请对照文献。

文件读写 ↔ "可复现性"(reproducibility),近年认知科学最受关注的方法学危机。

17.1 为什么"读写文件"这么基础的事,值得单独讲

因为它决定你的研究能不能被别人复现。

做法 后果
数据在代码里写死 换一批数据要改代码 → 别人无法复现
路径写死绝对路径 别人电脑上跑不了 → 无法复现
手工在 Excel 里改数据 中间步骤无人知道 → 无法复现
分析脚本化 + 数据文件只读 ✓ 一条命令重跑全部结果

17.2 认知科学的"可复现危机"(replication crisis)

2015 年,Open Science Collaboration 做了一个大规模重复研究:
100 项已发表的心理学研究,只有约 36% 得到了显著重复结果。

原因很复杂,但其中一个技术因素是:很多研究没有共享数据和分析脚本,
别人无法知道"到底做了什么处理"。

解决办法在工程上很朴素:

做法 对应本阶段学的什么
数据用文件存,不写死在代码里 open() 读文件
路径用相对路径/脚本相对路径 pathlib + __file__
处理步骤全部写成脚本 而不是手工操作
原始数据只读,结果另存 "r" 读、"w" 写到新文件

"可复现"在技术上的最低要求,就是"把数据处理写成能一条命令跑完的脚本"。
这正是你从这一阶段开始要做的事。

17.3 异常处理在研究代码里的角色

场景 该怎么做
一个被试的数据文件缺失 try/except FileNotFoundError,记录下来,继续处理其他人
某行数据格式不对 try/except ValueError,输出是哪一行,不要静默跳过
批量处理 200 个文件 必须能报告"哪几个失败了",而不是"跑了但少了一半"

⚠️ 最危险的做法是 except: pass,
你的数据里少了一批被试,但程序"成功"跑完了,你还以为一切正常。
科研代码里,"静默失败"比"崩溃"危险得多。

⚠️ 诚实说明:
- 上面提到的重复率约 36% 来自 Open Science Collaboration (2015, Science) 的
大规模重复研究,具体数字请核对原文;
- "数据共享不足是可复现危机的原因之一"是学界的普遍共识,但各领域严重程度不同;
- 这一节是让你知道"为什么工程习惯在研究里重要",不是让你把它当结论。
请以方法论文献为准。


18 编程作业 S10

提示:一个和编码有关,一个和输出格式有关
- [ ] 8. 写 read_records(path),读一个 CSV 文件,返回元组列表
要求:跳过空行、去掉空格、转换类型、报错时说明是第几行
- [ ] 9.(技能)写 describe(path),打印"文件存在吗/多大/多少行/前 3 行",用于调试路径问题
- [ ] 10.(Python)写 clean_folder(raw_path, out_path),读原始数据、清洗后写到新文件(原始文件不动)

第 3 题参考写法:

from pathlib import Path


def count_lines(path):
    """数文件有多少行。用逐行读取,大文件也不会吃爆内存。"""
    count = 0
    with open(path, "r", encoding="utf-8") as f:
        for line in f:              # 逐行读,一次只有一行的内存开销
            count += 1
    return count


# 先造一个测试文件
p = Path("t3.txt")
p.write_text("第一行\n第二行\n第三行\n", encoding="utf-8")
print(count_lines(p))               # 3

第 5 题参考写法:

def safe_int(s, default=0):
    """把字符串转成整数。转不了就返回默认值,不抛异常。"""
    try:
        return int(s.strip())       # strip 去掉前后的空格
    except (ValueError, AttributeError):
        return default


print(safe_int("42"))       # 42
print(safe_int("abc"))      # 0
print(safe_int("", -1))     # -1
print(safe_int(None, -1))   # -1      None 没有 strip -> AttributeError,也被抓了

💡 为什么抓两个类型:int("abc") 抛 ValueError,
但 None.strip() 抛 AttributeError。抓多个类型要用元组:
except (A, B):。这是新手常漏的地方。

第 8 题参考写法:

from pathlib import Path


def read_records(path):
    """读 CSV,返回 [(被试, 反应时, 正确), ...],并报告出错的行号。"""
    records = []
    with open(path, "r", encoding="utf-8") as f:
        for lineno, line in enumerate(f, start=1):   # 从 1 开始数行号
            line = line.strip()
            if not line:                             # 空行跳过
                continue
            parts = line.split(",")
            try:
                subject = parts[0].strip()
                rt = float(parts[1].strip())
                correct = bool(int(parts[2].strip()))
            except (IndexError, ValueError) as e:
                # 关键:报出是第几行,便于排查,而不是静默跳过
                print(f"⚠️ 第 {lineno} 行格式错误,已跳过:{line!r} ({e})")
                continue
            records.append((subject, rt, correct))
    return records


# 造测试数据
p = Path("t8.csv")
p.write_text(
    "A01, 0.523, 1\n"
    "\n"
    "A02, 1.234, 0\n"
    "坏行, 没有逗号\n"
    "A03, 0.789, 1\n",
    encoding="utf-8",
)

data = read_records(p)
print(data)
# ⚠️ 第 4 行格式错误,已跳过:'坏行, 没有逗号' (could not convert string to float: '没有逗号')
# [('A01', 0.523, True), ('A02', 1.234, False), ('A03', 0.789, True)]

assert len(data) == 3                       # 坏行被跳过,空行也被跳过
assert data[0] == ("A01", 0.523, True)
print("检查用例通过")

第 9 题参考写法:

from pathlib import Path


def describe(path):
    """调试路径问题的万能小工具:文件到底在不在、长什么样。"""
    p = Path(path)
    print(f"路径      : {p}")
    print(f"绝对路径  : {p.resolve()}")
    print(f"存在吗    : {p.exists()}")
    if not p.exists():
        print("  -> 文件不存在,检查路径或先创建它")
        return
    print(f"是文件吗  : {p.is_file()}")
    print(f"大小      : {p.stat().st_size} 字节")
    print(f"行数      : {len(p.read_text(encoding='utf-8').splitlines())}")
    print("前 3 行   :")
    with open(p, "r", encoding="utf-8") as f:
        for i, line in enumerate(f):
            if i >= 3:
                break
            print(f"  {i + 1}: {line.rstrip()}")


describe("t8.csv")

💡 把这个 describe() 存进你的工具箱。以后遇到 FileNotFoundError,
第一件事就是把它指向那个路径,比瞎猜快得多。

第 7 题答案:

# Bug 1:没写 encoding="utf-8"
#   中文系统上可能乱码,换到别的电脑上可能直接 UnicodeDecodeError
# Bug 2:print(line) 会多打一个空行
#   因为文件每行末尾本来就有 \n,print 又加一个 \n -> 两个换行

# 修正版
with open("data.txt", "r", encoding="utf-8") as f:
    for line in f:
        print(line.rstrip())        # 去掉行尾换行,避免多空行
        # 或者用 print(line, end="")  让 print 不加换行

本阶段自评

  1. 数学:线性相关和线性无关的定义是什么?"相关"的直觉是什么?
  2. 数学:为什么"$n$ 维空间里超过 $n$ 个向量一定相关"?
  3. 数学:秩的三种说法是什么?秩的上限是多少?
  4. 数学:齐次方程组什么时候有非零解?判定条件是什么?
  5. 数学:非齐次方程组的解由哪两部分组成?几何上怎么理解?
  6. 数学:三种解的情况(无解/唯一/无穷多)分别对应什么秩的关系?
  7. 数学:为什么只算 $\text{rank}(A)$ 不够,还要算 $\text{rank}([A\mid b])$?
  8. 编程:为什么读文件一定要用 with?不用会有什么风险?
  9. 编程:"w" 和 "a" 有什么区别?处理原始数据该用哪个?
  10. 编程:为什么路径不能写死成 /Users/mamingyu/...?推荐怎么写?
  11. 编程:except Exception: pass 为什么危险?
  12. 技能:遇到 FileNotFoundError,你的第一步该做什么?
  13. 连接:为什么"秩亏"在实验设计里是致命的?怎么提前发现?

全部答得上 → 进阶段 11。
有任何一个答不上 → 回去补对应小节,别跳。

最后更新:2026-09-26