LZW' Blog

  • Startseite

  • Archiv

Pytorch训练加速技巧

Veröffentlicht am 2019-07-19 in deep learning

Pytorch训练模型,如果数据集比较大的话,需要一块GPU。而如何高效利用这块GPU来,来使得训练速度加快是一件非常重要的是。以下是我实践中的经验和搜索到的一些经验:

  • dataloader中设置num_worker>1, pin_memory=True。num_worker是用于读取数据用的线程数,pin_memory: 是否将数据放在锁页内存,而不是放在缓存区域,因为GPU的显存都在锁页内存中。

    需要注意的是,这个技巧和个人使用的电脑配置有关。在我个人的实验中,通过将num_worker设置为4, pin_memory=True,我训练一个百万图像数据集(224*224)的时间从3个小时缩短到1.5个小时(resnet34为基准)。实际中最好划分出一部分的数据进行“调参”。

  • 使用sgd+momentum这样的需要较少显存的优化器。主要有两个原因,sgd+momentum的效果不比其他的优化器差,反而更能够比Adam达到最优点(即不容易陷在局部极值点和鞍点)。第二个原因是,sgd+momentum只需要对每个参数保存梯度信息和动量信息。而对于类似Adam,额外需要一个二阶信息,这个会加大显存的要求。

  • 使用固态硬盘。我实际中把训练数据放在系统盘,因为系统盘是固态硬盘。把验证集数据放在机械硬盘,毕竟验证过程中可以加大batchsize,且不需要记录梯度信息,显存不会爆炸。

  • 避免将不需要的数据从GPU中拷贝出来。实际中,我们常需要把每个batch的统计信息比如loss,acc或其他数据显示出来。实际上可以在这些数据在GPU中进行累积,在每个epoch结束的 时候显示出来。

  • torch.cuda.emptyCache():释放PyTorch的缓存分配器中的缓存内存块,用于防止一不小使用大grad_require=True的变量。同时使用del来删除不需要的变量来释放内存

  • 预先读取下一次迭代用的数据:最近才看到一个,mark。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

class data_prefetcher():
def __init__(self, loader):
self.loader = iter(loader)
# self.stream = torch.cuda.Stream()
self.preload()

def preload(self):
try:
self.next_data = next(self.loader)
except StopIteration:
self.next_input = None
return
# with torch.cuda.stream(self.stream):
# self.next_data = self.next_data.cuda(non_blocking=True)

def next(self):
# torch.cuda.current_stream().wait_stream(self.stream)
data = self.next_data
self.preload()
return data

###
正常定义train_loader
###

prefetcher = data_prefetcher(train_loader)
data = prefetcher.next()
i = 0
while data is not None:
print(i, len(data))
i += 1
data = prefetcher.next()

将python代码打包成exe文件

Veröffentlicht am 2019-07-13 in 编程

安装pyinstaller

pip install pyinstaller

编译成exe

运行下面的命令行

1
pyinstaller **.py

当前目录下的dist文件夹下的项目文件夹会有产生的exe文件。这种方法到处的exe需要很多的附加依赖项,运行的时候也不能脱离那个文件。如果想只生成一个exe,需要加上 -F

1
pyinstaller **.py -F

(note:如果自带界面也可以 -Fw)

经过对比发现单独只生成一个exe文件,启动速度非常慢,并且单独的exe文件也非常大。如何减少体积:

要在虚拟环境里安装pyinstaller和程序用到的库

1
2
3
4
5
6
7
8
9
10
#建立虚拟环境
pipenv install
#进入虚拟环境
pipenv shell
#安装模块
pip install 小工具.py里面用到的模块
#打包的模块也要安装
pip install pyinstaller
#开始打包
pyinstaller -Fw E:\test\url_crawler.py

实际效果表明确实能够非常大地减少exe的

用

1
-i https://pypi.tuna.tsinghua.edu.cn/simple

可以加快下载

conda自带了很多的库

拷贝需要的资源文件

将py文件用的资源,比如图片等放入dist文件夹下的项目文件夹,或和单独exe文件同一个文件夹。

Reference

https://zhuanlan.zhihu.com/p/57674343

线性方程组

Veröffentlicht am 2019-07-13 in machine learning

线性方程组问题,通常表达为:

