▤书架 ← 返回索引
▶目录

阶段 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{倍}}$$

"特征"这个词的来历:德语 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$ ✓

这两条怎么用:

其他常用性质:

性质 说明
$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

第 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

第 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("全部检查用例通过")

本阶段自评

  1. 数学:特征值和特征向量的定义是什么?用一句话说几何意义。
  2. 数学:为什么特征方程是 $\det(A-\lambda I)=0$?这个"=0"是从哪来的?
  3. 数学:求特征向量的步骤是什么?为什么特征向量不唯一?
  4. 数学:$\sum\lambda = \text{tr}(A)$ 和 $\prod\lambda=\det(A)$ 有什么用?
  5. 数学:三角矩阵的特征值怎么快速看出?
  6. 数学:旋转 90° 的矩阵为什么特征值是复数?几何上怎么理解?
  7. 数学:$\begin{pmatrix}2&0\\0&2\end{pmatrix}$ 和 $\begin{pmatrix}2&1\\0&2\end{pmatrix}$ 特征多项式相同吗?为什么一个是可对角化一个不是?
  8. 数学:PCA 里"特征值"和"解释方差比"是什么关系?
  9. 编程:继承和组合的区别是什么?判断口诀是什么?
  10. 编程:super() 是干什么的?不调用 super().__init__() 会怎样?
  11. 编程:@property 让 s.mean_rt 支持"不加括号",它还有什么好处?
  12. 编程:Git 的三个区域是什么?为什么要有个"暂存区"?
  13. 编程:.gitignore 对已经 git add 过的文件有效吗?为什么密钥提交过就必须换掉?
  14. 技能:遇到 Git 的任何奇怪状态,你的第一步该做什么?
  15. 连接:为什么说"大脑不是 Git 仓库"?这个对照让你对"记忆不可靠"有了什么直觉?

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

最后更新:2026-09-26