▤书架 ← 返回索引
▶目录

阶段 12 讲义

数学 11h:高数 · 定积分及其应用
编程 9h:面向对象(一)


第一部分 · 数学

阶段 11 的不定积分是"求一族函数"。这一阶段的定积分是"求一个数"。

别小看这个区别:不定积分的答案里有 $C$,定积分的答案里没有,因为两个端点一减,$C$ 自己消掉了。

而"定积分是一个数"这件事,正是它在科学里如此有用的原因:它能给出可测量的量(面积、体积、总概率、平均值)。

1 从"面积"到定积分

设想:求曲线 $y = x^2$ 在 $[0,1]$ 上、与 $x$ 轴围成的面积。

方法:把它切成很多细长条,用小矩形近似,再把面积加起来。

$n$ 越大,近似越准。我实算给你看($f(x)=x^2$,区间 $[0,1]$):

$n$ 左黎曼和 右黎曼和
10 $0.285000$ $0.385000$
100 $0.328350$ $0.338350$
1000 $0.332834$ $0.333833$
100000 $0.333328$ $0.333338$
极限 $\mathbf{1/3}$ $\mathbf{1/3}$

两边从外往里夹,都趋向 $\frac13 \approx 0.3333333$。

定义:这个极限就是定积分

$$\int_a^b f(x)\,dx = \lim_{n\to\infty}\sum_{i=1}^{n} f(x_i)\,\Delta x$$

读法:$\int$ 是拉长的 S(Sum),$dx$ 是"无穷小的宽度"。

⚠️ 定积分是一个数,不定积分是一族函数。两者是完全不同的东西,
只是碰巧由同一个公式(牛顿-莱布尼茨)联系起来。

2 牛顿-莱布尼茨公式 ⭐⭐(本阶段的核心)

如果不用这个公式,每次算定积分都要去求黎曼和的极限,那会疯掉。

$$\boxed{\int_a^b f(x)\,dx = F(b) - F(a)}$$

其中 $F$ 是 $f$ 的任意一个原函数。

先求原函数,再代两个端点,上减下。

2.1 完整例子一

求 $\int_0^1 x^2\,dx$

第一步:找原函数 $F(x) = \dfrac{x^3}{3}$

第二步:代入两个端点,上减下

$$\int_0^1 x^2 dx = \left[\frac{x^3}{3}\right]_0^1 = \frac{1^3}{3} - \frac{0^3}{3} = \frac13$$

和黎曼和的极限一致 ✓

💡 方括号 $[F(x)]_a^b$ 是标准写法,表示"先求原函数,再代端点相减"。
写作业时用这个记号,既清楚又不容易漏端点。

2.2 完整例子二

求 $\int_0^{\pi} \sin x\,dx$

$$\int_0^{\pi}\sin x\,dx = [-\cos x]_0^{\pi} = (-\cos\pi) - (-\cos 0) = 1 - (-1) = 2$$

⚠️ 这里最容易错:$\cos\pi = -1$,前面还有个负号,所以 $-\cos\pi = 1$。
符号一乱就容易算成 0。建议老老实实一步一步写。

实算确认:$\int_0^\pi \sin x\,dx = 2$ ✓

2.3 完整例子三(含换元)

求 $\int_0^1 \dfrac{1}{1+x^2}dx$

原函数是 $\arctan x$,所以

$$[\arctan x]_0^1 = \arctan 1 - \arctan 0 = \frac{\pi}{4} - 0 = \frac{\pi}{4} \approx 0.7854$$

实算确认 ✓

⚠️ 定积分做换元时,别忘了同时换积分限!
如果令 $u = g(x)$,那么 $x=a$ 对应 $u=g(a)$,$x=b$ 对应 $u=g(b)$。
换了限就不用再换回 $x$,少一步,也少一次出错的机会。

3 定积分的性质

性质 式子
上下限交换变号 $\int_a^b f = -\int_b^a f$
上下限相同为零 $\int_a^a f = 0$
区间可加 $\int_a^b f = \int_a^c f + \int_c^b f$
常数可提出 $\int_a^b kf = k\int_a^b f$
可拆开 $\int_a^b (f+g) = \int_a^b f + \int_a^b g$
奇函数在对称区间积分为 0 $\int_{-a}^{a} f = 0$($f$ 为奇函数)
偶函数在对称区间 $\int_{-a}^{a} f = 2\int_0^a f$($f$ 为偶函数)

