介绍
今天开始一个新的系列,这个系列的目标是用python在不使用任何第三方库的情况下去实现各类机器学习或者深度学习的算法。之所以会有这种想法是因为每当我想提高编程技巧的时候,我总希望能够做一些简单又有趣的小项目练手。我一直对机器学习算法颇感兴趣,所以我想为什么不用python从零开始搭建一套迷你机器学习库呢。于是我尝试这么做了,这个系列就是记录我实现这一想法的过程。另外,由于这个项目很少用到第三方库,而且实现上尽可能抱着语法简单,因此也较为容易转换成其他语言。
说起来,在没有开始这个项目之前,有些东西,比如Numpy里的array赋值、取值、转置这些,用起来跟呼吸一样自然,并造成一种...就像被问1+1为什么等于2的感觉:它就是应该等于2没有为什么。但真正深入实现的时候,发现又不是这么一回事...
本篇是系列的第一篇,主要是模仿numpy的部分功能,搭建一个矩阵计算框架。当然,这个实现不会像商业库那样拥有强大的功能以及稳定性,因而会有些不那么robust。但对于抱着学习的目的来说,忽略一些复杂情况可以更容易理解本质。我打打算是每一期的代码都是最简实现,够用就行,只有后面实现算法时需要用到新功能时才会新增功能。那么下面就开始这个矩阵计算框架的第一步,Vector类。
1.Vector类
矩阵运算首先得要有矩阵,numpy里面矩阵的展现形式是ndarray这个类,pytorch或者tensorflow都叫Tensor。我这里起个名字叫Vector吧,用于存储矩阵的数据结构。
1.1 初始化函数
关于Vector类,有两个必不可少的类成员属性,一是用于存储数值的变量array,二是用于表示矩阵形状的变量shape。
总之,我们希望如果对Vector进行索引的话,得到的东西还是个vector。所以最简单的想法就是,array里装的是低一维的Vector。这样索引的时候直接可以对array索引直接取到响应的Vector了。这样,array在初始化的时候需要做一些额外的操作,直接看代码好了:
class Vector:def __init__(self, data, shape=None, requires_grad=False, grad=None, _creator=None, name='Unknown'):self.name = nameif not shape:self.shape = _inference_shape(data)else:self.shape = deepcopy(shape)if self.ndim > 1:self.array = [v if isinstance(v, Vector) else Vector(v, shape=self.shape[1:]) for v in data]else:self.array = [cast(v) for v in data]self.grad = gradself.requires_grad = requires_gradself._creator = _creator
上述代码看到,初始化Vector最主要是两个参数,一个是data,即存了什么样的数据。另一个是shape,表明了数据按照什么样的格式存储的(其他参数是自动求导所需要的,后一期再做介绍)。需要注意的是,如果data本身具备多维的结构,比如嵌套的list,那么shape可以为None,此时,shape可以从data的嵌套结构中推测出来,即_inference_shape()这个函数。该函数的作用是给定一个多维list,并返回这个list的shape,具体实现放在了后面。
接下来,如果data是一个高维的结构,那么array里存的是低一维度的Vector,因此可以通过递归构造Vector。当data是一维的时候,意味着array里存的是数值,直接把data存入array就好了。这里需要注意的是我为python中的int以及float分别新建了一个类Int以及Float。这么做的原因说主要是想把value的传递方式从按值传递转变为引用传递。考虑到当vector进行转置操作时会新生成一个vector,但是我们希望对转置后的vector进行修改时,原始vector的值也跟着修改,毕竟他俩是同一个东西,只不过shape变了而已。如果使用原始数据类型的话,赋值时会按值传递的,也就无法实现这一效果。
题外话:关于变量array存储数据的形式,我在这里还踩了两个坑。一开始我很天真,以为多维矩阵就是list的嵌套,即Vector的array就是诸如:[[1,2,3],[4,5,6]]。但是随着进度继续,我发现单纯list的嵌套会有很多麻烦的地方。其中第一不和谐的点是,如果array为list的嵌套,对Vector的某个维度进行索引的时候,取出来是个list,而不是一个Vector。尽管在复写__getitem__的时候,可以构造一个新的Vector实例,但每次索引都要初始化一个Vector,这会影响索引时的速度...当然,list作为线性表(当然python里没有严格的线性表,list是线性表和链表的结合),索引速度是很快的,于是我打算让Vector类在索引时尽可能维持线性表的特性,于是产生了我第二个想法。第二个想法有点极端:我让array的存储结构就是一个一维的list。对vector取值时,可以根据shape把index进行映射到array相应的index上,来达到索引指定位置的目的,思路类似下图(图一,图一中展示了一个三维矩阵,当索引(0,2,0)这个位置时,可以通过简单的换算把(0,2,0)转换成list上第5个位置)。这种实现有很多方便的地方,比如做element-wise的运算时。但是,在写转置操作时,我觉得这种方案非常难写,于是放弃了这个方案。
1.2 数据索引取值
python中如果想实现对某个类的实例进行索引取值,就是重写__getitem__方法。类似numpy,索引支持切片,即vector[1:3],以及多维度索引,vector[1,2,3]。
def __getitem__(self, index):def recursive_getitem(vector, index_):if len(index_) > 0:res_array_ = []if isinstance(index_[0], slice):for elem in vector.array[index_[0]]:res_array_.append(recursive_getitem(elem, index_[1:]))else:res_array_ = recursive_getitem(vector.array[index_[0]], index_[1:])else:return vectorreturn res_array_if isinstance(index, int):return self.array[index]elif isinstance(index, slice):start = index.start if index.start else 0end = index.stop if index.stop else self.shape[0]step = index.step if index.step else 1res_array = [self[i].array if isinstance(self[i], Vector) else self[i] for i in range(start, end, step)]elif isinstance(index, tuple) or isinstance(index, list):if len(index) < self.ndim:index = list(index)index.extend([slice(None, None, None)] * (self.ndim - len(index)))res_array = recursive_getitem(self, index)else:raise Exception()if isinstance(res_array, list):return Vector(res_array)else:return res_array
__getitem__有一个入参index,表示索引的位置。由于既要支持普通索引,又要支持切片操作同时还要支持高维索引,所以index的类型有三种情况,第一个是最普通的,传入一个int型数值,第二种传进来的是slice。前两种都是在单一维度上的索引。第三种情况传进来的是一个元祖(或list),表示在多个维度上的索引。最后一个情况处理起来稍微麻烦些,不过思路却很简单:由于需要在多个维度上索引,很容易想到按照顺序每次只处理一个维度,这样高维索引问题可以转化为多次一维的索引。用递归很容易解决,只是需要留意下高维索引和切片索引结合的情况,即vector[1,:,3]。
1.3 索引赋值
关于赋值,我犯过一个错误,就是想当然的认为,既然有了索引,那么赋值不就很简单吗,__setitem__里直接self[key] = value就完事了。但实际上,=操作就是__setitem__,如果__setitem__写上self[key] = value,那么就等于是循环调用一个没有任何意义的__setitem__,结果就是死循环。不过,__getitem__写好了的话,__setitem__也不算特别麻烦:
def __setitem__(self, key, value):t = self[key]if isinstance(t, BaseType):v = cast(value)t.val = v.valelif isinstance(t, Vector):index = [0 for _ in range(t.ndim)]while 1:for axis in range(t.ndim - 1):if index[axis] == t.shape[axis]:index[axis] = 0index[axis + 1] += 1if index[-1] >= t.shape[-1]:breakt[index].val = cast(value[index]).valindex[0] += 1
与取值类似,key(对应于__getitem__里的index,原谅我命名没有统一...)有三种情况(上面代码漏写了一种情况)。由于索引取值已经在__getitem__里实现了,剩下的只是赋值而已。这里也只是提醒一点,如果value是python内置的数据类型需要转化成自定义的Int或Float。
1.4 转置与交换维度
接下来是一个比较重要的功能,转置。说实话,可能是我比较反应比较迟钝,转置这个操作卡了我很长时间。因为我始终觉得转置其实就是几个轴交换顺序了,所以我一直想的是如何给Vector加上轴的概念(类似于图一的结构)这样转置会变得异常简单。但真正做下去以后,总觉得加轴反而会把Vector的结构弄复杂了,于是放弃了这个思路。我现在的实现方式如下:
def transpose(self, axises=None):new_shape = [self.shape[axises[i]] for i in range(self.ndim)]new_vec = zeros(new_shape)curr_index = [0 for _ in range(self.ndim)]target_idx = [0 for _ in range(new_vec.ndim)]while 1:for axis in range(self.ndim - 1):if curr_index[axis] == self.shape[axis]:curr_index[axis] = 0curr_index[axis + 1] += 1if curr_index[-1] >= self.shape[-1]:breakfor i, j in enumerate(axises):target_idx[i] = curr_index[j]new_vec[target_idx] = self[curr_index]curr_index[0] += 1return new_vec
transpose接收一个参数axises,表示维度的交换顺序:原始的维度为[0,1,2,3],如果打算交换第0维与第2维的位置,则axises为[2,1,0,3]。我这里的实现思路可能不够高效,但好在还算简单直白,首先用转置后的shape初始化一个新的vector,然后遍历原始vector,把数值一个个塞到新的vector里去。遍历的过程比较原始,就是通过构造多维索引curr_index,从最低位开始遍历,每次循环在最低位+1,不过需要注意“进位”。另外target_idx 是转置后对curr_index的映射。
另外,numpy里还有个swapaxes函数,功能类似transpose,但是只接受两个参数,即只能交换两个维度。我这里用transpose来辅助实现的。
def swapaxes(self, axis1=None, axis2=None):axises = [i for i in range(self.ndim)]axises[axis1], axises[axis2] = axises[axis2], axises[axis1]return self.transpose(axises)
1.5 To String
还有一个重要的功能是,我希望写好的Vector类能够顺利的被print出来。在python中,如果想要把一个类print出来,可以覆写__str__方法。听起来这个功能似乎很简单,实际上如果想要输出的工整一些,还是有些麻烦的:
def __str__(self):max_l = len(str(self.max_num))def recursive_print_vector(vector, index=0):if isinstance(vector, Vector):res = []for i, elem in enumerate(vector.array):s = recursive_print_vector(elem, i)if isinstance(vector.array[0], Vector):if i == len(vector.array) - 1:res.append(str(s))else:res.append('%sn' % s)else:offset = max_l - len(s)prefix = ' ' * offsetres.append('%s%s' % (prefix, s))blank = ' ' * (self.ndim - vector.ndim - 1) if index else ''return blank + '[' + ' '.join(res) + ']'else:return str(vector)return recursive_print_vector(self)
另外上述函数需要计算Vector的最大值:
@propertydef min_num(self):min_num = float('inf')index = [0 for _ in range(self.ndim)]while 1:for axis in range(self.ndim - 1):if index[axis] == self.shape[axis]:index[axis] = 0index[axis + 1] += 1if index[-1] >= self.shape[-1]:breakval = self[index].valif min_num < val:min_num = valindex[0] += 1return min_num@propertydef max_num(self):max_num = float('-inf')index = [0 for _ in range(self.ndim)]while 1:for axis in range(self.ndim - 1):if index[axis] == self.shape[axis]:index[axis] = 0index[axis + 1] += 1if index[-1] >= self.shape[-1]:breakval = self[index].valif max_num > val:max_num = valindex[0] += 1return max_num
1.6 增加维度
unsqueeze的功能是给vector增加一个维度,这个比较简单,核心就是对vector进行遍历。另外看代码的话,似乎很多函数都用到了对vector进行循环遍历的功能,看来这块代码可以封装起来...
def unsqueeze(self, dim):if dim == -1:dim = self.ndimnew_shape = deepcopy(self.shape)new_shape.insert(dim, 1)new_vec = zeros(new_shape)index = [0 for _ in range(new_vec.ndim)]while 1:for axis in range(new_vec.ndim - 1):if index[axis] == new_vec.shape[axis]:index[axis] = 0index[axis + 1] += 1if index[-1] >= new_vec.shape[-1]:breakcurr_index = deepcopy(index)del curr_index[dim]new_vec[index] = self[curr_index]index[0] += 1return new_vec
1.7 减少维度
有unsqueeze就有squeeze。
def squeeze(self, dim):if self.shape[dim] == 1:if dim == -1:dim = self.ndim - 1new_shape = deepcopy(self.shape)del new_shape[dim]new_vec = zeros(new_shape)index = [0 for _ in range(new_vec.ndim)]while 1:for axis in range(new_vec.ndim - 1):if index[axis] == new_vec.shape[axis]:index[axis] = 0index[axis + 1] += 1if index[-1] >= new_vec.shape[-1]:breakcurr_index = deepcopy(index)curr_index.insert(dim, 0)new_vec[index] = self[curr_index]index[0] += 1return new_vecelse:raise ValueError
2.若干工具函数
_inference_shape:功能是推断嵌套list的shape。
def _inference_shape(data):shape = []while isinstance(data, list):shape.append(len(data))data = data[0]return shape
create_array_by_shape:与_inference_shape功能相反,是通过shape构造嵌套的list。
def create_array_by_shape(shape, val):if isinstance(shape, int):return valelse:new_shape = shape[1:] if len(shape) > 1 else shape[0]return [create_array_by_shape(new_shape, val) for _ in range(shape[0])]
cast:用于转换类型的函数
def cast(v):if isinstance(v, int):return Int(v)elif isinstance(v, float):return Float(v)elif isinstance(v, BaseType):return velse:raise ValueError
zeros和ones:构造一个0或1组成的Vector实例。
def zeros(shape):array = create_array_by_shape(shape, 0)return Vector(array, shape=shape)def ones(shape):array = create_array_by_shape(shape, 1)return Vector(array, shape=shape)
结语
至此,一个简单Vector类别就构造好了,它现在可以索引,可以赋值,以及能够进行转置操作。作为一个矩阵计算框架,功能似乎还不够多。更多的功能会在后面需要的时候一点点补充。在下期我打算实现自动求导,以及大部分运算操作。
https://github.com/iron-fe/machine_learning_toys.gitgithub.com