假设$A \in R^{m*n}, b\in R^m $。通常情况下,如果矩阵$A$可逆,那么该线性方程组的解$x=A^{-1}b$。但是当矩阵A不可解的时候,该线性方程组可能无解或者有无穷多个解。

矩阵的列空间

如何判断什么时候有唯一解,无解或者无穷多解从矩阵$A$的列空间来看是非常明了的。

矩阵$A$的列空间为矩阵$A$的列向量构成的向量空间:

  • 如果矩阵$A$的列数n为m,且$A$的列向量能够张成$R^m$空间,那么由于$b$也在$R^m$空间,那么肯定有矩阵$A$的列向量线性组成$b$,且该线性组合是唯一的,即此时有唯一解。
  • 如果矩阵$A$的列数n小于m,则$A$的列向量不能够张成$R^m$空间,$A$的列空间会是$R^m$空间的子空间,如果此时$b$恰好在这个子空间中,那么此时线性方程组有解;如果$b$不在该子空间中,就无解。
  • 如果矩阵$A$的列数n大于m,如果$A$的列向量不能够张成$R^m$空间,则结果同上。如果$A$的列空间会是$R^m$空间的子空间,那么此时矩阵$A$的列向量有无穷多的线性组合$b$,此时有无穷多解。

伪逆(moore-penrose)

如果线性方程组存在无穷多解的时候,可以使用伪逆来求解。伪逆定义为:

主要是来自于矩阵的奇异值分解:

其中$D^+$是对对角矩阵$D$中非零对角元素取导数得到的。

此时有:

当矩阵$A$的列数多于行数的时候,使用伪逆求解线性方程会得到一个解$x=A^+y$,该解会是方程所有解中二范数最小的一个。

当矩阵$A$的列数小于行数的时候,使用伪逆的解$x=A^+y$到$y$的距离最短。

梯度下降法、牛顿法和拟牛顿法

Veröffentlicht am 2019-07-13 in machine learning

梯度下降法

梯度下降法是机器学习里面最常用的优化方法,简单直接效果还好,因此被广泛应用。今天就来回顾下梯度下降法。

梯度下降法有一个很trivial的想法,比如你在山上,你想要最快的下山,那么你就要走最陡的路下山,即坡度最大的路下山。反映在数学里面,山高为一个目标函数$f(x)$, 你当前的位置是$x_0 $, 当前坡度最大的路对应于当前位置$x$梯度最大的方向,但是由于我们是要下山,我们要反着方向走,即方向为

确定了方向之后,我们需要确定要走的步长$\alpha$, 由于梯度是在$x_0 $处得到的,它只能够表示$f(x)$在点$x_0$出的陡峭程度,如果我们走的步伐太大,脱离的点$x_0$的邻域,那么这个梯度并不会是一个很好的下山方向。因此我们要保证这个步长$\alpha$足够小,此时,我们迈出了下山的第一步:

一次类推,只要我们一小步一小步的走陡峭的路,很快就能下山:

下面用稍微严谨一点的数学说明下为什么要走负梯度的方向。假设我们在$f(x)$的$x_0 $处走一小步$\Delta x$,

我们的目标是走了这一步后的$f(x_0 + \Delta x)$相比$f(x_0)$下降的最多。首先,我们先在$x_0$处对$f(x)$进行一阶泰勒展开:

让$f(x)$下降的最多的点即为$x_1$,让要想让近似后的$f(x)$ 最小的话,就要让$f’(x_0)\Delta x$的值最小,考虑到两个向量的内积最小的问题,便要求$\Delta x$ 和梯度是反向的。实际上,只要$\Delta x$和梯度的夹角是大于90度的时候,上面的$argmin \ f(x)$就可以比$f(x_0)$小,当$\Delta x$和梯度的夹角是90度或者$\Delta x$是0的时候,$argmin \ f(x)$和$f(x_0)$相等。此时令$f(x)$最小的$x$即为$x_{1}$,同时有$x_1 = x_0 + \Delta x$.

更一般的,考虑在$x_k$处对函数$f(x)$进行一阶泰勒展开,则能到的迭代更新公式为:

牛顿法

梯度下降法只是用到了一阶的导数信息,而牛顿法可以用到二阶的导数信息。

首先,我们先在$x_k$处对$f(x)$进行二阶泰勒展开,$\Delta x = x - x_k$:

