阶段 13 讲义
数学 11h:线代 · 向量空间与特征值(一)
编程 9h:面向对象(二)· Git 基础
第一部分 · 数学
这一阶段回答一个非常"几何"的问题:一个矩阵把空间拉伸变形,有没有哪些方向"没被扭动"?
答案是:有。这些方向就叫特征向量,拉伸的倍数叫特征值。
为什么这重要:它把一个复杂的变换拆成"几个方向各自拉伸"。
阶段 16 的 SVD、PCA 降维、马尔可夫链的稳态,全都建立在特征值上。
1 向量空间(回顾)
向量空间:一个对"加法"和"数乘"封闭的集合。
几个核心概念:
| 概念 | 含义 | 例子($\mathbb{R}^2$) |
|---|---|---|
| 基 | 一组线性无关、能张成整个空间的向量 | $\{(1,0),(0,1)\}$ |
| 维数 | 基里向量的个数 | $2$ |
| 坐标 | 用基表示一个向量时的系数 | $(3,5) = 3(1,0)+5(0,1)$ |
关键点:同一个向量,换一组基,坐标就变了,但向量本身没变。
例:向量 $(3,5)$ 在基 $\{(1,0),(0,1)\}$ 下坐标是 $(3,5)$;
在基 $\{(1,1),(1,-1)\}$ 下呢?解 $(3,5) = a(1,1)+b(1,-1)$:
$$\begin{cases} a+b = 3 \\ a-b = 5\end{cases} \Longrightarrow a = 4,\ b = -1$$
坐标变成 $(4,-1)$,但向量还是那个向量。
⭐ 这个思想是特征值问题的核心:
能不能找到一组"特别好的基",让这个矩阵在这组基下变得极其简单(接近对角)?
如果能,就叫可对角化,阶段 15 的主角。
2 特征值与特征向量 ⭐⭐(本阶段核心)
2.1 直觉先行
矩阵乘向量 = 一个变换(旋转、拉伸、投影)。
大部分向量被变换后,方向会改变。但总有一些特殊方向,只被拉伸(或压缩),方向不变。
$$\underbrace{A v}_{\text{变换后}} = \underbrace{\lambda v}_{\text{只是放大了}\lambda\text{倍}}$$
- 满足这个式子的非零向量 $v$ 叫特征向量
- 对应的数 $\lambda$ 叫特征值
"特征"这个词的来历:德语 Eigenwert 意为"自身的值"。
这个方向是这个矩阵"自己固有的",和你选什么坐标系无关。
2.2 定义
$$Av = \lambda v, \qquad v \ne 0$$
读法:$A$ 作用在 $v$ 上,效果和"把 $v$ 乘个数字"一样。
2.3 几何直觉
| $\lambda$ | 效果 |
|---|---|
| $\lambda > 1$ | 沿这个方向拉长 |
| $0 < \lambda < 1$ | 沿这个方向压缩 |
| $\lambda < 0$ | 反向并缩放 |
| $\lambda = 0$ | 这个方向被压扁到 0 |
| $\lambda = 1$ | 这个方向完全不动 |
完整例子(本讲义反复用):
$$A = \begin{pmatrix} 2 & 1 \\ 1 & 2 \end{pmatrix}$$
试 $v_1 = (1,1)^T$:
$$Av_1 = \begin{pmatrix} 2 & 1 \\ 1 & 2 \end{pmatrix}\begin{pmatrix} 1 \\ 1 \end{pmatrix} = \begin{pmatrix} 3 \\ 3 \end{pmatrix} = 3\begin{pmatrix} 1 \\ 1 \end{pmatrix} \quad ✓$$
方向没变,只是变成 3 倍 → $v_1$ 是特征向量,$\lambda_1 = 3$。
试 $v_2 = (1,-1)^T$:
$$Av_2 = \begin{pmatrix} 2 & 1 \\ 1 & 2 \end{pmatrix}\begin{pmatrix} 1 \\ -1 \end{pmatrix} = \begin{pmatrix} 1 \\ -1 \end{pmatrix} = 1\cdot\begin{pmatrix} 1 \\ -1 \end{pmatrix} \quad ✓$$
→ $\lambda_2 = 1$(这个方向完全不动)
试一个普通向量 $v_3 = (1,0)^T$:
$$Av_3 = \begin{pmatrix} 2 \\ 1 \end{pmatrix}$$
方向变了(不再是 $\begin{pmatrix}1\\0\end{pmatrix}$ 的倍数)→ 不是特征向量。
所以 $A$ 的完整"性格"就是:沿 $(1,1)$ 方向拉 3 倍,沿 $(1,-1)$ 方向不变。
其他所有方向都是这两个方向的混合。
3 特征多项式(怎么把 $\lambda$ 求出来)
从 $Av = \lambda v$ 出发:
$$Av - \lambda v = 0 \quad\Longrightarrow\quad (A - \lambda I)v = 0$$
这是个齐次方程组,且我们要求 $v \ne 0$,非零解!
回忆阶段 10 的结论:齐次方程组有非零解 $\iff$ 系数矩阵的秩 < 未知数个数,
对方阵而言就是行列式为 0:
$$\boxed{\det(A - \lambda I) = 0}$$
这就是特征方程。左边叫特征多项式,是一个关于 $\lambda$ 的多项式,次数 = 矩阵阶数。
所以求特征值的路线是:
$$\text{特征值} \xleftarrow{\text{解方程}} \det(A-\lambda I) = 0$$
⭐ 注意这个逻辑链:
"要非零解 → 行列式必须为 0 → 得到一个关于 $\lambda$ 的方程"。
不是"因为行列式方便所以为 0",而是"我们主动要求它是 0"。
4 求特征值/特征向量的完整步骤
以 $A = \begin{pmatrix} 4 & 1 \\ 2 & 3 \end{pmatrix}$ 为例。
4.1 第一步:写 $A - \lambda I$
$$A - \lambda I = \begin{pmatrix} 4-\lambda & 1 \\ 2 & 3-\lambda \end{pmatrix}$$
4.2 第二步:令行列式为 0,解出 $\lambda$
$$\det(A-\lambda I) = (4-\lambda)(3-\lambda) - 1\cdot2 = \lambda^2 - 7\lambda + 10 = 0$$
因式分解:
$$(\lambda - 5)(\lambda - 2) = 0 \quad\Longrightarrow\quad \lambda_1 = 5,\quad \lambda_2 = 2$$
(sympy 实算确认:特征多项式 $= (\lambda-5)(\lambda-2)$ ✓)
4.3 第三步:对每个 $\lambda$ 解 $(A-\lambda I)v = 0$
对 $\lambda_1 = 5$:
$$A - 5I = \begin{pmatrix} -1 & 1 \\ 2 & -2 \end{pmatrix}$$
解 $\begin{pmatrix}-1&1\\2&-2\end{pmatrix}\begin{pmatrix}x\\y\end{pmatrix} = 0$:
第一行给 $-x + y = 0$,即 $x = y$。(第二行是它的 $-2$ 倍,冗余)
特征向量:$v_1 = \begin{pmatrix}1\\1\end{pmatrix}$(或任意非零倍数)
验算:$Av_1 = \begin{pmatrix}4&1\\2&3\end{pmatrix}\begin{pmatrix}1\\1\end{pmatrix} = \begin{pmatrix}5\\5\end{pmatrix} = 5v_1$ ✓
对 $\lambda_2 = 2$:
$$A - 2I = \begin{pmatrix} 2 & 1 \\ 2 & 1 \end{pmatrix} \Longrightarrow 2x + y = 0 \Longrightarrow y = -2x$$
特征向量:$v_2 = \begin{pmatrix}1\\-2\end{pmatrix}$
验算:$Av_2 = \begin{pmatrix}4-2\\2-6\end{pmatrix} = \begin{pmatrix}2\\-4\end{pmatrix} = 2v_2$ ✓
⚠️ 注意两件事:
① 特征向量不唯一,任意非零倍数都是。$k\begin{pmatrix}1\\1\end{pmatrix}$ 全都行。
② 求出 $\lambda$ 后必须回代,特征值是"和向量配对"的,不能分开。
5 特征值的性质(能省很多计算)
两条最实用的:
$$\boxed{\sum_i \lambda_i = \text{tr}(A) \quad (\text{迹} = \text{主对角线之和})}$$
$$\boxed{\prod_i \lambda_i = \det(A)}$$
我实算验证了三组:
| 矩阵 | 特征值 | $\text{tr}(A)$ vs $\sum\lambda$ | $\det(A)$ vs $\prod\lambda$ |
|---|---|---|---|
| $\begin{pmatrix}2&1\\1&2\end{pmatrix}$ | $3,\ 1$ | $4 = 4$ ✓ | $3 = 3$ ✓ |
| $\begin{pmatrix}4&1\\2&3\end{pmatrix}$ | $5,\ 2$ | $7 = 7$ ✓ | $10 = 10$ ✓ |
| $\begin{pmatrix}1&2&3\\0&4&5\\0&0&6\end{pmatrix}$ | $1,4,6$ | $11 = 11$ ✓ | $24 = 24$ ✓ |
这两条怎么用:
- 快速验算:算出特征值后,加起来看看等不等于迹。不等就是算错了。
- 省一步计算:求第三个特征值时,可以用 $\lambda_3 = \text{tr}(A) - \lambda_1 - \lambda_2$,不用再解方程。
其他常用性质:
| 性质 | 说明 |
|---|---|
| $A$ 和 $A^T$ 特征值相同 | 但特征向量一般不同 |
| 三角矩阵的特征值 = 对角线元素 | 上表第三个例子就是这样 |
| $A^{-1}$ 的特征值是 $1/\lambda$ | 前提是 $\lambda \ne 0$ |
| $A^k$ 的特征值是 $\lambda^k$ | 特征向量不变(这是幂法的原理) |
| 对称矩阵的特征值全为实数 | 且特征向量互相正交(阶段 15/16 用) |
5.1 一个重要的特殊情形:复特征值
$$A = \begin{pmatrix} 0 & -1 \\ 1 & 0 \end{pmatrix} \quad (\text{旋转 } 90°)$$
$$\det(A-\lambda I) = \lambda^2 + 1 = 0 \quad\Longrightarrow\quad \lambda = \pm i$$
实矩阵也可能有复特征值(sympy 实算确认)。
💡 这很合理:旋转 90° 怎么会有一个"方向不变"的实向量呢?
转 90° 后每个向量都转了,没有哪个方向是不变的。
复特征值正是"旋转"的数学标志,它的模长 $|i| = 1$ 表示"只转不缩"。
5.2 另一个特殊情形:不可对角化
$$A = \begin{pmatrix} 2 & 1 \\ 0 & 2 \end{pmatrix}$$
$$\det(A-\lambda I) = (2-\lambda)^2 = 0 \quad\Longrightarrow\quad \lambda = 2 \ (\text{重根})$$
但特征向量只有一个(sympy 实算:只有 $\begin{pmatrix}1\\0\end{pmatrix}$)。
所以这个矩阵"缺"了一个特征方向,无法对角化。
⚠️ 这引出阶段 15 的核心区分:
- 代数重数:$\lambda$ 作为特征方程根的重数(这里是 2)
- 几何重数:对应的线性无关特征向量个数(这里是 1)
- 几何重数 < 代数重数 → 不可对角化
6 常见的坑
| 坑 | 说明 |
|---|---|
| 忘写 $A - \lambda I$ 里的 $I$ | 写成 $\det(A-\lambda)$ 完全错,必须减 $\lambda I$(只减对角线) |
| 以为特征向量唯一 | 任意非零倍数都是,答案不唯一 |
| 只求特征值不求特征向量 | 题目问的是"特征值与特征向量",必须配对写出 |
| 求出 $\lambda$ 后不回代 | 特征向量必须代回 $(A-\lambda I)v=0$ 求 |
| 忘记验算 | 用 $\sum\lambda = \text{tr}(A)$ 一秒验算,别跳过 |
| 以为实矩阵只有实特征值 | 旋转矩阵的特征值是 $\pm i$ |
| 以为特征值重根就一定不可对角化 | 纯量矩阵 $\begin{pmatrix}2&0\\0&2\end{pmatrix}$ 有重根但可对角化(两个独立特征向量) |
| 混淆特征值和行列式 | $\det A \ne \lambda$,是 $\det A = \prod\lambda$ |
⚠️ 纯量矩阵那个坑:
$A = \begin{pmatrix}2&0\\0&2\end{pmatrix}$ 和 $B = \begin{pmatrix}2&1\\0&2\end{pmatrix}$ 特征多项式完全相同(都是 $(\lambda-2)^2$),
但 $A$ 可对角化,$B$ 不能(sympy 实算确认)。
所以"光看特征值"不足以判断一个矩阵的性质,这正是阶段 15 要做的事。
7 🧠 这跟认知科学有什么关系
这一节的对应关系是我验证过的(用代码实算),但心理学结论请对照文献。
协方差矩阵的特征值 = 主成分方差,特征向量 = 主成分方向。这就是 PCA。
7.1 PCA 在问什么
你有 10 个测量指标,但它们高度相关(比如 10 个认知任务的成绩)。
PCA(主成分分析)问:能不能用 2–3 个"综合指标"概括它们?
答案来自特征值。
7.2 实算给你看
我生成了 300 个被试的两个测量值,让它们高度相关:
协方差矩阵:
$$C = \begin{pmatrix} 0.8654 & 0.7943 \\ 0.7943 & 0.9872 \end{pmatrix}$$
求它的特征值:
| 量 | 实算结果 |
|---|---|
| 特征值 | $0.1297,\ \mathbf{1.7229}$ |
| 特征值之和 | $1.8526$ |
| 矩阵的迹(总方差) | $1.8526$ ✓ 完全相等 |
| 第一主成分解释的方差比例 | $\dfrac{1.7229}{1.8526} = \mathbf{0.93}$ |
| 第一主成分方向 | $(0.6795,\ 0.7336)$ |
读出来:一个方向就解释了 93% 的总变异。
原本两个指标,现在一个"综合指标"就够了,这就是降维。
关键等式:
$$\sum_i \lambda_i = \text{tr}(C) = \text{总方差}$$
所以"特征值 ÷ 特征值之和"就是"这个主成分解释了多少方差"。
这个比例叫"解释方差比"(proportion of variance explained),是 PCA 报告里的核心数字。
7.3 PCA 的完整逻辑(你现在能看懂了)
| 步骤 | 数学操作 | 对应本阶段学的 |
|---|---|---|
| 1. 标准化数据 | 减去均值除标准差 | — |
| 2. 算协方差矩阵 $C$ | $\frac{1}{n-1}X^TX$ | 阶段 9(矩阵乘法) |
| 3. 求 $C$ 的特征值/特征向量 | $\det(C-\lambda I)=0$ | 本阶段 |
| 4. 按特征值从大到小排序 | — | — |
| 5. 取前 $k$ 个 | 降维到 $k$ 维 | — |
| 6. 把数据投影上去 | $X V_k$ | 阶段 9(矩阵乘法) |
PCA 在你以后会反复遇到:
| 应用 | 怎么用 |
|---|---|
| EEG / fMRI 降维 | 几百个电极 → 几十个主成分 |
| 行为数据的因子结构 | 20 个任务成绩 → 3 个认知能力维度 |
| 去除伪迹 | 丢掉小特征值对应的成分(常被当作噪声) |
| 白化(whitening) | 缩放各主成分到同方差 |
⭐ 特征值告诉你"这个方向有多重要",特征向量告诉你"这个方向是什么"。
PCA 就是"按重要性给方向排序,然后只留下前几个"。⚠️ 诚实说明:
- 上面的协方差矩阵、特征值、解释方差比都是我实算的(可复现),
"协方差矩阵的特征向量是主成分方向""特征值之和等于总方差"是数学事实。
- 但"保留几个主成分""丢掉的成分是噪声还是信号"是研究决策,
不同领域有不同惯例,做法本身也有争议(平行分析、碎石图、理论驱动等)。
请以 Principal Component Analysis(Jolliffe)和领域方法论文献为准。
我保证数学,不保证你该保留几个成分。
8 数学作业 S13
- [ ] 1. 求 $A = \begin{pmatrix} 3 & 0 \\ 0 & 5 \end{pmatrix}$ 的特征值与特征向量(看一眼就能答)
- [ ] 2. 求 $A = \begin{pmatrix} 2 & 1 \\ 1 & 2 \end{pmatrix}$ 的特征多项式、特征值、特征向量
- [ ] 3. 求 $A = \begin{pmatrix} 4 & 1 \\ 2 & 3 \end{pmatrix}$ 的特征值与特征向量,并验算 $Av = \lambda v$
- [ ] 4. 用 $\sum\lambda = \text{tr}(A)$ 验算作业 3 的结果
- [ ] 5. 求 $A = \begin{pmatrix} 1 & 2 & 3 \\ 0 & 4 & 5 \\ 0 & 0 & 6 \end{pmatrix}$ 的特征值(用最快的方法)
- [ ] 6. 求 $A = \begin{pmatrix} 0 & -1 \\ 1 & 0 \end{pmatrix}$ 的特征值,并解释为什么是复数
- [ ] 7. 求 $A = \begin{pmatrix} 2 & 1 \\ 0 & 2 \end{pmatrix}$ 的特征值和特征向量,说明为什么它不可对角化
- [ ] 8.(重点) $A = \begin{pmatrix} 2 & 0 \\ 0 & 2 \end{pmatrix}$ 和 $B = \begin{pmatrix} 2 & 1 \\ 0 & 2 \end{pmatrix}$ 的特征多项式相同吗?都可对角化吗?
- [ ] 9. 已知 $\lambda$ 是 $A$ 的特征值,证明 $1/\lambda$ 是 $A^{-1}$ 的特征值($\lambda \ne 0$)
- [ ] 10.(Python)用 sympy 验证作业 2–8,并用 numpy 对作业 3 验证 $Av = \lambda v$
第 10 题参考代码:
import numpy as np
import sympy as sp
from sympy import Matrix, symbols, factor, eye
lam = symbols('lambda')
def report(A, name=""):
"""打印一个矩阵的特征值/特征向量/可对角化情况。"""
M = Matrix(A)
print(f"--- {name or A.tolist()} ---")
print("特征多项式 det(A-λI) =", factor(M.charpoly(lam).as_expr()))
print("特征值:", M.eigenvals())
for val, mult, vecs in M.eigenvects():
vs = [v.T.tolist()[0] for v in vecs]
print(f" λ={val} 代数重数={mult} 几何重数={len(vecs)} 特征向量={vs}")
print("迹 =", M.trace(), " Σλ =", sum(M.eigenvals()),
" | det =", M.det(), " Πλ =", sp.prod([v**m for v, m in M.eigenvals().items()]))
print("可对角化?", M.is_diagonalizable())
print()
report([[2, 1], [1, 2]], "[[2,1],[1,2]]")
report([[4, 1], [2, 3]], "[[4,1],[2,3]]")
report([[0, -1], [1, 0]], "旋转矩阵(复特征值)")
report([[2, 1], [0, 2]], "Jordan 块(不可对角化)")
report([[2, 0], [0, 2]], "纯量矩阵(重根但可对角化)")
report([[1, 2, 3], [0, 4, 5], [0, 0, 6]], "上三角")
print("=== numpy 验证 Av = λv ===")
A = np.array([[4., 1.], [2., 3.]])
vals, vecs = np.linalg.eig(A)
for i in range(len(vals)):
v = vecs[:, i]
lhs = A @ v
rhs = vals[i] * v
print(f"λ={vals[i]:.4f} A·v = {lhs} λ·v = {rhs} "
f"相等? {np.allclose(lhs, rhs)}")
print()
print("=== 作业 8:特征多项式相同,可对角化不同 ===")
for M in [np.array([[2., 0.], [0., 2.]]), np.array([[2., 1.], [0., 2.]])]:
vals, vecs = np.linalg.eig(M)
# 数一数有几个线性无关的特征向量
rank = np.linalg.matrix_rank(vecs)
print(f"M={M.tolist()} 特征值={vals} 独立特征向量个数={rank} "
f"{'可对角化' if rank == 2 else '不可对角化'}")
💡
np.linalg.eig返回的特征向量:vecs[:, i]是第 $i$ 个特征值对应的特征向量
(numpy 返回的是列向量形式,注意下标顺序)。
eigh是给对称矩阵用的,更快更稳定,且特征值自动升序。做 PCA 时用eigh。
第二部分 · 编程
9 继承:让子类"继承"父类
问题:你有一个 Trial 类。现在要做冲突试次(Stroop 任务),它多一个"冲突类型"字段。
笨办法:把 Trial 的代码复制一遍再加字段。→ 将来改 bug 要改两个地方。
继承:
class Trial:
"""一次试次。"""
def __init__(self, rt, correct):
self.rt = rt
self.correct = correct
def summary(self):
return f"RT={self.rt:.3f} {'✓' if self.correct else '✗'}"
class ConflictTrial(Trial): # ← 括号里写父类名 = "继承"
"""冲突试次:比 Trial 多一个冲突类型。"""
def __init__(self, rt, correct, conflict_type):
super().__init__(rt, correct) # ← 先调用父类的 __init__
self.conflict_type = conflict_type
def summary(self):
base = super().summary() # ← 复用父类的 summary
return f"[{self.conflict_type}] {base}"
t = Trial(0.5, True)
c = ConflictTrial(0.7, False, "色词不一致")
print(t.summary()) # RT=0.500 ✓
print(c.summary()) # [色词不一致] RT=0.700 ✗ ← 用的是子类版本
super() 是什么:
| 写法 | 含义 |
|---|---|
super().__init__(...) |
调用父类的 __init__(把父类的活干完) |
super().summary() |
调用父类的 summary(复用而不重写) |
⭐
super()的意思是"我的上一层"。
子类只写"和父类不同的部分",相同的部分靠super()复用。
这就是继承的价值:消除重复。
isinstance 检查:
print(isinstance(c, ConflictTrial)) # True 它是子类实例
print(isinstance(c, Trial)) # True 它"也是"父类实例
print(isinstance(t, ConflictTrial)) # False 反过来不成立
💡 "子类也是父类",这是继承最重要的语义(里氏替换原则):
任何需要Trial的地方,都可以给它一个ConflictTrial。
就像"麻雀是鸟",所以"需要鸟的地方"都能用麻雀。
10 魔术方法:让你的对象"像内置类型一样"
魔术方法(dunder methods,前后各两个下划线)让你的对象支持 Python 的内置语法。
class RT:
"""一组反应时。"""
def __init__(self, name, rts):
self.name = name
self.rts = list(rts)
def __len__(self): # 支持 len()
return len(self.rts)
def __getitem__(self, i): # 支持 r[0] 和 r[1:3]
return self.rts[i]
def __iter__(self): # 支持 for x in r
return iter(self.rts)
def __contains__(self, v): # 支持 x in r
return v in self.rts
def __eq__(self, other): # 支持 ==
return isinstance(other, RT) and self.rts == other.rts
def __add__(self, other): # 支持 +
return RT(f"{self.name}+{other.name}", self.rts + other.rts)
def __repr__(self): # 支持 print() / 调试显示
return f"RT({self.name!r}, n={len(self.rts)})"
r = RT("A01", [0.5, 0.6, 0.7])
print(len(r)) # 3 ← __len__
print(r[0], r[-1]) # 0.5 0.7 ← __getitem__
print(list(r)) # [0.5, 0.6, 0.7] ← __iter__
print(0.6 in r) # True ← __contains__
print(r == RT("x", [0.5, 0.6, 0.7])) # True ← __eq__
print(r + RT("A02", [0.9])) # RT('A01+A02', n=4) ← __add__
print(r) # RT('A01', n=3) ← __repr__
常用的魔术方法一览:
| 魔术方法 | 让你能写 | 说明 |
|---|---|---|
__init__ |
RT(...) |
初始化 |
__repr__ |
print(r) / 调试 |
可读的字符串,调试用 |
__str__ |
str(r) / f"{r}" |
给用户看的(没定义就用 __repr__) |
__len__ |
len(r) |
长度 |
__getitem__ |
r[0] |
下标取值(自动支持切片和 for) |
__iter__ |
for x in r |
迭代 |
__contains__ |
x in r |
成员检查 |
__eq__ |
r1 == r2 |
相等比较 |
__add__ |
r1 + r2 |
加法 |
__lt__ |
r1 < r2 |
小于(可以 sorted()) |
⚠️ 关于
__repr__:
不定义它,print(对象)输出<__main__.RT object at 0x104f2a3d0>,完全没用。
定义了它,调试时一眼就能看清对象内容。
这是投入产出比最高的魔术方法,建议每个类都写。💡
__getitem__很神奇:只要定义了它,切片、for循环、in全都自动可用
(Python 会按"老式迭代协议"去调它)。但显式定义__iter__更清楚。
11 @property:把方法伪装成属性
问题:s.mean_rt() 是方法调用,但"平均反应时"读起来应该像一个属性,不该带括号。
class Subject:
"""一个被试。"""
def __init__(self, sid, rts):
self.sid = sid
self._rts = list(rts) # 下划线开头 = "内部使用,别直接改"
@property # ← 这个装饰器把方法变成"属性"
def mean_rt(self):
"""平均反应时。"""
if not self._rts:
return 0
return sum(self._rts) / len(self._rts)
@property
def n(self):
"""试次数量。"""
return len(self._rts)
s = Subject("A01", [0.5, 0.6, 0.7])
print(s.mean_rt) # 0.6 ← 注意:没有括号!
print(s.n) # 3
好处:
| 好处 | 说明 |
|---|---|
| 语法更自然 | s.mean_rt 比 s.mean_rt() 更像读数据 |
| 加了只读保护 | 默认不能赋值(见下) |
| 对外接口不变 | 以后想改成"缓存计算结果",调用方不用改代码 |
只读保护:
s.mean_rt = 1.0
# AttributeError: property 'mean_rt' of 'Subject' object has no setter
💡 为什么要保护:
mean_rt是从_rts算出来的。
如果允许随便赋值,就会出现"_rts是 [0.5],但mean_rt是 1.0"的不一致状态。
@property从语法上杜绝了这种不一致。⚠️ 下划线开头的约定:
_rts里的_是"这是内部属性,外部别碰"的约定。
Python 不会强制(你仍然能访问),但看到下划线就该知道不该依赖它。
12 组合 vs 继承
继承是"是"(is-a);组合是"有"(has-a)。
class Experiment:
"""一个实验,包含多个被试。"""
def __init__(self, name, subjects):
self.name = name
self.subjects = subjects # ← 组合:实验"有"被试
@property
def grand_mean(self):
"""所有被试所有试次的总平均。"""
all_rts = [rt for s in self.subjects for rt in s._rts]
return sum(all_rts) / len(all_rts) if all_rts else 0
def __len__(self):
return len(self.subjects)
e = Experiment("Stroop", [Subject("A01", [0.5, 0.6]),
Subject("A02", [0.8, 0.9])])
print(e.grand_mean) # 0.7
print(len(e)) # 2
怎么选:
| 关系 | 用什么 | 例子 |
|---|---|---|
| "是" | 继承 | 冲突试次是一种试次 |
| "有" | 组合 | 实验有被试 |
⚠️ 新手最常犯的错:用继承表达"有"。
比如写class Experiment(Trial),实验不是试次!
判断口诀:读一遍"子类 是 父类",读得通才用继承。
13 Git:为什么要版本控制
你现在的做法(我猜):
论文_v1.docx
论文_v2.docx
论文_v2_最终.docx
论文_v2_最终_真的最终.docx
论文_v2_最终_真的最终_改好了.docx
这种做法的三宗罪:
| 问题 | 说明 |
|---|---|
| 不知道改了什么 | 两个文件 diff 一下全是乱的 |
| 不敢删任何一版 | 空间越占越多 |
| 多人协作时灾难 | 谁覆盖了谁? |
Git 解决的正是这些:
- 完整历史:每一次改动都有记录,包括"改了什么"
- 可以回退:任何时候能回到过去的任何一版
- 分支隔离:试新想法时不影响主线
- 协作基础:多人改同一个项目而不互相覆盖
⭐ Git 是你进实验室的硬门槛。代码不放进 Git 的实验室,基本不存在。
而且你的 GitHub 主页就是简历的一部分,阶段 16 的目标之一就是"1 个 GitHub 项目"。
14 Git 的三个区域(理解这个就懂了 Git)
这是 Git 最容易懵的地方,但只有三块:
工作区 暂存区 本地仓库
(working dir) (staging area) (repository)
│ │ │
│ git add │ git commit │
└──────────────────────►└─────────────────────►┘
你正在编辑的文件 "准备提交的清单" "已保存的历史"
| 区域 | 是什么 | 怎么进去 |
|---|---|---|
| 工作区 | 你直接编辑的文件 | — |
| 暂存区 | 一份"待提交清单" | git add |
| 本地仓库 | 已保存的历史(.git 目录) |
git commit |
⭐ 为什么要有"暂存区"这个中间层:
你改了 5 个文件,但只想提交其中 2 个(另外 3 个还没改完)。
暂存区让你精确挑拣"这次提交包含什么"。
15 Git 最小工作流(覆盖 90% 日常)
首次配置(一台电脑只需一次):
git config --global user.name "你的名字"
git config --global user.email "你的邮箱@example.com"
开始一个仓库:
git init # 在当前目录创建仓库(生成 .git 目录)
日常三步循环(这是最常用的):
git status # ① 看看现在什么情况(随时都可以跑)
git add readme.txt # ② 把改动放进暂存区
git commit -m "加 readme" # ③ 提交,写清楚这次干了什么
我实跑给你看(真实的终端输出):
$ git status --short
?? readme.txt ← ?? 表示"未跟踪"(Git 还没管这个文件)
$ git add readme.txt
$ git status --short
A readme.txt ← A 表示"已加入暂存区"
$ git commit -m "第一次提交:加 readme"
$ git log --oneline
b69fc05 第一次提交:加 readme ← 提交历史
$ echo "world" >> readme.txt # 改一下文件
$ git status --short
M readme.txt ← M 表示"已修改但未暂存"
$ git diff ← 看具体改了什么
diff --git a/readme.txt b/readme.txt
@@ -1 +1,2 @@
hello
+world
状态符号速查:
| 符号 | 含义 |
|---|---|
?? |
未跟踪(新文件,Git 还没管) |
A |
已暂存(新增) |
M |
已修改 |
M |
第二列 M = 改了但没 add |
M |
第一列 M = 已 add,等提交 |
D |
已删除 |
⚠️
M的位置(第一列还是第二列)含义完全不同,这是新手最容易误读的地方。
第一列 = 暂存区状态,第二列 = 工作区状态。
其他常用命令:
git log --oneline # 一行一条看历史
git log --oneline --graph # 带分支图形
git diff # 工作区 vs 暂存区
git diff --staged # 暂存区 vs 上次提交
git show HEAD # 看最近一次提交的内容
后悔药:
git restore readme.txt # 撤销工作区的修改(⚠️ 改动会丢!)
git restore --staged f.txt # 从暂存区拿出来(不影响文件内容)
git commit --amend # 修改最近一次提交的信息(⚠️ 别改已推送的)
16 .gitignore:哪些文件不该进仓库
有些文件不该提交:密码、大数据、临时文件、缓存。
在项目根目录建 .gitignore:
# 虚拟环境和缓存
venv/
__pycache__/
*.pyc
.ipynb_checkpoints/
# 数据和结果(太大,或含隐私)
data/
*.csv
*.npy
results/
# 系统文件
.DS_Store
Thumbs.db
# 编辑器和密钥
.vscode/
.idea/
*.key
.env
规则:
| 写法 | 含义 |
|---|---|
venv/ |
忽略整个目录 |
*.pyc |
忽略所有 .pyc 文件 |
!important.csv |
不忽略(例外) |
# 注释 |
注释 |
⚠️ 注意:
.gitignore只对"还没有被跟踪"的文件生效。
如果文件已经被git add过,写进.gitignore也没用!补救:
git rm --cached 文件名(从 Git 移除但保留本地文件)。⚠️ 另一个必须知道的:提交过一次的密码/密钥,即使后来删掉,
它仍然留在 Git 历史里,别人 clone 下来还能翻出来。
所以密钥从来就不该提交。万一提交了,必须立刻去换掉那个密钥,
光删文件是不够的。
17 🔧 技能卡 13:Git 基本工作流
技能卡 12 教你理解类和实例;这张卡教你"怎么不把自己的代码搞丢",Git 是你进实验室的硬门槛。
17.1 决策一:我现在该跑哪条命令?
我改了一些文件,想保存 →
git status 先看看情况
git add <文件> 挑要提交的
git commit -m "说明" 提交
我想看看改了啥 → git diff
我想看看历史 → git log --oneline
我改错了想撤销 → git restore <文件> ⚠️ 改动会丢
17.2 决策二:git add 加什么?
| 命令 | 含义 | 什么时候用 |
|---|---|---|
git add 文件名 |
加指定文件 | 推荐,精确 |
git add . |
加当前目录所有改动 | 偷懒,但要先看 status |
git add -A |
加整个仓库所有改动 | 更偷懒,风险更大 |
⚠️ 永远先
git status再git add。
一眼扫过去有没有不该提交的东西(数据文件、密钥、几百 MB 的 PDF)。
提交了再想撤,麻烦得多。
17.3 决策三:提交信息怎么写
好的提交信息:
修复 read_records 在空行上的崩溃
添加 PCA 分析脚本
更新讲义 13 的特征值部分
坏的提交信息:
update
改了一下
asdfasdf
最终版
格式:动词开头 + 说清"做了什么"。
⭐ 判断标准:半年后的你,看这条信息能不能知道当时干了什么?
不能就重写。
17.4 三条新手铁律
① 小步提交,别攒一大堆
每完成一个"能说清楚的小改动"就提交一次。
攒一周再提交,等于回到"论文_v2_最终"的老路。
② 提交前一定跑 git status
确认没有多余文件混进去。
③ 密钥、数据、大文件绝不提交
写进 .gitignore,而且要在第一次 git add 之前就写好。
一旦提交过,光加 .gitignore 是不够的。
💡 遇到
fatal: not a git repository:
你不在仓库目录里,或者还没git init。
git status会明确告诉你当前状态。遇到 Git 问题,先跑git status。💡 遇到冲突/奇怪状态:
不要乱试命令。先git status,它会告诉你"现在能做什么"。
Git 的设计哲学是:每个状态下都告诉你下一步的选项。
18 🧠 这跟认知科学有什么关系(编程侧)
概念性内容,无法用代码验证,请对照文献。
Git 的提交历史 ↔ 认知科学里的"记忆的可重构性"与"来源监控",一个值得玩味的对照。
18.1 编程侧的事实
Git 的历史是"不可篡改的因果链":
| Git 特性 | 含义 |
|---|---|
| 每次提交记录父提交 | 形成一条有向的因果链 |
| 提交哈希由内容决定 | 内容变一点,哈希全变 |
| 可以回到任何历史点 | 完整的溯源能力 |
| 改历史要重写哈希 | 篡改是可检测的 |
"谁在什么时候改了什么、为什么改",全都有记录,且无法抵赖。
18.2 认知科学里的对照物
人类的记忆恰恰相反:它是"可重构的"(reconstructive),不是"可回放的"。
| 维度 | Git 历史 | 人类记忆 |
|---|---|---|
| 保真度 | 精确,逐字节保存 | 大致,细节会丢 |
| 写入方式 | 追加,不改旧记录 | 每次提取都会重写 |
| 错误检测 | 哈希校验,篡改可查 | 很难检测投毒 |
| 来源 | 明确的父子关系 | 来源监控(source monitoring)容易出错 |
几个认知科学里和 Git 形成鲜明对照的现象:
| 现象 | 说明 | Git 里的对应 |
|---|---|---|
| 错误记忆(DRM 范式) | 没见过"睡觉",却"记得"见过 | Git 里不可能:没提交过就是没有 |
| 记忆重构(Bartlett) | 回忆时按自己的图式"改写"了故事 | Git 里 = 篡改历史(哈希会变) |
| 来源监控错误 | 记得内容,但记错"从哪知道的" | Git 里来源清晰不可混 |
| 闪光灯记忆 | 自信但细节常错 | — |
| 逐字 vs 要点记忆 | 记住要点,忘了逐字 | Git 逐字全留(除非 .gitignore) |
"每次提取都会重写记忆"(reconsolidation,再巩固)。
在 Git 里这是一条禁忌(--amend 要慎重),
在脑里它是常态。
⭐ 这个对照的价值:它让"记忆为什么不可靠"有了一个极其具体的直觉。
你的大脑不是 Git 仓库,它更像一个"每次读取都重新生成"的系统。
这在工程上叫"不可复现",在认知上叫"重构性记忆"。⚠️ 诚实说明:
- "记忆是重构性的"是认知心理学的主流观点,有大量经典研究
(Bartlett 1932、Loftus 的错误记忆研究、DRM 范式等)。
- 但"重构"的具体机制、"再巩固"的边界条件仍有大量争论
(比如再巩固是否只在特定条件下发生)。
- 本节的对照是教学类比,不是神经机制的主张。
大脑里没有"提交哈希",也没有"暂存区"。请以文献为准。
19 编程作业 S13
- [ ] 1. 定义
Trial(rt、correct、summary()),再定义ConflictTrial继承它并加conflict_type - [ ] 2. 解释
super().__init__(...)为什么必须调用?不调用会怎样(动手试) - [ ] 3. 用
isinstance验证"子类实例也是父类实例"(三行,正反各测) - [ ] 4. 给
RT类加__len__和__getitem__,验证len()、r[0]、for都能用 - [ ] 5. 给
RT类加__eq__,让两个内容相同的对象==为True - [ ] 6.(重点)给
Subject加@property mean_rt,并验证不能给它赋值 - [ ] 7. 写
class Experiment,用组合(不是继承)装若干个Subject,提供grand_mean - [ ] 8. 判断下面该用继承还是组合,说明理由:
(a)
ConflictTrial和Trial(b)Experiment和Subject(c)Dog和Animal - [ ] 9.(技能)在电脑上建一个 Git 仓库,完成一次"修改→add→commit",用
git log --oneline确认 - [ ] 10.(技能)写一个
.gitignore,至少忽略__pycache__/、*.csv、.DS_Store、data/ - [ ] 11.(Python)写
class TrialSet,用魔术方法支持len()、迭代、+合并、==比较
第 6 题参考写法:
class Subject:
"""一个被试。"""
def __init__(self, sid, rts):
self.sid = sid
self._rts = list(rts)
@property
def mean_rt(self):
"""平均反应时。空数据返回 0。"""
if not self._rts:
return 0
return sum(self._rts) / len(self._rts)
if __name__ == "__main__":
s = Subject("A01", [0.5, 0.6, 0.7])
assert abs(s.mean_rt - 0.6) < 1e-9
print("mean_rt =", s.mean_rt) # 0.6 ← 没有括号
# 验证只读:赋值必须报错
try:
s.mean_rt = 1.0
raise AssertionError("应该报 AttributeError 但没报")
except AttributeError as e:
print("赋值被拒绝:", e)
# property 'mean_rt' of 'Subject' object has no setter
# 空数据
assert Subject("A02", []).mean_rt == 0
print("检查用例通过")
第 2 题答案:
class Parent:
def __init__(self, x):
self.x = x
class ChildBad(Parent):
def __init__(self, x, y):
self.y = y # ⚠️ 忘了调用 super().__init__
# 父类的 __init__ 根本没跑,self.x 从未被赋值!
class ChildGood(Parent):
def __init__(self, x, y):
super().__init__(x) # ✓ 先把父类的活干完
self.y = y
bad = ChildBad(1, 2)
print(bad.y) # 2
print(bad.x) # ✗ AttributeError: 'ChildBad' object has no attribute 'x'
good = ChildGood(1, 2)
print(good.x, good.y) # 1 2 ✓
不调用
super().__init__()的后果:父类负责初始化的属性根本不存在,
访问时报AttributeError。而且报错的位置可能离原因很远。
你在ChildBad.__init__里没写错什么,错在"少写了一行"。规则:子类的
__init__第一件事就是super().__init__(...),
除非你故意不想让父类初始化(极少见)。
第 11 题参考写法:
"""一组试次的容器,用魔术方法做到像内置类型一样好用。"""
class TrialSet:
"""存放若干试次,支持 len()、迭代、+ 合并、== 比较。"""
def __init__(self, trials=None):
self.trials = list(trials) if trials else []
def add(self, rt, correct):
"""加一个试次。返回 self 以支持链式调用。"""
self.trials.append({"rt": rt, "correct": correct})
return self
@property
def mean_rt(self):
"""平均反应时。空集合返回 0。"""
if not self.trials:
return 0
return sum(t["rt"] for t in self.trials) / len(self.trials)
@property
def accuracy(self):
"""正确率。空集合返回 0。"""
if not self.trials:
return 0
return sum(1 for t in self.trials if t["correct"]) / len(self.trials)
# ---- 魔术方法 ----
def __len__(self):
return len(self.trials)
def __iter__(self):
return iter(self.trials)
def __getitem__(self, i):
return self.trials[i]
def __add__(self, other):
return TrialSet(self.trials + other.trials)
def __eq__(self, other):
return isinstance(other, TrialSet) and self.trials == other.trials
def __repr__(self):
return (f"TrialSet(n={len(self.trials)}, "
f"mean_rt={self.mean_rt:.3f}, acc={self.accuracy:.1%})")
if __name__ == "__main__":
a = TrialSet().add(0.5, True).add(0.6, True)
b = TrialSet().add(0.9, False)
assert len(a) == 2
assert abs(a.mean_rt - 0.55) < 1e-9
assert abs(a.accuracy - 1.0) < 1e-9
assert len(list(a)) == 2 # 可迭代
assert a[0]["rt"] == 0.5 # 可下标
merged = a + b
assert len(merged) == 3
assert abs(merged.accuracy - 2 / 3) < 1e-9
assert a == TrialSet().add(0.5, True).add(0.6, True)
assert a != b
print(a) # TrialSet(n=2, mean_rt=0.550, acc=100.0%)
print(merged) # TrialSet(n=3, mean_rt=0.667, acc=66.7%)
print("全部检查用例通过")
本阶段自评
- 数学:特征值和特征向量的定义是什么?用一句话说几何意义。
- 数学:为什么特征方程是 $\det(A-\lambda I)=0$?这个"=0"是从哪来的?
- 数学:求特征向量的步骤是什么?为什么特征向量不唯一?
- 数学:$\sum\lambda = \text{tr}(A)$ 和 $\prod\lambda=\det(A)$ 有什么用?
- 数学:三角矩阵的特征值怎么快速看出?
- 数学:旋转 90° 的矩阵为什么特征值是复数?几何上怎么理解?
- 数学:$\begin{pmatrix}2&0\\0&2\end{pmatrix}$ 和 $\begin{pmatrix}2&1\\0&2\end{pmatrix}$ 特征多项式相同吗?为什么一个是可对角化一个不是?
- 数学:PCA 里"特征值"和"解释方差比"是什么关系?
- 编程:继承和组合的区别是什么?判断口诀是什么?
- 编程:
super()是干什么的?不调用super().__init__()会怎样? - 编程:
@property让s.mean_rt支持"不加括号",它还有什么好处? - 编程:Git 的三个区域是什么?为什么要有个"暂存区"?
- 编程:
.gitignore对已经git add过的文件有效吗?为什么密钥提交过就必须换掉? - 技能:遇到 Git 的任何奇怪状态,你的第一步该做什么?
- 连接:为什么说"大脑不是 Git 仓库"?这个对照让你对"记忆不可靠"有了什么直觉?
全部答得上 → 进阶段 14。
有任何一个答不上 → 回去补对应小节,别跳。