奇偶性那条特别好用:

$$\int_{-1}^{1} x^3\,dx = 0 \quad (\text{奇函数,实算确认})$$

看都不用算,左半边的负面积和右半边的正面积正好抵消。

💡 考试实战:看到对称区间先想奇偶性。
能直接得 0 的题,别去老老实实算原函数。

4 微积分基本定理(变限积分求导)

这条定理把"求导"和"积分"彻底焊在了一起。

$$\frac{d}{dx}\int_a^x f(t)\,dt = f(x)$$

白话:"先积分再求导,回到原函数。"

为什么叫"基本定理":它证明了求导和积分互为逆运算。
这是整个微积分的地基。牛顿-莱布尼茨公式就是它的直接推论。

我实算给你看:

$f(t)$ $\int_0^x f(t)dt$ $\frac{d}{dx}\left[\int_0^x f(t)dt\right]$
$t^2$ $\dfrac{x^3}{3}$ $x^2$ ✓
$\sin t$ $1 - \cos x$ $\sin x$ ✓
$e^{-t^2}$ $\dfrac{\sqrt{\pi}}{2}\mathrm{erf}(x)$ $e^{-x^2}$ ✓

第三行有意思:$e^{-t^2}$ 没有初等原函数(这就是著名的"正态分布积分"),
但它的变限积分求导依然等于它自己,定理不需要你求出原函数。

⭐ 这就是为什么这条定理性感:
即使原函数写不出来,求导和积分的关系依然成立。
正态分布的累积分布函数 $\Phi(x) = \int_{-\infty}^x \frac{1}{\sqrt{2\pi}}e^{-t^2/2}dt$
就是靠这个才存在。

5 定积分的应用

5.1 应用一:平面图形的面积

两曲线之间的面积 = $\int_a^b (\text{上曲线} - \text{下曲线})\,dx$

例:求 $y = x$ 与 $y = x^2$ 在 $[0,1]$ 之间的面积。

先看谁在上面(在 $[0,1]$ 上 $x \ge x^2$),所以上减下:

$$S = \int_0^1 (x - x^2)dx = \left[\frac{x^2}{2} - \frac{x^3}{3}\right]_0^1 = \frac12 - \frac13 = \frac16$$

实算确认:$\frac16 \approx 0.16667$ ✓

⚠️ 一定要先判断"谁在上"。判反了答案会变成负数(虽然绝对值对)。

5.2 应用二:函数的平均值

$$\bar{f} = \frac{1}{b-a}\int_a^b f(x)\,dx$$

直觉:把所有点的高度加起来平均。积分就是"求和",除以长度就是"平均"。

例:$f(x) = x^2$ 在 $[0,3]$ 上的平均值

$$\bar{f} = \frac13\int_0^3 x^2 dx = \frac13\left[\frac{x^3}{3}\right]_0^3 = \frac13\cdot\frac{27}{3} = 3$$

实算确认 ✓

这直接对应物理量:平均速度 $= \frac{1}{T}\int_0^T v(t)dt$(总路程 ÷ 总时间)。

5.3 应用三:旋转体的体积

曲线绕 $x$ 轴旋转一周的体积:

$$V = \pi\int_a^b [f(x)]^2\,dx$$

为什么有 $\pi$ 和平方:切片是圆盘,半径 $f(x)$,面积 $\pi f^2$。

例:$y = x^2$ 绕 $x$ 轴转 $[0,1]$

$$V = \pi\int_0^1 x^4 dx = \pi\left[\frac{x^5}{5}\right]_0^1 = \frac{\pi}{5} \approx 0.6283$$

实算确认 ✓

6 反常积分(了解)

两种情况:区间无穷,或被积函数无界。

$$\int_1^\infty f(x)dx = \lim_{b\to\infty}\int_1^b f(x)dx$$

关键问题:收敛还是发散?

积分 结果 收敛?
$\int_1^\infty \dfrac{1}{x^2}dx$ $1$ ✓ 收敛
$\int_1^\infty \dfrac{1}{x}\,dx$ $+\infty$ ✗ 发散
$\int_0^\infty e^{-x}dx$ $1$ ✓ 收敛
$\int_0^1 \dfrac{1}{\sqrt{x}}dx$ $2$ ✓ 收敛

(以上都是 sympy 实算结果)