让$f(x)$下降的最多的点即为$x_{k+1}$,即要$f(x)$取$f’(x)=0$的点,即让:

可得:

假设$f’(x_k) = g, f’’(x_k)=H$为一个hessian矩阵,那么$\Delta x$的矩阵形式为:

此时$x$的更新公式为:

拟牛顿法

牛顿法的迭代中,需要计算hessian矩阵的逆,这个计算很耗时。而拟牛顿法考虑使用一个矩阵$G$来近似hessian矩阵$H_{x_k}^{-1}$ 。考虑何种的矩阵能够近似hessian矩阵。

按上面的等式(9)。取$x=x_{k+1}$,有:

即

这个是hessian矩阵满足的条件,被称为拟牛顿条件。

如果$H_k$矩阵是正定的,那么可以保证牛顿法搜索是下降的,这个是因为根据(11)有

因为$H_k$矩阵是正定的,$H^{-1}_k$也会是正定的,那么有$g_k^TH^{-1}_kg_k$大于0,总有$f(x)<f(x_k)$,即牛顿法是下降的。

拟牛顿法是牛顿法的近似,选择$G_{k}$作为$H^{-1}_k$的近似,要求矩阵$G_k$满足同样的条件。首先$G_k$是正定的,其次,$G_k$满足上面的拟牛顿条件,即

这样子的方法被统称为拟牛顿法。同时可以使用下面的方式来更新$G_{k+1}$:

下面介绍BFP算法

假设每一步中的$G_{k+1}$是由$G_{k}$加上两个附加项构成的:

要求:

可以让$P_k (g_{k+1} - g_k )=\Delta x$且$Q_k(g_{k+1} - g_k )=-G_k(g_{k+1} - g_k )$ 使得更新后$G_{k+1}$满足拟牛顿条件。

事实上,这样子的满足条件的$P_k$和$Q_k$并不难找。例如取:

那么可以得到

BFGS算法是一个很常见的算法,用的是用一个正定矩阵$B_k$ 来近似$H_k$ ,同样需要满足上面说的拟牛顿条件。类似BFP算法,也有

考虑使$P_k,Q_k$满足:

找到满足条件的$P_k,Q_k$,得到BFGS的算法矩阵$B_{k+1}$ 的迭代公式:

实际的BFGS的算法中,有一个确定步长的步骤,即首先确定更新的方向$-B_k^{-1} g_k$, 然后求一个步长$\alpha $,求法如下:

soft-thresholding operator

Veröffentlicht am 2019-07-13

让$f(x) = \lambda |x|_1$ , 那么$f$的近似投影被定义为:

该问题的最优性条件为:

由于$l1 \ norm$是可以分离的,因此我们可以单独考虑x中的每一个成分。

首先考虑$z_i \neq 0$的情况,此时有$\partial (\lambda|z|_1)=sign(z_i)$并且最优的$z_i^*$为:

需要指出是,如果$z_i^ <0$那么$x_i <-\lambda$。同时如果$z_i^ >0$那么$x_i >\lambda$。因此,$|x_i| >\lambda$ 和$sign(z_i^*) = sign(x_i)$.代入道先前的等式得到:

然后考虑$z_i = 0$的情况。此时$l_1 \ norm$的次微分为区间$[-1,1]$,并且最优性条件为:

将两个情况放在一起得到了:

等式(6)同样可以写为:

Singular Value Thresholding

Veröffentlicht am 2019-07-13 in machine learning

SVT(奇异值阈值)奇异值收缩(singular value shrinkage)

考虑一个秩为r的矩阵$X\in R^{n1*n2}$的奇异值分解如下:

其中$U,V$分别是$n1r,n2r$的正交矩阵,奇异值$\sigma_i$非负。

对于每一个$\tau \ge 0$,有软阈值操作$D_\tau$:

可以看出,这个软阈值操作是作用在奇异值上的,使他们趋于0,这也是这个被叫做奇异值收缩的原因。

对于奇异值收缩$D_\tau$ 有一个重要的结论:

Softmax求导

Veröffentlicht am 2019-07-13 in machine learning

softmax 可以看作是logistic “one versus all”的多分类版本,和普通的“one versus all”不同的是,softmax是通过选择最大的输出概率值来做最后的决策任务,因此不存在”混淆区域“

softmax的公式为:

