介绍

本篇主要是模仿numpy的部分功能,搭建一个数值计算框架。目的是实现机器学习算法中涉及到基本的矩阵运算或操作。

1.Vector类

矩阵运算首先得要有矩阵,numpy里面矩阵的展现形式是ndarray这个类,pytorch或者tensorflow都叫Tensor。我这里起个名字叫Vector吧,用于存储矩阵的数据结构。

  1. 1 初始化函数

关于Vector类,有两个必不可少的类成员属性,一是用于存储数值的变量array,二是用于表示矩阵形状的变量shape。

题外话:关于变量array存储数据的形式,我在这里还踩了两个坑。一开始我很天真,以为多维矩阵就是list的嵌套,但是随着进度继续,我发现单纯list的嵌套会有很多麻烦的地方。其中第一不和谐的点是,如果array为list的嵌套,对Vector的某个维度进行索引的时候,取出来是个list,而不是一个Vector。尽管可以在复写__getitem__的时候,构造一个新的Vector实例,但这会影响索引时的速度...另外一点是,我发现一旦维度大于2,list嵌套实在太难写转置操作了。于是我打算尽可能维持这个vector取数据的过程类似线性表(当然python里没有严格的线性表,list是线性表和链表的结合)的特性,二是希望这个存储结构有利于转置操作。我的第二个想法是,array的存储结构就是一个一维的list。对vector取值时,可以根据shape对index进行映射到array相应的index上,来达到索引指定位置的目的,思路类似下图(图一)。据说numpy的C实现的底层就是这么设计的,不过这种设计转置写起来感觉更麻烦了。于是放弃了这个方案。

825281f03e9f254e4d1fde98aaa4c279.png
图一

总之,我们希望如果对Vector进行索引的话,得到的东西还是个vector。所以最简单的想法就是,array里装的是低一维的Vector。这样索引的时候直接可以对array索引直接取到响应的Vector了。这样,array在初始化的时候需要做一些额外的操作,直接看代码好了:

class Vector:
    def __init__(self, data, shape=None, requires_grad=True, _creator=None, grad=None, name='Unknown'):
        if not shape:
            shape = _inference_shape(data)
        else:
            shape = deepcopy(shape)
        if len(shape) > 1:
            self.array = [elem if isinstance(elem, Vector) else Vector(elem, shape=shape[1:]) for elem in data]
        else:
            _array = []
            for elem in data:
                if isinstance(elem, int):
                    elem = Int(elem)
                elif isinstance(elem, float):
                    elem = Float(elem)
                elif isinstance(elem, BaseType):
                    pass
                _array.append(elem)
            self.array = _array

        self.shape = shape
        self._creator = _creator
        self.requires_grad = requires_grad
        self.grad = grad
        self.name = name

上述代码看到,初始化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。这么做的原因会在后文提及。

1.2 数据索引取值

python中如果想实现对某个类的实例进行索引取值,就是重写__getitem__方法。类似numpy,索引支持切片,即vector[1:3],以及多维度索引,vector[1,2,3]。

    def __getitem__(self, index):
        if isinstance(index, int):
            return self.array[index]

        elif isinstance(index, slice):
            start = index.start if index.start else 0
            end = index.stop if index.stop else self.shape[0]
            step = index.step if index.step else 1
            res_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)))

            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 vector
                return res_array_

            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 索引赋值

索引赋值在python中就是重写__setitem__方法。实际上一旦索引取值完成了,赋值就是水到渠成的事情。

    def __setitem__(self, key, value):
        if isinstance(key, int):
            if isinstance(value, BaseType):
                self.array[key] = value
            elif isinstance(value, int):
                self.array[key] = Int(value)
            elif isinstance(value, float):
                self.array[key] = Float(value)
            else:
                self.array[key] = value
        elif isinstance(key, list) or isinstance(key, tuple):
            vector = self
            for k in key[:-1]:
                vector = vector[k]
            if isinstance(value, BaseType):
                vector[key[-1]] = value
            elif isinstance(value, int):
                vector[key[-1]] = Int(value)
            elif isinstance(value, float):
                vector[key[-1]] = Float(value)
            else:
                vector[key[-1]] = value

与取值类似,key(对应于__getitem__里的index,原谅我命名没有统一...)有三种情况(上面代码漏写了一种情况)。由于索引取值已经在__getitem__里实现了,剩下的只是赋值而已。这里也只是提醒一点,如果value是python内置的数据类型需要转化成自定义的Int或Float。说下为什么要这么做,这里主要是把value的传递方式从按值传递转变为引用传递。一方面是考虑到这种方式可以节约一些内存,更重要的是在一些情况下这是必须的,比如转置操作会新生成一个vector,但是我们希望对转置后的vector进行修改时,原始vector的值也跟着修改,毕竟他俩是同一个东西,只不过shape变了而已。

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] = 0
                    curr_index[axis + 1] += 1
            if curr_index[-1] >= self.shape[-1]:
                break
            for i, j in enumerate(axises):
                target_idx[i] = curr_index[j]
            new_vec[target_idx] = self[curr_index]
            curr_index[0] += 1
        return 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的映射。

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 val
    else:
        new_shape = shape[1:] if len(shape) > 1 else shape[0]
        return [create_array_by_shape(new_shape, val) for _ in range(shape[0])]

zeros和ones:构造一个0或1组成的Vector实例。

def zeros(shape, _creator=None):
    array = create_array_by_shape(shape, 0)
    return Vector(array, shape=shape, _creator=_creator)


def ones(shape, _creator=None):
    array = create_array_by_shape(shape, 1)
    return Vector(array, shape=shape, _creator=_creator)

结语

至此,一个简单Vector类别就构造好了,作为一个矩阵计算框架,还需要实现各种运算方法,由于我希望把自动求导的功能集成到Vector里,所以运算这部分的内容实现起来会稍微麻烦一些,留到下期再说。

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