⭐ 注意 $\frac{1}{x}$ 和 $\frac{1}{x^2}$ 的分界:
$\int_1^\infty \frac{1}{x^p}dx$ 在 $p > 1$ 时收敛,$p \le 1$ 时发散。
这个"$p=1$ 是分界线"的事实,是分析学里反复出现的模式。

$\frac{1}{\sqrt x}$ 在 $0$ 附近虽然趋于无穷,但无穷得"不够快",所以积分收敛
"无穷大"也分快慢。

7 常见的坑

坑 说明
代端点时算错符号 $[-\cos x]_0^\pi$:$-\cos\pi = +1$,一不留神就得 0
换元后忘了换积分限 换了 $u$ 就必须换上下限,否则结果必错
求面积时判错"谁在上" 先比较或者画图,否则得负数
求面积时直接算积分不加绝对值 曲线穿过 $x$ 轴时要分段,否则正负相消
把定积分当成不定积分 定积分是一个数,答案里不应有 $+C$,也不应有 $x$
对称区间忘记用奇偶性 白白多算一堆,还容易错
误以为所有反常积分都收敛 $\int_1^\infty\frac1x dx$ 发散,别想当然
混淆"面积"和"积分" 积分可以有负值,面积永远是正的

⚠️ "面积"和"积分"的区别:
$\int_0^{2\pi}\sin x\,dx = 0$,但曲线与 $x$ 轴围成的面积是 4,不是 0。
求面积要分正负区间,或者对 $|f|$ 积分。


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

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

心理物理函数和信号检测论 = 两个积分,认知科学最基础的两块砖。

8.1 心理物理函数:从"刺激强度"到"觉察概率"

问题:给一个强度为 $x$ 的刺激(声音多响、光多亮),被试"报告感觉到了"的概率是多少?

经典答案:这是累积正态分布

$$P(\text{觉察} \mid x) = \Phi\!\left(\frac{x - \mu}{\sigma}\right) = \int_{-\infty}^{(x-\mu)/\sigma}\frac{1}{\sqrt{2\pi}}e^{-t^2/2}\,dt$$

它是一个积分。这条 S 形曲线叫心理物理函数,是心理物理学的核心对象。

我实算了 $\Phi$ 的值(数值积分 vs scipy.stats.norm.cdf):

$x$ 数值积分 理论值
$-1.96$ $0.024998$ $0.024998$
$0$ $0.500000$ $0.500000$
$1.0$ $0.841345$ $0.841345$
$2.0$ $0.977250$ $0.977250$

完全吻合,你的心理物理实验数据点,理论上应该落在这条曲线上。

8.2 信号检测论:$d'$ 是两个积分之差

实验情境:被试要在"有信号"和"无信号"之间做判断。
两类错误不可避免:虚报(没信号说成有)和漏报(有信号说没)。

信号检测论(SDT)的核心量 $d'$(辨别力):

$$d' = z(\text{命中率}) - z(\text{虚报率})$$

其中 $z(\cdot)$ 是正态分布的分位数函数($\Phi$ 的反函数)。

我实算验证了这套关系:

真实 $d'$ 命中率 虚报率 由命中/虚报反算的 $d'$
$0.5$ $0.5987$ $0.4013$ $0.5000$ ✓
$1.0$ $0.6915$ $0.3085$ $1.0000$ ✓
$1.5$ $0.7734$ $0.2266$ $1.5000$ ✓
$2.0$ $0.8413$ $0.1587$ $2.0000$ ✓
$3.0$ $0.9332$ $0.0668$ $3.0000$ ✓

验证完美闭合,说明"命中率/虚报率"和"$d'$"是同一组信息的两种表达。

8.3 别的积分(拓宽视野)

概念 积分形式
微分熵 $h = -\int f(x)\ln f(x)\,dx$(正态时 $=\frac12\ln(2\pi e\sigma^2)$)
信息量 / KL 散度 $D_{KL} = \int p(x)\ln\frac{p(x)}{q(x)}dx$
反应时分布的期望 $\int_0^\infty t f(t)dt$(阶段 11 讲过)
贝叶斯后验 分母 $=\int p(D\mid\theta)p(\theta)d\theta$
神经调谐曲线下的面积 $\int$ 神经反应

实算确认:正态分布 $\sigma=1$ 的微分熵,理论 $\frac12\ln(2\pi e) = 1.418939$,
数值积分 $-\int f\ln f\,dx = 1.418939$ ✓

