Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

函数、向量与矩阵

在导读里,我们让一条直线预测打印耗时:

y^=3x+20 \hat{y}=3x+20

给它 24 页,它就回答 92 秒。到这里,只有一个输入、一个输出,看起来还很轻巧。

现在给问题加一点变化:一份文档里既有黑白页,也有彩色页,怎么办?如果同时来了三份文档,要逐份写三遍公式吗?再多一台速度不同的打印机,又该怎样比较?

函数、向量和矩阵,正好能帮我们把这些事情说清楚。你可以把这一章看作一次整理:先把计算写成函数,再把相关的数字装进向量,最后把一批计算排进矩阵。每次引入一个新名字,都让它做一点实际的事。

本章需要的数学不超过一次函数、坐标和勾股定理。点积的角度解释会用到余弦;如果暂时忘了,也可以先看例子,稍后再回来。

1. 函数:给计算规则起个名字

同一条规则,可以反复使用

把导读里的模型记作:

f(x)=3x+20 f(x)=3x+20

ff 是函数的名字,xx 是输入。f(24)f(24) 的意思是“把 24 交给函数 ff 计算”,因此 f(24)=92f(24)=92。

函数规定了输入和输出的对应关系:每一个允许的输入,都有唯一的输出。这里允许的输入是文档页数,实际使用时应是非负整数;纸上画直线时,我们也可以把它扩展到实数,观察整条曲线。

这里的“唯一”说的是给定输入后,输出已经确定,并不要求不同输入一定得到不同输出。例如,一个总是返回 72.5 的基线,也是函数。

在 Python 里,可以这样写:

def predict(pages):
    return 3 * pages + 20

数学函数与这段程序讲的是同一件事。只是一般的程序函数还可能读写文件、修改状态;此刻我们专注于这种给定输入、计算输出的简单形式。

函数也不一定画成直线。例如,正方形的边长为 ss 时,面积函数是 h(s)=s2h(s)=s^2:边长从 2 变成 4,面积从 4 变成 16。输入翻倍,输出却变成了四倍。我们先从直线入手,是因为容易算、容易画;“输入经过一条规则得到输出”这个想法,适用于更丰富的关系。

输入在变,还是规则在变

如果换一台更慢的打印机,可能需要每页 4 秒;如果启动更慢,固定开销可能变成 40 秒。把两个数留成可调的参数,就得到:

fa,b(x)=ax+b f_{a,b}(x)=ax+b

下标 a,ba,b 提醒我们:这里其实有一整组函数。固定 a=3a=3、b=20b=20,才选中了其中一条。

  • 改变 xx:还是同一个模型,只是换了一份文档。
  • 改变 aa 或 bb:计算规则变了,也就是换了模型参数。

看看这几条线:

图 1:aa 决定页数增加时耗时涨得多快;bb 决定直线从多高的位置开始。图中不同线型对应不同参数,都是用于说明关系的假设。

导读中的训练,就是借助数据挑选参数。这一章先把参数设好,集中研究怎样组织计算,暂时不再训练新模型。

一个函数的输出,也能交给另一个函数

假如界面希望显示分钟,而模型输出秒,可以再写一个换算函数:

g(t)=t60 g(t)=\frac{t}{60}

tt 是秒数。先算 f(24)f(24),再把结果交给 gg:

g(f(24))=9260≈1.53 g(f(24))=\frac{92}{60}\approx1.53

这叫函数复合,也就是把两次计算接起来。程序里对应 g(f(24))。

顺序要看清楚:内层先算,外层后算。这里先预测秒数、再换算成分钟才符合含义。以后看到一层接一层的神经网络,也可以先从“上一层的输出,交给下一层”这个角度理解。

2. 向量:一份文档,不止一个数字

现在允许文档包含黑白页和彩色页。我们把每一页归到其中一类,不重复计数。一份有 10 页黑白、2 页彩色的文档,可以记成:

x=[102] \mathbf{x}= \begin{bmatrix} 10\\ 2 \end{bmatrix}

这就是一个向量:按约定顺序排好的一组数。粗体 x\mathbf{x} 表示整组数,x1x_1 和 x2x_2 表示其中的第一个、第二个分量。

顺序很重要。这里约定第一项是黑白页数,第二项是彩色页数。把它们交换,就变成了另一份文档。数字本身不会告诉程序自己代表什么,解释数字的约定也必须一起保留。

一个数,例如固定耗时 20,叫标量。上面的向量有两个分量,是一个二维向量。遇到 x∈R2\mathbf{x}\in\mathbb{R}^2,可以读成“x\mathbf{x} 是由两个实数组成的向量”;符号 R\mathbb{R} 表示实数集合。虽然页数只取非负整数,我们仍可以把这样的记录放进实数向量中讨论。

