Numpy与数据结构

维数与运算

Posted by Welt Xing on April 26, 2021

引言

NumPy是Python语言的一个扩展程序库。支持高阶大量的维度数组与矩阵运算,此外也针对数组运算提供大量的数学函数库。由于Numpy是用C编写,因而它弥补了Python语言本身速度慢的缺点。但随着深入使用Numpy,我们发现这样的一个问题,作为Numpy的核心数据结构ndarray,它的维度对于初学者来说难以捉摸,虽然在表示上无伤大雅,但是一旦涉及到矩阵的运算,则会引发难以琢磨的错误;此外,Numpy库同时支持向量乘和标量乘,不同算法会导出不一样的结果,同样令人疑惑,本文是我由于在实现感知机算法时遇到相关问题,有感而发,从而做出的整理,因此对问题的考虑和解释都是针对机器学习任务而展开,必然无法完全覆盖Numpy的内容.

Numpy的维数问题

Numpy数组(ndarray)的维数

我们利用Numpy中的array方法将一个Python数据结果转换为Numpy数组,我们来看看下面的例子:

import numpy as np
a = np.array(1)
b = np.array([1, 2])
c = np.array([[1, 2]])

>>> a.shape
()
>>> b.shape
(2,)
>>> c.shape
(1, 2)

我们用线性代数的视角去解释输出:a一个数的数组化,理解称一个实数,一个实数是没有维度的(0维),所以a.shape是一个空元组;b是一个列表的数组化,理解为一个向量,描述一个向量用的是维数,所以b.shape是一个单元素元组,意思就是“2维向量”;c将一个嵌套列表数组化,我们可以将其理解为一个矩阵,嵌套数组中的每个元素(也就是一个列表),都看作是一个行向量,从而c.shape(1, 2),也就是一个$1\times 2$矩阵. 我们当然可以继续往上叠加:

d = np.array([[[1, 2]]])
>>> d.shape
(1, 1, 2)

这样似乎可以用张量解释,但使用起来显然过于复杂,不如不用. 我们可以发现,在Numpy中,实数,一维向量和$1\times 1$矩阵是三个完全不同的东西. 下面我们只对这三个有形象数学意义的数学结构进行解析.

ndarray的维数变换

假如我们在一个程序中规定所有运算都必须是以向量(shape=(n,))的形式进行,如果我们想让一个实数提升到一个向量,或者是让一个$1\times N$矩阵降为一个$N$维向量,就要涉及到维数的变换. 前一个问题比较简单:

a = 2
na = np.array(a)
>>> na.shape
()
>>> np.array([na]).shape
(1,)

我们就可以将其提升为一个1维向量,而对于矩阵降为向量我们也有办法:考虑二维列表元素的调用方式:l[0][1]表示第一个列表的第二个元素,受此启发,我们可以这样:

a = [[1, 2]]
na = np.array(a)
>>> va = na[0]
>>> va.shape
(2,)

我们用此法成功将矩阵降为向量. 而更推荐的做法是,使用Numpy为我们提供的升维降维函数:expand_dimssqueeze.

升维变换

expand_dims函数的参数:数组a和“轴”参数axis,我们还是通过例子讲解.

先看实数升为向量:

from numpy import array, expand_dims
a = array(1) # 实数
>>> expand_dims(a, 0).shape
(1)
>>> eppand_dims(a, 1).shape
# 触发AxisError异常

再看向量升为矩阵:

from numpy import array, expand_dims
a = array([1, 2]) # 向量
>>> expand_dims(a, 0).shape
(1, 2)
>>> eppand_dims(a, 1).shape
(2, 1)
>>> expand_dims(a, 2).shape
# 触发AxisError异常

我们如何理解轴参数(axis)?,一个比较简单的解释是升维的方向:实数升到矩阵,只能是一个方向,一种可能,也就是$a\to[a]$,但对于一个$n$维向量,可以有$n\times1$矩阵和$1\times n$矩阵两种升维方式,两个方向:横着升($1\times n$)和竖着升($n\times 1$). 为了不混淆,我的记法是”1”是竖着的,所以axis=1的时候形成一个$n\times1$的竖矩阵.

降维变换

和升维一样,squeeze函数的参数也是数组和轴参数,但不是完全可逆的运算:

from numpy import array, expand_dims
a = array([[1, 2]])
b = squeeze(a)
c = squeeze(b)
>>> b.shape # 由于降维结果只能是一个向量,所以不需要指定axis
(2,)
>>> c.shape
(2,)

可以发现,squeeze只能将ndarray降维成向量形式.

ndarray的下标和维数

前面我们已经对ndarray做过下标运算了,我们再来看一下:

from numpy import array
a = array([[1, 2]])
>>> a.shape
(1, 2)
>>> a[0].shape # 第1行的维数,也就是向量维数
(2,)
>>> a[0][0].shape # 第一行第一列的维数,也就是实数的维数
()

也就是说,对ndarray做一次下标运算([]),维数就会降低一次,因此,虽然ndarray中有A[i][j]A[i, j]等价的语法糖,我还是不支持这一做法.

Numpy矩阵(np.matrix)的维数问题

Numpy中除了ndarray意外还有一种称作matrix的数据结构,是对数学中的矩阵行为的仿真.