⭐ 定积分在认知科学里是"把分布变成可测量数字"的工具。
概率、期望、熵、$d'$,都是用积分定义的。
你手算的部分在考试里;但在论文里,它们全都以 $\int$ 的形式出现。

⚠️ 诚实说明:
- 上面的积分值、$\Phi$ 值、$d'$ 反算都是我实算的(可复现),
"心理物理函数是累积正态""$d'=z(H)-z(F)$"是SDT 的标准数学形式。
- 但"心理物理函数一定是对称正态吗"是领域内的研究问题
(Weibull、logistic、Gumbel 等都被用过),
"哪些条件下 SDT 假设成立"也有大量文献讨论。
请以 Signal Detection Theory and Psychophysics(Green & Swets)
和近年方法论文献为准。我保证数学,不保证模型选择。


9 数学作业 S12

第 11 题参考代码:

import numpy as np
import sympy as sp
from sympy import symbols, sin, cos, sqrt, pi, oo, simplify, integrate, diff, sympify

x = symbols('x', positive=True)
LOC = {'x': x}


def check(expr, lo, hi, name=""):
    """用符号积分算定积分,返回一个数。"""
    e = sympify(expr, locals=LOC)
    v = integrate(e, (x, lo, hi))
    try:
        num = float(v)
        print(f"{name or expr:28s} = {str(v):12s} = {num:.6f}")
    except TypeError:
        print(f"{name or expr:28s} = {v}   <- 发散或含符号")
    return v


print("=== 符号积分 ===")
check("3*x**2 + 2*x", 0, 2, "∫_0^2 (3x²+2x)dx")
check("sqrt(x)", 1, 4, "∫_1^4 √x dx")
check("cos(x)", 0, pi / 2, "∫_0^{π/2} cos x dx")
check("2/x", 1, 2, "∫_1^2 2/x dx")
check("1/x**3", 1, oo, "∫_1^∞ 1/x³ dx")
check("1/x", 1, oo, "∫_1^∞ 1/x dx  (发散)")
check("1/sqrt(x)", 0, 1, "∫_0^1 1/√x dx")

print()
print("=== 应用 ===")
check("x - x**2", 0, 1, "y=x 与 y=x² 之间面积")
print("平均值 =", integrate(x**2, (x, 0, 3)) / 3)
print("旋转体 V = π∫_0^1 (√x)² dx =", pi * integrate(x, (x, 0, 1)))
check("sin(x)", 0, 2 * pi, "∫_0^{2π} sin x dx  (注意这是 0!)")

print()
print("=== 变限积分求导 ===")
t = symbols('t', positive=True)
F = integrate(sp.sin(t**2), (t, 0, x))
print("d/dx ∫_0^x sin(t²)dt =", simplify(diff(F, x)))

print()
print("=== numpy 数值积分独立验证 ===")
from scipy import integrate as si

# 验证 ∫_0^1 x² dx = 1/3
v1, err1 = si.quad(lambda z: z**2, 0, 1)
print(f"数值积分 ∫_0^1 x² dx = {v1:.10f}  (误差 {err1:.2e})  理论 1/3 = {1/3:.10f}")

# 验证 ∫_0^{π/2} cos x dx = 1
v2, err2 = si.quad(np.cos, 0, np.pi / 2)
print(f"数值积分 ∫_0^{{π/2}} cos = {v2:.10f}  理论 1")

# 黎曼和:用"数格子"的方式逼近,看它收敛
f = lambda z: z**2
for n in [10, 1000, 100000]:
    xs = np.linspace(0, 1, n + 1)
    left = np.sum(f(xs[:-1])) / n
    right = np.sum(f(xs[1:])) / n
    print(f"  n={n:>6}: 左和={left:.6f}  右和={right:.6f}")

💡 三种验证方式并存:符号积分(sympy)、数值积分(scipy.quad)、黎曼和(numpy)。
学数学时能用代码从三个角度确认同一个答案,是最好的检查方式。
任何一个环节写错了,另两个都会对不上。


第二部分 · 编程

10 为什么需要"类"

先看一段你已经会写的代码(阶段 10 的元组方案):

subject = ("A01", 0.523, 0.95)      # (编号, 反应时, 正确率)
print(subject[0], subject[1], subject[2])

问题来了:subject[0]、subject[1]、subject[2],这些数字是什么意思?
三个月后你还记得吗?别人读你的代码能看懂吗?

而且,数据和处理数据的函数是分开的:

def describe(subject):
    return f"{subject[0]}: RT={subject[1]}, ACC={subject[2]}"

print(describe(subject))

痛点很明确:

问题 说明
靠位置记数据 [0] 是编号还是反应时?靠脑记
数据和操作分家 数据在元组里,函数在外面,靠参数传
没法表达"属于谁" 这个函数只对"被试元组"有意义,但看不出关联

"类"就是来解决这三件事的。

11 定义类与创建实例

class Subject:
    """一个被试的实验数据。"""

    def __init__(self, sid, rt, acc):
        self.sid = sid         # 编号
        self.rt = rt           # 反应时
        self.acc = acc         # 正确率

    def describe(self):
        """返回一句可读的说明。"""
        return f"{self.sid}: RT={self.rt}, ACC={self.acc}"


# 创建"实例"
s1 = Subject("A01", 0.523, 0.95)
s2 = Subject("A02", 1.234, 0.80)

print(s1.rt)              # 0.523        ← 用名字取,不用记位置!
print(s1.describe())      # A01: RT=0.523, ACC=0.95
print(s2.describe())      # A02: RT=1.234, ACC=0.8

对比一下:

元组方案 类方案
取反应时 subject[1](记位置) s1.rt(看名字)
操作数据 函数在外面 describe(subject) 方法挂在对象上 s1.describe()
加字段 全体改索引,极易出错 加一行 self.xxx = ...

⭐ 类把"数据"和"操作数据的函数"打包在一起。
这叫封装(encapsulation)。

12 __init__ 和 self

__init__ 是"初始化方法",创建实例时自动调用。

s1 = Subject("A01", 0.523, 0.95)
#            ↑ 这里自动调用了 __init__(self=s1, sid="A01", rt=0.523, acc=0.95)

12.1 self 是什么(新手最大的困惑)

self 就是"这个实例自己"。

class Subject:
    def __init__(self, sid, rt, acc):
        self.sid = sid
        #  ↑         ↑
        #  给"s1 的 sid 属性"赋值为参数 sid

s1 = Subject("A01", 0.523, 0.95)
s2 = Subject("A02", 1.234, 0.80)

print(s1.sid)      # A01      ← s1 自己的
print(s2.sid)      # A02      ← s2 自己的

关键理解:s1 和 s2 是两个独立的实例,各自有自己的一套属性。

⚠️ self 不是 Python 的关键字,只是一个约定俗成的参数名。
你写成 this 也能跑,但千万别写,全世界都叫 self。

⚠️ 调用时不用传 self:
定义是 def describe(self),调用是 s1.describe()。
Python 自动把 s1 当作 self 传进去。

这是新手最常犯的错:写 s1.describe(s1),多传了一个。

12.2 __init__ 里能做的事

不只是赋值,什么都能做(校验、计算、默认值):

class Subject:
    """一个被试的实验数据。"""

    def __init__(self, sid, rt, acc=None):
        if not sid:
            raise ValueError("编号不能为空")
        self.sid = sid
        self.rt = float(rt)
        self.acc = acc if acc is not None else 0.0
        self.n_trials = 0                       # 计数器,从 0 开始

    def add_trial(self, correct):
        """记录一次试次。"""
        self.n_trials += 1
        return self


s = Subject("A01", 0.523)
s.add_trial(True).add_trial(False)              # 可以链式调用
print(s.n_trials)                               # 2
print(s.acc)                                    # 0.0

💡 raise ValueError(...) 是"主动报错":数据不对时当场炸掉,
而不是让它带着错误数据往下跑。这叫"快速失败"(fail fast),是好的习惯。

13 实例属性 vs 类属性(一个经典的坑)

属性可以定义在两个地方:

class Subject:
    species = "human"                    # ← 类属性:所有实例共享

    def __init__(self, sid):
        self.sid = sid                   # ← 实例属性:每个实例独有


s1 = Subject("A01")
s2 = Subject("A02")

print(s1.species, s2.species)            # human human   都能读
print(s1.sid, s2.sid)                    # A01 A02       各自的
print(Subject.species)                   # human         类上也能读

区别:

实例属性 类属性
定义位置 __init__ 里,self.x = ... 类里、方法外
归属 每个实例一份 所有实例共享一份
适合放什么 各自不同的数据(编号、反应时) 全体一样的常量(类别、单位)

13.1 ⚠️ 最大的坑:可变类属性