同一组数,也可以画成箭头

暂时放下页数,看看平面中的向量:

u=[32] \mathbf{u}= \begin{bmatrix}3\\2\end{bmatrix}

它可以表示“向右走 3 步,再向上走 2 步”。从原点出发,箭头就指向坐标 (3,2)(3,2)。箭头平移到别的位置,只要方向和长度不变,表示的仍是同一个位移向量。

向量既可以是一份特征记录,也可以是一段位移。后者很适合用来理解运算,但不要因此认为所有特征都必须有一个真实的空间方向。等到向量有几百个分量时,画不出来也不影响逐项计算。

相加与缩放,都从每个分量开始

再取一个向量 v=[1,2]T\mathbf{v}=[1,2]^{\mathsf T}。这里的上标 T\mathsf T 表示转置,把横着写的一行转成竖着的一列;这样可以在一句话中紧凑地写出列向量。

把两个向量相加,就是对应位置相加:

u+v=[3+12+2]=[44] \mathbf{u}+\mathbf{v} = \begin{bmatrix} 3+1\\2+2 \end{bmatrix} = \begin{bmatrix}4\\4\end{bmatrix}

从走路的角度看,先走 u\mathbf{u},再从它的终点走 v\mathbf{v},总位移就是 (4,4)(4,4)。

图 2:向量相加可以首尾相接。虚线箭头与实线橙色箭头代表同一个 v\mathbf{v},只是起点不同。

乘上一个标量,则让每个分量都乘它:

2u=[64],−u=[−3−2] 2\mathbf{u}= \begin{bmatrix}6\\4\end{bmatrix}, \qquad -\mathbf{u}= \begin{bmatrix}-3\\-2\end{bmatrix}

乘 2,箭头沿原方向变成两倍长;乘 −1-1,箭头掉头,长度不变。两个向量相减,也可以理解成加上反方向的向量。

在打印任务中,两份文档的页数向量相加,可以算出合计的黑白页数和彩色页数。但这不表示把两份任务合并后,总耗时一定等于原来两份耗时之和:固定开销是付一次还是两次,还要看实际怎样提交任务。

3. 点积:把几项贡献合成一个数

从页数到耗时

先假设每页黑白需要 3 秒,每页彩色需要 5 秒,每份文档还有 20 秒固定开销。这些是本章为便于计算设定的参数,并非从彩色打印数据训练出来的结果,也不代表真实设备的速度。

对于 10 页黑白、2 页彩色的文档:

y^=10×3+2×5+20=60 \hat{y}=10\times3+2\times5+20=60

预计耗时为 60 秒。先不看最后的 20,前面的计算就是“对应相乘,再全部相加”。这个操作叫点积。

把两个每页耗时装进权重向量 w=[3,5]T\mathbf{w}=[3,5]^{\mathsf T},就可以写成:

x⋅w=x1w1+x2w2=40 \mathbf{x}\cdot\mathbf{w} =x_1w_1+x_2w_2 =40

“权重”在这里描述每个特征对结果的影响:黑白页数每增加 1,预测增加 3 秒;彩色页数每增加 1,预测增加 5 秒。最后加上的固定项通常叫偏置,记作 bb。于是模型变成:

y^=x⋅w+b \hat{y}=\mathbf{x}\cdot\mathbf{w}+b

看一眼单位,也能帮我们检查这个式子:页数乘以每页耗时,得到的是秒。黑白页贡献 30 秒,彩色页贡献 10 秒,两项可以相加;再加上 20 秒固定开销,才是完整的预测耗时。这里的权重不只是两个数字,它们还带着“秒/页”的含义。

两个参数乘两个特征,只是开头。如果有 dd 个特征,规则仍然一样:

x⋅w=∑j=1dxjwj \mathbf{x}\cdot\mathbf{w} =\sum_{j=1}^{d}x_jw_j

∑\sum 是求和符号,意思是让 jj 从 1 走到 dd,把每一项 xjwjx_jw_j 加起来。向量必须有同样多的分量,并且对应位置的含义要对得上。

点积还有一个几何解释

回到刚才的箭头 u=[3,2]T\mathbf{u}=[3,2]^{\mathsf T} 和 v=[1,2]T\mathbf{v}=[1,2]^{\mathsf T}:

u⋅v=3×1+2×2=7 \mathbf{u}\cdot\mathbf{v} =3\times1+2\times2=7

用勾股定理,u\mathbf{u} 的长度为:

∥u∥=32+22=13 \|\mathbf{u}\| =\sqrt{3^2+2^2} =\sqrt{13}

两侧的双竖线表示这里使用的欧几里得长度,也叫 L2 范数。你可以先把它当作勾股定理在向量上的写法。