m1 = matrix(2)
m2 = matrix([1, 2])
m3 = matrix([[1, 2]])
>>> m1
matrix([[2]])
>>> m2
matrix([[1, 2]])
>>> m3
matrix([[1, 2]])
>>> m1.shape
(1, 1)
>>> m2.shape
(1, 2)
>>> m3.shape
(1, 2)

我们可以发现无论是实数,列表(向量)还是嵌套列表(矩阵),matrix都会直接将它们提升成矩阵形式. 因此我们不再需要考虑升维降维的问题.

matrix的下标和维数

类似的,我们来考察下标运算与维数的关系:

m1 = matrix([1, 2])
>>> m1[0].shape
(2, 2)
>>> m1[0]

区别开始出现了,对matrix的单次下标运算,并不能和ndarray一样改变维数,以此类推,对于上面的m1m1[0][0]仍会是matrix([[1, 2]]).

那么我们如何取得矩阵的第i行和第j列元素呢? 我们使用下面这种写法:

m = matrix([1, 2])
>>> m[0, 0]
1
>>> m[0, 0].shape
()

说明这样返回的就是一个实数.

Numpy的计算问题

在机器学习中,我们经常会遇到向量乘法:

\[\textbf{w}^\top\cdot\textbf{x}=\sum_{i=1}^n\textbf{w}_i\textbf{x}_i\]

但Numpy中既有上面的乘法,也存在这标量乘法:

\[\textbf{w}\cdot\textbf{x}=\begin{bmatrix} \textbf{w}_1\textbf{x}_1&\cdots&\textbf{w}_n\textbf{x}_n \end{bmatrix}\]

我们想弄清楚什么情况会用到向量/标量乘法,包括其他的运算,比如加减和除法.

ndarray的五则运算

数组的四则标量运算

我们先从ndarray开始,在ndarray中,加减法都是标量的,先看向量$\pm$实数和矩阵$\pm$实数,它们都满足“交换律”:

from numpy import array
>>> a = array([1, 2])
>>> a + 1
array([2, 3])
>>> b = array([[1, 2]])
>>> b + 1
array([[2, 3]])

再来看看向量$\pm$向量,根据上面的标量加减,我们可以猜测,只要向量长度相同,就可以进行标量加减:

>>> a = np.array([1, 2])
>>> a + 2 * a
array([3, 6])
>>> a + np.array([2, 3, 3])
ValueError

接着是向量和矩阵的加减法:

>>> a = np.array([1, 2])   # vector
>>> A = np.array([[1, 2]]) # matrix
>>> a + A
array([[2, 4]])

注意到结果是一个矩阵.

>>> a = np.array([1, 2])
>>> A = np.array([[0, 1], [2, 3]])
>>> a + A
array([[1, 3],
       [3, 5]])
>>> A + a == a + A
True

这里的算数逻辑是,将$n$维向量$v$和$m\times n$矩阵$A$相加,因为之前提到矩阵的每一行就是一个向量,所以这里就是将$A$的每一行都加上$v$.

最后是矩阵和矩阵的加减法,这里也存在shape不同的矩阵的加法,但我不推荐这样做,因为会影响代码可读性,我还是偏向于只在$A_{m\times n}\pm B_{m\times n}$时使用.

在理解了上面的“标量运算”的含义后,我们直接给出标量乘除法的算法,也就是将上面的加减法换成乘除即可.

第五运算:向量乘法

Python中特地为ndarray设计了一个运算符@,用于向量和矩阵的运算,先来看向量

>>> a = np.array([1, 2])
>>> a@a
5 

所以很简单,求出来的就是向量内积. 而且是实数的形式.

再来看看向量和矩阵的乘积:

>>> a = np.array([1, 2])
>>> A = np.array([[1, 2], [3, 4]])
>>> A@a
array([ 5, 11])
>>> a@A
array([ 7, 10])

此时@运算会根据矩阵和向量的相对位置进行调整,再去计算矩阵与向量的乘法,返回的也是向量:

\[\begin{bmatrix} 1&2\\3&4 \end{bmatrix}\begin{bmatrix} 1\\2 \end{bmatrix}=\begin{bmatrix} 5\\11 \end{bmatrix},\begin{bmatrix} 1&2 \end{bmatrix}\begin{bmatrix} 1&2\\3&4 \end{bmatrix}=\begin{bmatrix} 7&10 \end{bmatrix}\]

最后是矩阵和矩阵的乘法,这里则不会有自动调整的功能,我们来看一下:

>>> a = np.array([[1, 2]]) # 行向量
>>> A = np.array([[1, 2], [3, 4]])
>>> a@A
array([[ 7, 10]])
>>> A@a
ValueError

注意到如果矩阵尺寸不符合乘法的规定,Python会抛出异常,此外,计算的结果也是矩阵. 至此我们可以发现一个问题,如果只是矩阵最基本的运算,其实不需要麻烦matrixndarray已经可以完成任务,

matrix的运算

上面的五则运算在这里通用,而且运算法则上就是ndarray中的矩阵和矩阵的运算.

总结

这篇文章又是因为现实问题不得不写,起码能够帮助我简化矩阵向量运算的代码,也不用摸黑撞墙一样调试numpy运算中的shape error. 总的来说,对于现实的问题,我们常常只需要掌握实数,向量和矩阵形式即可,而对更复杂的问题,我们就需要维数更高的矩阵,也就是张量(tensor),本文也从数学层面揭示了张量(由于物理层面的难以理解)的几何含义.