为什么$p(Y=K|x)=\frac{1}{1+\Sigma_i^{K-1}{exp(w_i x)}}$?是因为,softmax是存在参数冗余的,即一开始所有的类别k都有一个$w_k$的,但是,如果对上面下面都除以一个$exp(w_Kx)$的话,就得到上面的公式:

令新的到的$w_i- w_K$为新的$w_i$即得到公式(1)。

得到softmax的参数的过程是通过最大似然函数得到的。

其中$1\{y_i=k\}在$$\{\}$内表达式为真的时候取1。由于有多个连乘,做法是对其log,得到它的对数似然函数:

此外,$log\{1+\Sigma_i^{K-1}{exp(w_i * x)}\}w_k$求导有:

因此对对数似然函数求导为:

可知,样本是否属于j类,样本i对于$w_j$的梯度贡献不同(这里假设是最小化log likelihood,需要对梯度取反):

回顾到感知机的更新为用错误的样本对权重$w$进行更新,

可以看到softmax除了对错误样本进行更新和感知机是吻合的,不同指出是softmax也用正确的样本来更新权重。

可以把$x_i(1-p(y=j|x_i))$ 重新写为:$-x_i(p(y=j|x_i) - 1)$

GBDT提升树

Veröffentlicht am 2019-07-13 in machine learning

提升树是以分类树或者回归树为基本分类器的提升方法。提升方法采用的加法模型和前向分布算法。提升树模型可以表示为决策树的加法模型:

梯度提升树采用的是前向分布算法。第m步骤的模型为:

则,通过经验风险极小化来确定下一棵数的参数$W_m$,

一阶的GBDT