点积为什么会和角度扯上关系?先把两根箭头的起点放在一起,从 v\mathbf{v} 的终点向 u\mathbf{u} 所在的直线作垂线,垂足记作 PP。从原点到 PP 的这段路,就是 v\mathbf{v} 沿 u\mathbf{u} 方向留下的“影子”,数学上叫投影。

图 3:把橙色箭头投到蓝色箭头所在的直线上。虚线与蓝色方向垂直,绿色线段是投影;它比橙色箭头短。

图中的夹角记作 θ\theta。还记得直角三角形里的“邻边除以斜边”吗?橙色箭头是斜边,所以投影长度等于 ∥v∥cos⁡θ\|\mathbf{v}\|\cos\theta。再乘上蓝色箭头的长度,就得到点积。在通常的直角坐标系中,两个非零向量满足:

u⋅v=∥u∥ ∥v∥cos⁡θ \mathbf{u}\cdot\mathbf{v} =\|\mathbf{u}\|\,\|\mathbf{v}\|\cos\theta

这里的投影要带上方向:如果夹角超过 90∘90^\circ,垂足就落在 u\mathbf{u} 的反方向上,投影的有向长度为负。向量越朝着同一个方向,余弦越接近 1;垂直时余弦为 0;指向相反方向时余弦为 −1-1。因此,点积同时受到长度和方向影响。

回到图中的两个向量,点积是 7,蓝色箭头的长度是 13\sqrt{13},所以绿色投影的长度为:

u⋅v∥u∥=713≈1.94 \frac{\mathbf{u}\cdot\mathbf{v}}{\|\mathbf{u}\|} =\frac{7}{\sqrt{13}}\approx1.94

图形与数字说的是同一件事:v\mathbf{v} 长约 2.24,沿着 u\mathbf{u} 的方向看,只留下了约 1.94。

选读:对应相乘再相加,为什么会得到“长度乘余弦”?

如果还记得高中学过的余弦定理,可以把这两种写法接起来。把两个非零平面向量的起点放在原点,记终点间的距离为 dd,夹角为 θ\theta。

先用坐标算距离,并把平方展开:

d2=(u1−v1)2+(u2−v2)2=(u12+u22)+(v12+v22)−2(u1v1+u2v2)=∥u∥2+∥v∥2−2(u⋅v) \begin{aligned} d^2&=(u_1-v_1)^2+(u_2-v_2)^2\\ &=(u_1^2+u_2^2)+(v_1^2+v_2^2)\\ &\quad-2(u_1v_1+u_2v_2)\\ &=\|\mathbf{u}\|^2+\|\mathbf{v}\|^2 -2(\mathbf{u}\cdot\mathbf{v}) \end{aligned}

再把两根箭头和连接终点的线段看成一个三角形。根据余弦定理,同一个距离也满足:

d2=∥u∥2+∥v∥2−2∥u∥ ∥v∥cos⁡θ \begin{aligned} d^2&=\|\mathbf{u}\|^2+\|\mathbf{v}\|^2\\ &\quad-2\|\mathbf{u}\|\,\|\mathbf{v}\|\cos\theta \end{aligned}

两种算法求的是同一个 d2d^2。消去相同的长度平方项,就得到前面的点积公式。两根箭头同向或反向时,三角形退化成线段,代入 cos⁡θ=1\cos\theta=1 或 −1-1,等式仍成立。

所以,坐标里的“对应相乘再相加”和图形里的“长度与方向”,其实在描述同一个量。暂时忘了余弦定理也没关系:先会用具体向量核对,日后再回来补上这一步。

取几个容易核对的例子:

与 u=[3,2]T\mathbf{u}=[3,2]^{\mathsf T} 比较的向量点积方向关系
[3,2]T[3,2]^{\mathsf T}13同向
[−2,3]T[-2,3]^{\mathsf T}0垂直
[−3,−2]T[-3,-2]^{\mathsf T}−13-13反向

如果只想比较方向,就可以除掉两个长度,得到余弦相似度:

cos⁡θ=u⋅v∥u∥ ∥v∥ \cos\theta= \frac{\mathbf{u}\cdot\mathbf{v}} {\|\mathbf{u}\|\,\|\mathbf{v}\|}

零向量没有方向,也不能放进这个除法。对于其他向量,点积大也未必意味着方向更接近:把一个向量放大十倍,就会让点积也放大十倍,方向却完全没变。

以后讨论文本向量和检索时,我们会再遇到这些量。眼下先记住,它们是计算工具;特征的单位、尺度和表示方式,会影响“远近”与“相似”的含义。把米换成厘米,数字扩大百倍,实际物体却并没有走远。

4. 矩阵:把一批任务排在一起