class Bad:
    trials = []                    # ⚠️ 可变对象当类属性!

    def add(self, t):
        self.trials.append(t)      # 注意:这里改的是"共享的那个列表"


a = Bad()
b = Bad()
a.add(1)
print(a.trials)      # [1]
print(b.trials)      # [1]      ← b 也被污染了!

这就是阶段 7 的"可变默认参数"坑的兄弟,同一个病。

正确写法:可变的东西永远在 __init__ 里创建。

class Good:
    def __init__(self):
        self.trials = []           # ✓ 每个实例一个自己的列表

    def add(self, t):
        self.trials.append(t)


a = Good()
b = Good()
a.add(1)
print(a.trials)      # [1]
print(b.trials)      # []       ← b 干净

⚠️ 铁律:类属性只放不可变的常量(字符串、数字、元组)。
列表、字典、集合永远放进 __init__。

这条规则和阶段 7 的"可变默认参数"是同一条,可变状态不能共享。

14 方法:挂在对象上的函数

方法就是"定义在类里的函数",第一个参数永远是 self。

class Subject:
    def __init__(self, sid, rts):
        self.sid = sid
        self.rts = list(rts)

    def mean_rt(self):
        """平均反应时。空列表返回 0。"""
        if not self.rts:
            return 0
        return sum(self.rts) / len(self.rts)

    def is_fast(self, threshold=0.8):
        """反应时是否快于阈值。"""
        return self.mean_rt() < threshold

    def __len__(self):
        """让 len(对象) 能工作。这是"魔术方法"。"""
        return len(self.rts)

    def __repr__(self):
        """让 print(对象) 输出可读内容,而不是 <object at 0x...>。"""
        return f"Subject({self.sid!r}, n={len(self.rts)})"


s = Subject("A01", [0.5, 0.6, 0.7, 0.8])
print(s.mean_rt())        # 0.65
print(s.is_fast())        # True
print(len(s))             # 4          ← 调用了 __len__
print(s)                  # Subject('A01', n=4)   ← 调用了 __repr__

💡 __len__ 和 __repr__ 叫"魔术方法"(dunder methods)。
前后各两个下划线。它们让你的对象能用 Python 的内置语法:
len()、print()、+、==、for……全都靠魔术方法。

阶段 13 会专门讲。

14.1 方法 vs 普通函数

普通函数 方法
定义 文件里任意位置 类里面
第一个参数 随意 必须是 self
调用 describe(s) s.describe()
能访问谁的数据 只有传入的参数 通过 self 访问整个对象

⚠️ 没有 self 的"方法"不是方法:
def describe(): 写在类里,调用 s.describe() 会报
TypeError: describe() takes 0 positional arguments but 1 was given
因为 Python 硬塞了一个 self 进去,而它不接受。


15 🔧 技能卡 12:类与实例到底是什么关系

技能卡 11 教你拆文件;这张卡教你拆"数据和行为",理解类和实例,是读懂一切 Python 库的前提。

15.1 决策一:我该用类,还是用字典/元组?

情况 用什么
只是一组固定的数据,不常变 元组(不可变)或字典
数据 + 一堆专门操作它的函数 类
需要状态(比如计数器、累积结果) 类
需要多个"同类但不同值"的东西 类

判断标准很简单:"我是不是总在做 xxx(data) 这样的调用?"
如果是,说明那些 xxx 应该变成 data.xxx()。

15.2 决策二:属性和方法该放哪?

class Subject:              ← 类是"模具"
    species = "human"       ← 类属性:全体共享、不变的常量

    def __init__(self, sid):
        self.sid = sid       ← 实例属性:每个实例独有
        self.rts = []        ← ⚠️ 可变的一律放这里,不放类属性

    def mean_rt(self):       ← 方法:需要用到 self 的东西
        return sum(self.rts) / len(self.rts)

15.3 决策三:怎么理解"类和实例"

最好的类比是"模具和零件":

概念 类比 代码
类 月饼模具 class Subject:
实例 压出来的每个月饼 Subject("A01", ...)
实例属性 每个月饼各自的馅 self.sid
类属性 模具的材质(所有月饼一样) species = "human"
方法 用模具能做的操作 def mean_rt(self)

一个类可以造出无限多个实例,每个属性值不同,但共享同一套方法。

验证一下:

class Subject:
    """一个被试。"""

    def __init__(self, sid):
        self.sid = sid

    def mean_rt(self):
        return 0.5