考虑一阶泰勒展开,对$obj(m)= \Sigma_{n=1}^N L(y_n,f_{m-1}(x_n)+T(x_n;W_m)$在$f_{m-1}(x)$处展开有:

对近似一阶展开最小化,可知:$L(y_n,f_{m-1}(x_n))$,$\frac{\partial L(y_n,f(x_n))}{\partial f _{m-1}(x_n) }$都是constant,因为为了最小化(4),只要求:

这个就得到了梯度提升树的算法,新的一颗树是通过拟合负梯度得到的。

需要注意的是,当$L(y,f_m(x))$选为平方函数的时候,上述的负梯度即残差。

二阶的GBDT

考虑二阶展开,有:

可以缩写为:

由于函数中的常量在优化过程中不起影响,因此可以省略,(7)可以进一步写为:

在更一般的机器学习的目标函数中,会通过添加一个正则项,来达到一个最小化结构化误差的作用,因此(8)进一步写为:

最常见的一个二阶的GBDT模型为Xgboost模型。

Xgboost

这里假设一棵决策树,叶子节点个数为$M$,该决策树是由叶子节点上的值组成的向量$\omega \in R^M$,以及一个将一个特征向量映射到叶子节点的索引函数$q:R^d \rightarrow \{1,2,…,M\}$ 组成的。因此一个决策树可以重新写为:$T(x)=\omega_{q(x)}$

此时正则项定义为:$\Omega(T) = \gamma M + \frac{1}{2} \lambda \Sigma_{j=1}^M\omega^2$来定义。此时为叶子j定义一个样本集合为:$I_j = \{n|q(x_n)=j\}$

上面的目标函数可以重新写为:

定义$G_n = \Sigma_{n \in I_j}g_n,H_n=(\Sigma_{n \in I_j}h_n)$,那么上面的目标函数可以写为:

对于一个单变量二次方程式:

假设树的结构是固定的,那么每个叶子的最佳权重为:

如果给定一个树的结果,可以用上面的公式计算出得分,并且为每个叶子赋值。但是问题是,如何找到一个合适的结构。

xgb中的方法是贪婪的

  • 树从深度0开始

  • 对树的每一个叶子节点,尝试增加一个划分。目标函数的变化程度为:

对于每一个节点,遍历所有的特征:

  • 对于每一个特征,根据特征值对样本进行排序
  • 使用线性扫描的方法去决定特征的最佳划分
  • 选择所有特征中最佳的特征

回到GBDT,生成的树要加到原来的模型上:

这里$\epsilon$被称为步长或者收缩,一般设置为0.1左右。这个意味着在每一步没有做最好的优化,保留了后面的轮数,有助于防止过拟合。

除了步长外,xgb还有其他的防止过拟合的措施:

  • Early Stopping:本质是在某项指标达标后就停止训练,也就是设定了训练的轮数

  • Subsampling:无放回抽样,具体含义是每轮训练随机使用部分训练样本,其实这里是借鉴了随机森林的思想

  • colsample_bytree: 训练的过程中,使用的特征以一定的比例从所有的特征中采样。

  • max_depth: 树的深度,树越深越容易过拟合

  • min_child_weight: 值越大,越容易欠拟合。

参考

[李航《统计学习方法》]:

拉格朗日对偶性

Veröffentlicht am 2019-07-13 in machine learning

拉格朗日对偶是约束最优化问题中最常见的一种方法,将带约束的问题转化为不带约束的问题,并进行求解。除此之外,拉格朗日对偶性有这非常好的性质,例如:

  • 对偶问题可以给出原问题的一个下界
  • 无论原问题是否是凸的,对偶问题都是凸的
  • 当满足一定的条件的时候,原始问题和对偶问题完全等价。

原始问题

首先我们先提到原始问题。考虑这么一个带约束的问题:

同时引入广义拉格朗日函数 :

其中$\alpha_i$,$\beta_i$是拉格朗日乘子,$\alpha_i\ge0$。那么考虑以下的函数:

假设存在一个$x$,$x$会是以下四种情况之一:

  1. $x$满足约束$c_i(x)\le0,h_j(x)=0$
  2. $x$不满足约束$c_i(x) \le 0 $
  3. $x$不满足约束$h_j(x)=0$
  4. $x$不满足约束$h_j(x)=0$和$h_j(x)=0$

考虑第一种情况,此时$\theta_p(x)$里面的max为了最大化$L(x,\alpha,\beta)$, 会让$\alpha_i = 0$,且$\beta_i$值任意,此时$\theta_p(x)$等价于$f(x)$.即此时$\theta_p(x)$等价于原问题。

考虑第二种情况,此时$c_i(x) > 0$.$\theta_p(x)$里面的max为了最大化$L(x,\alpha,\beta)$, 会让$\alpha_i \rightarrow +\infty$,且$\beta_i$值任意,此时$\theta_p(x) \rightarrow +\infty$。

考虑第三种情况,此时$h_j(x) \ne 0$,$\theta_p(x)$里面的max为了最大化$L(x,\alpha,\beta)$, 会让$\beta_i \rightarrow +\infty或-\infty$,且$\alpha_i$值为0.此时,$\theta_p(x) \rightarrow +\infty$。

考虑第四种情况,此时$h_j(x) \ne 0$,$c_i(x) > 0$,$\theta_p(x)$里面的max为了最大化$L(x,\alpha,\beta)$, 会让$\beta_i \rightarrow +\infty或-\infty$,且$\alpha_i \rightarrow +\infty$,$\theta_p(x) \rightarrow +\infty$。

因此可以看出$\theta_p(x)$的性质,

如果考虑问题

该问题和原问题是等价的,即它们有等价的解或者同样无解。这样子就得到了广义拉格朗日函数的极小极大问题。定义原问题的最优解为:

对偶问题

定义问题:

再考虑极大化$\theta_D(\alpha,\beta)=min_x=L(x,\alpha,\beta)$,即

该问题被称为广义拉格朗日函数的极大极小问题。该问题表达为带约束的优化问题

该带约束的问题被称为原始问题的对偶问题。同时定义该对偶问题的最优解为:

对偶问题和原始问题的关系

弱对偶性和强对偶性

对偶问题和原始问题的最优解满足以下关系:

证明很简单,即:

即

则有

这个性质也被称为弱对偶性,对所有的优化问题成立,即使原始问题非凸。相对于弱对偶性,也有强对偶性:

Slater条件

考虑原始问题和对偶问题。假设函数$f(x) , c_i(x)$是凸函数,并且$h_j(x)$是仿射函数,并且不等式$c_i(x)$是严格可行的,即存在x,对于所有的i有$c_i(x) < 0$则存在$x^,\alpha^,\beta^$,使得$x^$是原问题的最优解,$\alpha^,\beta^$是对偶问题的最优解,同时有:

Note, 该slater条件对应SVM即要求数据集是线性可分的;如果数据是线性不可分的,那么此时使用SVM寻找分隔超平面也失去了意义。

KKT条件

对于原始问题和对偶问题,假设假设函数$f(x) , c_i(x)$是凸函数,并且$h_j(x)$是仿射函数,并且不等式$c_i(x)$是严格可行的,则$x^$是原问题的最优解,$\alpha^,\beta^$是对偶问题的最优解的充分必要条件是$x^$$\alpha^,\beta^$满足以下的KKT条件:

其中,$\alpha_i^c_i(x)=0, i=1,2,…,k $被称为是KKT条件的对偶互补条件。由此条件可知:若$\alpha_i^ >0$,则$c_i(x^*)=0$.

其他

当原始问题为凸优化问题的时候,其对偶问题的强对偶性与KKT条件是互为充要的。

当原始问题不为凸优化问题是,利用其对偶问题也可以得到原问题最优解的下界。

参考文章

https://blog.csdn.net/qq_32742009/article/details/81413068

统计学习方法附录C

基于梯度的优化算法

Veröffentlicht am 2019-07-13 in deep learning

Batch gradient descent

使用整个训练集来优化权重,意思是用训练集中的所有样本的loss来更新模型的权重。此时,权重的梯度为:

可以看出权重的梯度是每个每个样本的梯度的期望。

此时的更新过程为:

如果样本的个数的是10000, 100000个的时候,需要先遍历所有的样本在对权重$W$更新,这个是非常耗时的。因此有人提出了SGD

Stochastic gradient descent

相比与batch gradient descent方法, SGD不适用全部的样本来估计权重的梯度,而是使用的一小部分的样本。主要的原因是一小部分的样本的梯度均值可以近似真实的梯度,而这部分样本的数量越多,估计的梯度越近似真实的梯度。另一方面,用小部分样本来估计的梯度是带有噪声的,在一定程度也起到了正则化的作用。

此时的梯度为:

此时的更新过程为:

实际过程中,每次选取一部分的样本来估计梯度,并进行更新,这部分被叫做一个迭代;如果一整个的训练集都使用一次,就叫经过了一个 epoch。注意,每个epoch,样本都要被打散。

SGD+Momentum

SGD方法存在这一个“震荡”的问题,如下图:

1562846876860

可以看到在更新的过程中,梯度下降的迭方向会偏离实际中真正的下降方向。momentum就是为了克服

这个震荡的问题。

动量可以看作是迭代过程中下降方向,震荡的方向被消除了,使得权重更新的方向变得一致。

Adagrad

梯度下降中存在一个问题,如果使用相同的学习率,对于权重W中每一个元素$W_i$,如果他们的梯度值有较大的差别的时候,会导致权重在梯度值较小的维度上迭代过慢。Adagrad方法根据权重在每个维度上梯度值的大小来调整各个维度上的学习率,来避免统一学习率难以适应所有维度的问题。

Adagrad存在一个问题,有与$s_t$一致累加着梯度按元素平方的和,因此如果权重的某个元素的偏导数一直较大,会使得学习率下降较快;反之,如果某个元素的偏导数一直较小,学习率则会下降较慢。所以Adagrad在后期的时候,比较难找到一个合适的解。

RMSprop

由于Adagrad存在后期学习率低的问题,RMSprop对其做了一点小小的改动:

RMSprop对这些梯度的平方做指数加权移动平均,使得每个权重的学习率不会一直降低或者不变。

可以消除梯度下降过程中的摆动,包括mini-batch,允许使用更大的$\alpha$,加快算法学习速度

Adadelta

Adadelta也针对Adagrad做了改进,同样的,Adadelta也是了梯度平方的加权移动平均。但是它与RMSprop不同的是Adadelta还维护了一个额外的状态变量$\Delta W_t$ 来计算自变量的变化值:

然后更新权重:

最后使用$\Delta W_t$来记录权重变化量的指数加权移动平均:

Adam

Adam算法是将momentum和RMSprop结合起来

$\beta_1$常用为0.9,$\beta_2$常用为0.999,而$\epsilon$为10^-8。Adam还对动量项和 梯度平方项做了偏差修正,然后使用修正后的变量对权重进行更新。

123
Zhiwei Liu

Zhiwei Liu

27 Artikel
3 Kategorien
29 schlagwörter
GitHub
© 2019 Zhiwei Liu
Erstellt mit Hexo v3.9.0
|
Design – NexT.Muse v7.2.0