一行一份文档,一列一种特征

假设现在有三份文档:

文档黑白页数彩色页数
第一份102
第二份46
第三份80

把表格中的数字保留下来,就得到矩阵:

X=[1024680] X= \begin{bmatrix} 10&2\\ 4&6\\ 8&0 \end{bmatrix}

矩阵就是按行、列排列的数。 这个矩阵有 3 行、2 列,形状记作 3×23\times2。在本章的数据约定中,行数告诉我们有多少条样本,列数告诉我们每条样本有多少个特征。

记住“先行、后列”,再看 X2,1=4X_{2,1}=4 就很自然了:它是第 2 行、第 1 列,也就是第二份文档的黑白页数。这里数学下标从 1 开始,Python 列表下标从 0 开始,所以代码对应的是 X[1][0]。

我们此前把单份文档写成列向量 x\mathbf{x},这里却把它放在了矩阵的一行。两者不矛盾:这一行写的是 xT\mathbf{x}^{\mathsf T}。转置只交换行列位置,不改变每个数代表的内容。

例如,整个矩阵转置后:

XT=[1048260] X^{\mathsf T}= \begin{bmatrix} 10&4&8\\ 2&6&0 \end{bmatrix}

形状从 3×23\times2 变成 2×32\times3。现在每一列是一份文档,每一行是一种特征。无论选哪种排法,后面的计算都要遵守同一套约定。

矩阵乘向量,就是每一行做一次点积

给三份文档都用同一组权重 w=[3,5]T\mathbf{w}=[3,5]^{\mathsf T}。逐行计算,得到:

Xw=[10×3+2×54×3+6×58×3+0×5]=[404224] X\mathbf{w} = \begin{bmatrix} 10\times3+2\times5\\ 4\times3+6\times5\\ 8\times3+0\times5 \end{bmatrix} = \begin{bmatrix}40\\42\\24\end{bmatrix}

这就是矩阵与向量相乘。每一行提供一份文档的特征,与权重做一次点积,于是每一行得到一个数。

最后,每个结果都要加一次 20 秒固定开销:

y^=[40+2042+2024+20]=[606244] \hat{\mathbf{y}} = \begin{bmatrix} 40+20\\42+20\\24+20 \end{bmatrix} = \begin{bmatrix}60\\62\\44\end{bmatrix}

三份文档分别预测为 60、62、44 秒。这里算的是每份文档单独作为一次任务时的耗时,没有把排队等待或合并打印算进去。

还记得前面说过,“页数可以相加,耗时却未必直接相加”吗?现在就能算清楚了。把前两份文档合成一个任务,共有 14 页黑白、8 页彩色。沿用同一条预测规则,只付一次固定开销:

14×3+8×5+20=102 14\times3+8\times5+20=102

分开提交时,预测耗时之和是 60+62=12260+62=122 秒;合成一个任务后,预测为 102 秒。少掉的 20 秒,恰好是省下来的一次固定开销。页数向量相加没有问题,但算耗时之前,还得说清楚我们在处理几次任务。

如果希望写成一句紧凑的公式,可以用 1=[1,1,1]T\mathbf{1}=[1,1,1]^{\mathsf T} 表示全为 1 的向量:

y^=Xw+b1 \hat{\mathbf{y}}=X\mathbf{w}+b\mathbf{1}

b1b\mathbf{1} 就是 [20,20,20]T[20,20,20]^{\mathsf T}。这样相加的两个对象都是三项列向量,每一项的固定开销都清清楚楚。

再检查一下形状:XX 是 3×23\times2,w\mathbf{w} 作为列向量是 2×12\times1,输出是 3×13\times1。中间的两个 2 对应同样的两种特征;它们必须匹配,逐项相乘才有意义。

5. 矩阵乘矩阵:一次比较两台打印机

现在多了一台打印机。第一台仍然使用权重 [3,5]T[3,5]^{\mathsf T},第二台假设使用 [2,4]T[2,4]^{\mathsf T}。把两组权重并排放好:

W=[3254] W= \begin{bmatrix} 3&2\\ 5&4 \end{bmatrix}

这里,每列是一台打印机的权重。第一行对应黑白页,第二行对应彩色页。别因为它恰好有两行两列,就忽略行、列各自的含义。

我们要把三份文档分别交给两套规则计算。结果的第 1 行、第 2 列,应该是“第一份文档在第二台打印机上的加权耗时”:

10×2+2×4=28 10\times2+2\times4=28

用同样的办法填完其他位置:

XW=[402842322416] XW= \begin{bmatrix} 40&28\\ 42&32\\ 24&16 \end{bmatrix}

结果中的每一项,都是左边的一行与右边的一列做点积。 这就是矩阵乘法的计算规则。