a = Subject("A01")
b = Subject("A02")

print(a.sid, b.sid)              # A01 A02   属性各自独立
print(type(a) is type(b))        # True      都是同一个类
print(a is b)                    # False     但是两个不同的对象

# 方法这里有个反直觉的地方:
print(a.mean_rt is b.mean_rt)    # False !每次访问"实例.方法"都会新建一个绑定方法对象
print(a.mean_rt is a.mean_rt)    # False !连同一个实例的两次访问都不相等
print(a.mean_rt.__func__ is b.mean_rt.__func__)   # True 底层函数确实只有一个
print(a.mean_rt.__func__ is Subject.mean_rt)      # True 就是类里定义的那个

💡 a.mean_rt 叫"绑定方法"(bound method):它是一个临时创建的小包装,
里面记住了两样东西:要调用的函数,和要当 self 传进去的 a。
所以调用时不用再传 self,它已经绑好了。

这就是"面向对象"的字面含义:把行为绑定到对象上。

⚠️ 所以别用 is 比较方法。要看"是不是同一个函数",
应该比较 __func__,或者干脆用 ==(对同一个类的方法,== 是 True)。

15.4 三条新手铁律

① 不可变的东西才放类属性

class A:
    KIND = "被试"        # ✓ 字符串,不可变
    COUNT = 0            # ✓ 数字,不可变(但要注意 += 会创建新的)
    items = []           # ✗ 列表!所有实例共享,会互相污染

② self 别忘了写

def mean_rt(self):       # ✓
def mean_rt():           # ✗ 调用时报参数错误

③ 每个类都写一句 docstring

class Subject:
    """一个被试的实验数据:编号 + 各试次反应时。"""

⭐ 读别人的库时,dir(obj) 是你的探测器(阶段 2 的技能卡):
dir(s) 列出所有属性和方法,立刻知道这个对象能干什么。


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

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

类 ↔ 认知科学里的"范畴"(category)和"图式"(schema),一个值得小心的类比。

16.1 编程侧的事实

类天然表达一种"类别—成员"结构:

编程 含义
Subject(类) 一个类别:"被试"这个东西
Subject("A01")(实例) 该类别下的一个具体成员
类属性 该类别的共同特征
实例属性 成员的个体差异
方法 该类事物能做的事

16.2 认知科学里的对应物

范畴化(categorization)是认知科学的核心议题之一:人怎么把无限多样的个体归入有限的类别?

编程概念 认知科学概念 关系
类 范畴 / 概念 "鸟"是一个范畴
实例 成员 / 样例 具体的那只麻雀
类属性 共享特征 有羽毛、会飞
方法 该范畴事物的典型行为 "鸟会飞"
继承(阶段 13) 层级分类 "麻雀"是"鸟"的子类

不只是表面相似。面向对象编程的很多术语(class、instance、inheritance)就是从认知/逻辑学借来的词。

16.3 ⚠️ 但必须非常小心的地方

理论上的"类"和认知上的"范畴"有一个根本差别:

编程的类 认知的范畴
边界 精确:要么是实例,要么不是 模糊:企鹅算不算"鸟"?
判据 充要条件:满足条件就是 典型性:麻雀比企鹅"更像鸟"
归属 二值:True/False 程度:有些成员比其他成员更典型

这正是"经典范畴理论"(classical view)在认知科学里被推翻的原因:
人脑的范畴不像程序里的类那样有清晰边界。

后来的原型理论(Rosch)、样例理论、理论-理论都在试图解释
"为什么典型性效应会存在",而这是"类"模型无法解释的。

⚠️ 诚实说明:
- "面向对象的概念借自认知/逻辑学"是编程史的事实(Simula/Smalltalk 的设计者明确受影响)。
- 但"所以人脑就是面向对象的"是一个错误的推论。
程序里的类比能帮你快速建立直觉,但它绝不能代替认知科学的经验研究。
上面那张"边界/判据/归属"的对照表正说明了这一点。
- 关于范畴化,请以 Rosch (1975)、Murphy The Big Book of Concepts 等文献为准。
- 用熟悉的结构去理解陌生的结构(类比推理)本身是认知科学的研究对象。
这一节希望你先用它,再审视它。


17 编程作业 S12

第 6 题答案:

class Bad:
    rts = []                    # ⚠️ 可变对象当类属性:所有实例共享同一个列表

    def add(self, rt):
        self.rts.append(rt)