不妨用手指沿着下面的图走一遍:先选中第一份文档,再找到第二台打印机,黑白页配黑白页的耗时,彩色页配彩色页的耗时。算出的 28,落在结果表中这份文档与这台打印机相交的位置。

图 4:选一行、配一列,就能填好结果中的一格。底部其余数字用同样的方法算出,暂时都没有加固定开销。

为什么非得“一行配一列”?因为我们把一份文档的特征横着排,把一台打印机的权重竖着排。两边都按“黑白、彩色”的顺序,对应位置才是一对可以相乘的数。矩阵乘法把刚才逐份、逐台的点积统一写了出来。

若左边有 nn 份样本、每份 dd 个特征,右边有 kk 组权重,那么:

X⏟n×dW⏟d×k=Z⏟n×k \underbrace{X}_{n\times d} \underbrace{W}_{d\times k} = \underbrace{Z}_{n\times k}

这里 ZZ 是乘积的名字。共同的 dd 是每次点积要计算的项数,结果则保留了 nn 份样本和 kk 个输出。用下标写,就是:

Zij=∑ℓ=1dXiℓWℓj Z_{ij}=\sum_{\ell=1}^{d}X_{i\ell}W_{\ell j}

ii 选左边的一行,jj 选右边的一列,ℓ\ell 沿着共同的特征逐项走过去。看不顺眼时,先回到刚才的 10×2+2×410\times2+2\times4,它就是这个式子的一个具体位置。

还差固定开销。假设第一台为 20 秒,第二台为 30 秒,就给每一行的第一列加 20、第二列加 30,得到:

Y^=[605862624446] \hat{Y}= \begin{bmatrix} 60&58\\ 62&62\\ 44&46 \end{bmatrix}

于是,这个简单模型认为第一份文档在第二台上更快,第二份一样快,第三份在第一台上更快。所有数字都来自设定好的规则,目前没有真实数据来说明两台设备是否真的如此。

在一些数值计算库里,把同一组偏置自动加到每一行的做法会用到广播。它是一套明确的形状规则,并不意味着任意两个不同形状的数组都可以相加。本章代码会把逐行加偏置的过程直接写出来。

别把两种乘法混在一起

向量 [3,2][3,2] 和 [1,2][1,2] 对应位置相乘,得到的是 [3,4][3,4];做点积,则还要加起来,得到 7。前一种叫逐元素乘法,结果中保留了每个位置;后一种把它们合成了一个数。

矩阵也一样:逐元素乘法通常要求同样的形状,而矩阵乘法要求左边的列数等于右边的行数。具体库可能额外支持广播,使用时仍需查清楚约定。

还有一个容易踩的坑:矩阵乘法通常不能交换顺序。本例 XWXW 能算,WXWX 却连形状都对不上:2×22\times2 右乘 3×23\times2,中间的 2 和 3 不相等。即便两种顺序都能算,结果也未必相同。下一节会看到,顺序其实对应“先做哪一步”。

6. 矩阵还能改变一张方格纸

刚才把矩阵看成表格,是为了管理数据。现在换个角度:把一个矩阵看成作用在向量上的函数。

取:

A=[2101] A= \begin{bmatrix} 2&1\\ 0&1 \end{bmatrix}

给它一个平面向量 p=[x,y]T\mathbf{p}=[x,y]^{\mathsf T},它会输出:

Ap=[2x+yy] A\mathbf{p} = \begin{bmatrix} 2x+y\\ y \end{bmatrix}

第二个坐标保持不变;第一个坐标先加倍,再加上原来的第二个坐标。例如:

A[12]=[42] A\begin{bmatrix}1\\2\end{bmatrix} = \begin{bmatrix}4\\2\end{bmatrix}

如果把方格纸上的每个点都这样变换,原来的正方形会变成平行四边形:

图 5:蓝色和橙色箭头分别表示水平、竖直方向的单位向量。矩阵 AA 把它们送到 (2,0)(2,0) 和 (1,1)(1,1);绿色点从 (1,2)(1,2) 移到 (4,2)(4,2)。

这个变换有一个很方便的观察方法。向量 [x,y]T[x,y]^{\mathsf T} 可以理解成“xx 份水平单位向量,加上 yy 份竖直单位向量”。变换之后:

A[xy]=x[20]+y[11] A\begin{bmatrix}x\\y\end{bmatrix} = x\begin{bmatrix}2\\0\end{bmatrix} + y\begin{bmatrix}1\\1\end{bmatrix}

右边那两个向量,恰好就是 AA 的两列。矩阵的每一列,告诉我们对应的单位向量被送到了哪里。 这就是理解矩阵几何意义的一条捷径。

矩阵乘向量给出的是线性变换。它保留向量相加和标量缩放的关系:

A(u+v)=Au+AvA(cu)=c(Au) \begin{aligned} A(\mathbf{u}+\mathbf{v})&=A\mathbf{u}+A\mathbf{v}\\ A(c\mathbf{u})&=c(A\mathbf{u}) \end{aligned}

cc 是一个标量。因此,“先把两段位移加起来,再变换”和“分别变换,再相加”会得到同一结果。线性变换也一定把零向量送回零向量。

如果在变换后再加一个固定向量,让整张图平移,就得到仿射变换。严格地说,带有非零固定项的 f(x)=ax+bf(x)=ax+b 也是仿射函数;机器学习里仍习惯把带偏置的这类预测方法称为线性模型。两个名字出现在不同语境中,不必因此怀疑前面的直线。

再想想函数复合:先做矩阵 AA 表示的变换,再做矩阵 DD 表示的变换,可以写成 D(Ap)=(DA)pD(A\mathbf{p})=(DA)\mathbf{p}。右边的 AA 先作用,这和 g(f(x)) 从内向外计算是同样的顺序。练习里,我们会亲手比较换序前后的结果。

7. 从形状看懂张量

一份文档有两个数字,三份文档排成两条轴的表格。如果数据是一批彩色图片,情况又会怎样?

一张图片有高度、宽度,每个位置还有红、绿、蓝三个通道。再把多张图片放在一起,就需要更多条轴来定位一个数字。

在机器学习代码里,我们通常用张量来称呼这种规则排列的多维数组。本章先采用这个计算上的理解;不需要现在就掌握更一般的数学定义。

数据一种形状写法每条轴代表什么
一个标量()没有数组轴
两个分量的一维数组(2,)特征
三份文档,每份两个特征(3, 2)样本、特征
八张 32 × 32 的 RGB 图片(8, 32, 32, 3)样本、高度、宽度、颜色通道

括号中的数字依次告诉我们每条轴有多长;(2,) 中的逗号表示这个形状只有一条轴。

这里有两个“维”容易混淆:一个有 100 个分量的向量,可以叫 100 维向量;保存它的一维数组,却只有一条轴。前者说有多少个坐标分量,后者说要用几个索引才能取到一个数。

还有,纸上的列向量和代码里的一维数组也要区分。长度为 2 的一维数组形状是 (2,),显式的列矩阵形状是 (2, 1),行矩阵则是 (1, 2)。它们都存两个数,但在数组运算中并不总能互换。后面的标准库代码用一维列表保存向量,行列含义由我们调用的运算决定。

不同程序也可能把图片通道放在前面,使用 (8, 3, 32, 32)。所以,看到一个形状时,不只要数数字,还要问:这一条轴究竟代表什么?

以后遇到形状错误,可以按三步查:先解释每条轴的含义,再核对运算要求,最后检查结果应该保留哪些轴。不要仅仅为了让程序跑通,就随意交换行列或重排元素。

8. 用几十行 Python 把它们串起来

运行方式

下面的脚本只使用 Python 标准库,Python 3.9 或更高版本即可运行。

如果已经下载本书仓库,在根目录执行:

python3 src/examples/vectors_matrices.py

也可以单独下载脚本,在保存它的目录执行:

python3 vectors_matrices.py

部分电脑上的命令是 python 或 py,换成自己的可用命令即可。

先看懂三个小步骤

dot 做点积,matvec 让矩阵的每一行做一次点积,matmul 再把“每行配每列”的计算展开。

matmul 里的三个循环和公式一一对应:i 选择结果的行,j 选择列,k 沿共同的特征维度,把乘积累加起来。这里的 shape 是我们自己写的函数,用来检查二维列表是否为矩形,并返回行数、列数。

zip 会把两个列表的元素逐项配对,但遇到较短的列表就停止,所以 dot 会先检查分量个数,避免漏掉分量。这里比较的是 len 返回的元素数量,不是几何上的向量长度。列表的 + 在 Python 中表示拼接,* 与整数一起使用表示重复;它们不会自动替我们完成这里的向量加法和矩阵乘法。

"""第一章示例:Python 3.9+,只使用标准库。参数均为教学设定。"""

from math import sqrt


JOBS = [[10, 2], [4, 6], [8, 0]]  # 每行依次是黑白页数、彩色页数
WEIGHTS = [3, 5]                 # 两类页面分别需要的秒数
BIAS = 20                       # 每份文档的固定开销
MODEL_WEIGHTS = [[3, 2], [5, 4]]  # 每列对应一台打印机
MODEL_BIASES = [20, 30]
U = [3, 2]
V = [1, 2]
TRANSFORM = [[2, 1], [0, 1]]


def linear(pages, a=3, b=20):
    return a * pages + b