a = Bad(); b = Bad()
a.add(1)
print(b.rts)      # [1]   ← 不是 []!b 被 a 污染了
print(a.rts is b.rts)   # True  ← 证实:是同一个列表对象

修正版:

class Good:
    def __init__(self):
        self.rts = []           # ✓ 每个实例一个自己的列表

    def add(self, rt):
        self.rts.append(rt)


a = Good(); b = Good()
a.add(1)
print(a.rts, b.rts)             # [1] []
print(a.rts is b.rts)           # False  ← 证实:各是各自的列表

同源坑:阶段 7 的"可变默认参数" def add(x, lst=[])。
本质完全相同:默认值/类属性只在定义时创建一次,被所有调用/实例共享。
规律:可变对象不能出现在"只执行一次"的位置(默认参数、类属性、模块级变量)。

第 10 题参考写法:

"""被试反应时数据的最小容器。"""


class RTData:
    """累积一个被试的反应时,提供常用统计量。"""

    def __init__(self, sid=""):
        self.sid = sid
        self.rts = []                 # ✓ 可变属性在 __init__ 里创建

    def add(self, rt):
        """记录一次反应时。非正数会被拒绝。"""
        rt = float(rt)
        if rt <= 0:
            raise ValueError(f"反应时必须是正数,收到 {rt}")
        self.rts.append(rt)
        return self                   # 返回 self 以支持链式调用

    def n(self):
        """试次数量。"""
        return len(self.rts)

    def mean(self):
        """平均反应时。无数据返回 0。"""
        if not self.rts:
            return 0
        return sum(self.rts) / len(self.rts)

    def median(self):
        """中位数反应时。无数据返回 0。"""
        if not self.rts:
            return 0
        s = sorted(self.rts)
        mid = len(s) // 2
        if len(s) % 2 == 1:
            return s[mid]
        return (s[mid - 1] + s[mid]) / 2

    def __len__(self):
        return len(self.rts)

    def __repr__(self):
        return f"RTData({self.sid!r}, n={len(self.rts)}, mean={self.mean():.3f})"


if __name__ == "__main__":
    d = RTData("A01")
    d.add(0.5).add(0.6).add(0.7)          # 链式调用
    assert d.n() == 3
    assert abs(d.mean() - 0.6) < 1e-9
    assert abs(d.median() - 0.6) < 1e-9
    assert len(d) == 3
    print(d)                              # RTData('A01', n=3, mean=0.600)

    # 空数据
    e = RTData()
    assert e.mean() == 0 and e.median() == 0 and e.n() == 0

    # 非法输入要被拒绝
    try:
        d.add(-1)
        raise AssertionError("应该报错但没报")
    except ValueError:
        pass                              # 预期行为

    # 两个实例互不干扰(第 6 题那个坑的反面教材)
    f = RTData("A02")
    f.add(9.9)
    assert d.n() == 3 and f.n() == 1, "实例之间串味了!"

    print("全部检查用例通过")

本阶段自评

  1. 数学:定积分和不定积分的本质区别是什么?为什么定积分没有 $+C$?
  2. 数学:牛顿-莱布尼茨公式是什么?为什么它让定积分变得可算?
  3. 数学:$\int_0^{\pi}\sin x\,dx$ 怎么算?符号上最容易错在哪一步?
  4. 数学:为什么 $\int_{-1}^{1}x^3dx = 0$?什么情况下能这样"不算"?
  5. 数学:微积分基本定理为什么叫"基本"?它建立了什么关系?
  6. 数学:$\int_0^{2\pi}\sin x\,dx = 0$,但曲线与 $x$ 轴围成的面积是多少?为什么不同?
  7. 数学:$\int_1^\infty \frac{1}{x^p}dx$ 什么时候收敛?分界线在哪?
  8. 编程:类和元组比,解决了哪三个问题?
  9. 编程:self 是什么?为什么调用 s.describe() 时不用传它?
  10. 编程:实例属性和类属性有什么区别?可变的东西为什么不能当类属性?
  11. 编程:__repr__ 和 __len__ 有什么用?它们为什么叫"魔术方法"?
  12. 技能:什么时候该用类,什么时候元组/字典就够了?判断标准是什么?
  13. 连接:程序里的"类"和认知科学里的"范畴",有哪三个根本差别?为什么不能混为一谈?

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

最后更新:2026-09-26