def dot(u, v):
    if len(u) != len(v):
        raise ValueError("点积要求两个向量的分量数相同")
    return sum(left * right for left, right in zip(u, v))


def shape(matrix):
    if not matrix or not matrix[0]:
        raise ValueError("这个示例只处理非空矩阵")
    columns = len(matrix[0])
    if any(len(row) != columns for row in matrix):
        raise ValueError("矩阵的每一行必须一样长")
    return len(matrix), columns


def matvec(matrix, vector):
    _, columns = shape(matrix)
    if columns != len(vector):
        raise ValueError("矩阵的列数必须等于向量的分量数")
    return [dot(row, vector) for row in matrix]


def matmul(left, right):
    rows, inner = shape(left)
    right_rows, columns = shape(right)
    if inner != right_rows:
        raise ValueError("左矩阵的列数必须等于右矩阵的行数")
    result = []
    for i in range(rows):
        row = []
        for j in range(columns):
            value = 0
            for k in range(inner):
                value += left[i][k] * right[k][j]
            row.append(value)
        result.append(row)
    return result


def main():
    seconds = linear(24)
    print(f"24 页:{seconds} 秒,约 {seconds / 60:.2f} 分钟")
    print("u + v =", [a + b for a, b in zip(U, V)])
    print("2u =", [2 * value for value in U])
    print("u · v =", dot(U, V))
    u_length = sqrt(dot(U, U))
    v_length = sqrt(dot(V, V))
    print(f"u 的长度:{u_length:.3f}")
    print(f"v 沿 u 方向的投影长度:{dot(U, V) / u_length:.3f}")
    print(f"u 与 v 的余弦相似度:{dot(U, V) / (u_length * v_length):.3f}")

    print("X 的形状:", shape(JOBS))
    weighted = matvec(JOBS, WEIGHTS)
    print("Xw =", weighted)
    print("一台打印机的预测(秒):", [value + BIAS for value in weighted])

    raw = matmul(JOBS, MODEL_WEIGHTS)
    predicted = [[value + MODEL_BIASES[j] for j, value in enumerate(row)]
                 for row in raw]
    print("XW =", raw)
    print("两台打印机的预测(秒):", predicted)
    print("A 作用于 [1, 2]:", matvec(TRANSFORM, [1, 2]))


if __name__ == "__main__":
    main()

对照手算结果

运行后应得到:

24 页:92 秒,约 1.53 分钟
u + v = [4, 4]
2u = [6, 4]
u · v = 7
u 的长度:3.606
v 沿 u 方向的投影长度:1.941
u 与 v 的余弦相似度:0.868
X 的形状: (3, 2)
Xw = [40, 42, 24]
一台打印机的预测(秒): [60, 62, 44]
XW = [[40, 28], [42, 32], [24, 16]]
两台打印机的预测(秒): [[60, 58], [62, 62], [44, 46]]
A 作用于 [1, 2]: [4, 2]

这里没有随机过程,也没有训练步骤。代码是在执行我们设定的函数和矩阵运算。u 的长度只显示了三位小数;13\sqrt{13} 本身并不恰好等于 3.606。

第一次跑通后,试着把第一份文档的彩色页数从 2 改为 3。先不运行,猜猜两台打印机的预测各增加多少秒,再用程序验证。这样的改动很小,却能检查你是否真的把“特征、权重和输出”连在了一起。

我们用循环,是为了看清每个数怎样算出来。以后处理大批数据时,会把相同的运算交给数值库完成,省去手写循环,也更方便利用高效的计算实现。

9. 自己动手,把符号变成熟悉的东西

  1. 对于 fa,b(x)=ax+bf_{a,b}(x)=ax+b,把输入从 10 改为 12,和把 aa 从 3 改为 4,有什么不同?取 a=3,b=20a=3,b=20 时,f(12)f(12) 是多少?
  2. 一份文档有 6 页黑白、3 页彩色,按第一台打印机的参数,预测耗时是多少?如果把这两个特征的位置写反了,却没有调整权重,会得到多少?
  3. 已知 u=[3,2]T\mathbf{u}=[3,2]^{\mathsf T}、v=[1,2]T\mathbf{v}=[1,2]^{\mathsf T},求 u−v\mathbf{u}-\mathbf{v}、它的长度,以及 u\mathbf{u} 与 v\mathbf{v} 的逐元素乘积。
  4. XX 的形状为 5×35\times3,WW 为 3×43\times4,那么 XWXW 是什么形状?若数据按本章的方式排列,各个数字代表什么?
  5. 为什么在矩阵 XX 的右边乘 w=[3,5]T\mathbf{w}=[3,5]^{\mathsf T} 可以计算,却不能直接右乘三个分量的权重向量?
  6. 取 D=[1002]D=\begin{bmatrix}1&0\\0&2\end{bmatrix},它会把竖直坐标加倍。使用本章的 AA 和 p=[1,2]T\mathbf{p}=[1,2]^{\mathsf T},分别算 A(Dp)A(D\mathbf{p}) 与 D(Ap)D(A\mathbf{p})。结果相同吗?
  7. 把脚本中的第一份文档改成 [10, 3]。先猜两台打印机的预测,再运行核对。接着故意让 dot([1, 2], [3]) 运行一次,看看为什么要先检查分量个数。
  8. 一批图片的形状是 (8, 32, 32, 3),若按本章的轴顺序解释,共存了多少个数字?它有几条轴?
  9. 把 v=[1,2]T\mathbf{v}=[1,2]^{\mathsf T} 放大十倍,u\mathbf{u} 保持不变。点积、投影长度和余弦相似度各会怎样变化?先看图 3 想一想,再用代码中的 dot 和 sqrt 核对。
展开参考答案与代码提示
  1. 改 xx 是用同一条规则处理新输入;改 aa 则改变了规则。f(12)=3×12+20=56f(12)=3\times12+20=56。
  2. 正确顺序下,6×3+3×5+20=536\times3+3\times5+20=53 秒。只交换特征、保留原权重,会得到 3×3+6×5+20=593\times3+6\times5+20=59 秒。分量个数虽然相同,含义却错位了。
  3. u−v=[2,0]T\mathbf{u}-\mathbf{v}=[2,0]^{\mathsf T},长度为 2;逐元素乘积为 [3,4]T[3,4]^{\mathsf T},与点积 7 不同。
  4. 形状是 5×45\times4。5 表示样本数,3 表示输入特征数,4 表示每个样本要得到的输出数。
  5. XX 每一行只有两个特征,无法与三个权重逐项配对。新增特征时,要同时提供数据中相应的一列,并明确新权重的含义。
  6. Dp=[1,4]TD\mathbf{p}=[1,4]^{\mathsf T},再乘 AA 得 [6,4]T[6,4]^{\mathsf T};Ap=[4,2]TA\mathbf{p}=[4,2]^{\mathsf T},再乘 DD 得 [4,4]T[4,4]^{\mathsf T}。两者不同,说明换序会改变结果。
  7. 第一台增加 5 秒,变为 65 秒;第二台增加 4 秒,变为 62 秒。分量数不等时,dot 会抛出 ValueError;如果去掉检查,zip 只配对第一项,会把不完整的计算误当作结果。
  8. 一共有 8×32×32×3=245768\times32\times32\times3=24576 个数字,按四条轴组织。
  9. 点积从 7 变为 70,沿 u\mathbf{u} 方向的投影长度也扩大十倍,从约 1.94 变为约 19.41。余弦相似度仍约为 0.868,因为分子中的点积和分母中的 ∥v∥\|\mathbf{v}\| 同时扩大十倍,两个向量的方向都没变。

可以在脚本旁新建一个文件,补上这段小实验:

from vectors_matrices import TRANSFORM, matmul, matvec

D = [[1, 0], [0, 2]]
p = [1, 2]

print(matvec(TRANSFORM, matvec(D, p)))  # [6, 4]
print(matvec(D, matvec(TRANSFORM, p)))  # [4, 4]
print(matmul(TRANSFORM, D))            # [[2, 2], [0, 2]]
print(matmul(D, TRANSFORM))            # [[2, 1], [0, 2]]

进阶选做:用循环写一个 transpose 函数,把本章的 3×23\times2 矩阵变成 2×32\times3。用已经知道的结果核对,再试着连续转置两次。你应该回到原矩阵,而且每个数字的位置也都恢复了。

10. 下一步,怎样找到更好的参数

一开始,我们只有“页数乘 3,再加 20”。现在,同样的思路已经可以写成一个处理多种特征、多份样本的模型。函数说明怎样从输入得到输出,向量把相关的数组织起来,矩阵让一批相似的计算有了清楚的结构。

接下来,还有一个在导读中暂时靠枚举解决的问题:参数稍微改一点,损失会怎样变?能不能顺着让损失变小的方向走,而不必逐个试遍?

这正是下一章导数、梯度与优化要讨论的问题。继续往前读之前,可以先用本章的代码改改输入和权重,让这些运算真正变得熟悉。

如果想多看一些图形解释,可以从 MIT 线性代数课程的几何入门继续探索。想对照机器学习中常见的符号,则可以选读 Goodfellow、Bengio 和 Courville 所著《深度学习》的第 2 章:线性代数,先看 2.1 和 2.2 节即可;后面的逆矩阵、特征分解等内容可以暂时放下。