Keras–基于python的深度学习框架

       Keras是一个高层神经网络API,Keras由纯Python编写而成并基于Tensorflow、Theano以及CNTK后端。Keras 为支持快速实验而生,能够把你的idea迅速转换为结果,如果你有如下需求,请选择Keras:

    • 简易和快速的原型设计(keras具有高度模块化,极简,和可扩充特性)
    • 支持CNN和RNN,或二者的结合
    • 无缝CPU和GPU切换 

  Keras的设计原则

  a) 用户友好:Keras是为人类设计而不是为其他设计的API。用户的使用体验始终是我们考虑的首要和中心内容。Keras遵循减少认知困难的最佳实践:Keras提供一致而简洁的API,能够极大减少一般应用下用户的工作量,同时,Keras提供清晰和具有实践意义的bug反馈。

  b)模块性:模型可理解为一个层的序列或者数据的运算图,完全可配置的模块可以用最少的代价自由组合在一起。具体而言,网络层,损失函数,优化器,初始化策略,激活函数,正则化方法都是独立的模块,你可以使用它们来构建自己的模型。

  c)易扩展性:添加新模块超级容易,只需要仿照现有的模块编写新的类或者函数即可,创建新模块的便利性使得Keras更适合于先进的研究工作。

  d)与Python协作:Keras没有单独的模型配置文件类型(作为对比,caffe有),模型由Python代码描述,使其更紧凑和更以Debug,并提供了扩展的便利性。

卷积神经网络之训练算法

  1. 同一般机器学习算法,先定义Loss function,衡量和实际结果之间差距。
  2. 找到最小化损失函数的W和b, CNN中用的算法是SGD(随机梯度下降)。

卷积神经网络之优缺点

优点
  • 共享卷积核,对高维数据处理无压力
  • 无需手动选取特征,训练好权重,即得特征分类效果好
缺点
  • 需要调参,需要大样本量,训练最好要GPU
  • 物理含义不明确(也就说,我们并不知道没个卷积层到底提取到的是什么特征,而且神经网络本身就是一种难以解释的“黑箱模型”)

卷积神经网络之典型CNN

  • LeNet,这是最早用于数字识别的CNN

  • AlexNet, 2012 ILSVRC比赛远超第2名的CNN,比LeNet更深,用多层小卷积层叠加替换单大卷积层。

  • ZF Net, 2013 ILSVRC比赛冠军

  • GoogLeNet, 2014 ILSVRC比赛冠军

  • VGGNet, 2014 ILSVRC比赛中的模型,图像识别略差于GoogLeNet,但是在很多图像转化学习问题(比如object detection)上效果奇好

卷积神经网络之 fine-tuning

何谓fine-tuning?
fine-tuning就是使用已用于其他目标、预训练好模型的权重或者部分权重,作为初始值开始训练。

那为什么我们不用随机选取选几个数作为权重初始值?原因很简单,第一,自己从头训练卷积神经网络容易出现问题;第二,fine-tuning能很快收敛到一个较理想的状态,省时又省心。

那fine-tuning的具体做法是?
  • 复用相同层的权重,新定义层取随机权重初始值
  • 调大新定义层的的学习率,调小复用层学习率


卷积神经网络的常用框架

Caffe
  • 源于Berkeley的主流CV工具包,支持C++,python,matlab
  • Model Zoo中有大量预训练好的模型供使用
Torch
  • Facebook用的卷积神经网络工具包
  • 通过时域卷积的本地接口,使用非常直观
  • 定义新网络层简单
TensorFlow
  • Google的深度学习框架
  • TensorBoard可视化很方便
  • 数据和模型并行化好,速度快

 

总结

  卷积网络在本质上是一种输入到输出的映射,它能够学习大量的输入与输出之间的映射关系,而不需要任何输入和输出之间的精确的数学表达式,只要用已知的模式对卷积网络加以训练,网络就具有输入输出对之间的映射能力。

  CNN一个非常重要的特点就是头重脚轻(越往输入权值越小,越往输出权值越多),呈现出一个倒三角的形态,这就很好地避免了BP神经网络中反向传播的时候梯度损失得太快。

卷积神经网络CNN主要用来识别位移、缩放及其他形式扭曲不变性的二维图形。由于CNN的特征检测层通过训练数据进行学习,所以在使用CNN时,避免了显式的特征抽取,而隐式地从训练数据中进行学习;再者由于同一特征映射面上的神经元权值相同,所以网络可以并行学习,这也是卷积网络相对于神经元彼此相连网络的一大优势。卷积神经网络以其局部权值共享的特殊结构在语音识别和图像处理方面有着独特的优越性,其布局更接近于实际的生物神经网络,权值共享降低了网络的复杂性,特别是多维输入向量的图像可以直接输入网络这一特点避免了特征提取和分类过程中数据重建的复杂度。

keras的模型结构

 

 Keras实现卷积神经网络

图解步骤

 

  在安装过Tensorflow后,在在安装Keras默认将TF作为后端,Keras实现卷积网络的代码十分简洁,而且keras中的callback类提供对模型训练过程中变量的检测方法,能够根据检测变量的情况及时的调整模型的学习效率和一些参数. 
下面的例子,MNIST数据作为测试

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.image as pimg
import seaborn as sb         # 一个构建在matplotlib上的绘画模块,支持numpy,pandas等数据结构
%matplotlib inline

from sklearn.model_selection import train_test_split
from sklearn.metrics import confusion_matrix     # 混淆矩阵

import itertools
#  keras
from keras.utils import to_categorical         #数字标签转化成one-hot编码
from keras.models import Sequential
from keras.layers import Dense,Dropout,Flatten,Conv2D,MaxPool2D
from keras.optimizers import RMSprop
from keras.preprocessing.image import ImageDataGenerator
from keras.callbacks import ReduceLROnPlateau
# 设置绘画风格
sb.set(style=\'white\', context=\'notebook\', palette=\'deep\')

# 加载数据
train_data = pd.read_csv(\'data/train.csv\')
test_data = pd.read_csv(\'data/test.csv\')
#train_x = train_data.drop(labels=[\'label\'],axis=1)  # 去掉标签列
train_x = train_data.iloc[:,1:]
train_y = train_data.iloc[:,0]
del  train_data   # 释放一下内存

# 观察一下训练数据的分布情况
g = sb.countplot(train_y)
train_y.value_counts()

train_x.isnull().describe() # 检查是否存在确实值

train_x.isnull().any().describe()

# 归一化
train_x =  train_x/255.0
test_x = test_data/255.0

del test_data

 转换数据的shape

# reshape trian_x, test_x
#train_x = train_x.values.reshape(-1, 28, 28, 1)
#test_x = test_x.values.reshape(-1, 28, 28, 1)
train_x = train_x.as_matrix().reshape(-1, 28, 28, 1)
test_x = test_x.as_matrix().reshape(-1, 28, 28, 1)

# 吧标签列转化为one-hot 编码格式
train_y = to_categorical(train_y, num_classes = 10)

 从数据中分离出验证数据

#从训练数据中分出十分之一的数据作为验证数据
random_seed = 3
train_x , val_x , train_y, val_y = train_test_split(train_x, train_y, test_size=0.1, random_state=random_seed)

一个训练样本 

plt.imshow(train_x[0][:,:,0])

 

使用Keras搭建CNN

model = Sequential()
# 第一个卷积层,32个卷积核,大小5x5,卷积模式SAME,激活函数relu,输入张量的大小
model.add(Conv2D(filters= 32, kernel_size=(5,5), padding=\'Same\', activation=\'relu\',input_shape=(28,28,1)))
model.add(Conv2D(filters= 32, kernel_size=(5,5), padding=\'Same\', activation=\'relu\'))
# 池化层,池化核大小2x2
model.add(MaxPool2D(pool_size=(2,2)))
# 随机丢弃四分之一的网络连接,防止过拟合
model.add(Dropout(0.25))  
model.add(Conv2D(filters= 64, kernel_size=(3,3), padding=\'Same\', activation=\'relu\'))
model.add(Conv2D(filters= 64, kernel_size=(3,3), padding=\'Same\', activation=\'relu\'))
model.add(MaxPool2D(pool_size=(2,2), strides=(2,2)))
model.add(Dropout(0.25))
# 全连接层,展开操作,
model.add(Flatten())
# 添加隐藏层神经元的数量和激活函数
model.add(Dense(256, activation=\'relu\'))    
model.add(Dropout(0.25))
# 输出层
model.add(Dense(10, activation=\'softmax\'))  

 

# 设置优化器
# lr :学习效率, decay :lr的衰减值
optimizer = RMSprop(lr = 0.001, decay=0.0)

# 编译模型
# loss:损失函数,metrics:对应性能评估函数
model.compile(optimizer=optimizer, loss = \'categorical_crossentropy\',
metrics=[\'accuracy\'])

 创建一个callback类的实例

# keras的callback类提供了可以跟踪目标值,和动态调整学习效率
# moitor : 要监测的量,这里是验证准确率
# matience: 当经过3轮的迭代,监测的目标量,仍没有变化,就会调整学习效率
# verbose : 信息展示模式,去0或1
# factor : 每次减少学习率的因子,学习率将以lr = lr*factor的形式被减少
# mode:‘auto’,‘min’,‘max’之一,在min模式下,如果检测值触发学习率减少。
在max模式下,当检测值不再上升则触发学习率减少。
# epsilon:阈值,用来确定是否进入检测值的“平原区”
# cooldown:学习率减少后,会经过cooldown个epoch才重新进行正常操作
# min_lr:学习率的下限
learning_rate_reduction = ReduceLROnPlateau(monitor = \'val_acc\', patience = 3,
                                            verbose = 1, factor=0.5, min_lr = 0.00001)


epochs = 40
batch_size = 100

 数据增强处理

# 数据增强处理,提升模型的泛化能力,也可以有效的避免模型的过拟合
# rotation_range : 旋转的角度
# zoom_range : 随机缩放图像
# width_shift_range : 水平移动占图像宽度的比例
# height_shift_range 
# horizontal_filp : 水平反转
# vertical_filp : 纵轴方向上反转
data_augment = ImageDataGenerator(rotation_range= 10,zoom_range= 0.1,
                                  width_shift_range = 0.1,height_shift_range = 0.1,
                                  horizontal_flip = False, vertical_flip = False)

 训练模型

history = model.fit_generator(data_augment.flow(train_x, train_y, batch_size=batch_size),
                             epochs= epochs, validation_data = (val_x,val_y),
                             verbose =2, steps_per_epoch=train_x.shape[0]//batch_size,
                             callbacks=[learning_rate_reduction])

 

1.卷积与神经元

 1.1 什么是卷积?

         简单来说,卷积(或内积)就是一种先把对应位置相乘然后再把结果相加的运算。

         如下图就表示卷积的运算过程:

(图1)

        卷积运算一个重要的特点就是,通过卷积运算,可以使原信号特征增强,并且降低噪音.

1.2 激活函数

             这里以常用的激活函数sigmoid为例:

             把上述的计算结果269带入此公式,得出f(x)=1

1.3 神经元

            如图是一个人工神经元的模型:

                                       (图2)

          对于每一个神经元,都包含以下几部分:

           x:表示输入

          w:表示权重

          θ:表示偏置

          ∑wx:表示卷积(内积)

           f :表示激活函数

           o:表示输出

 

1.4 图像的滤波操作

            对于一个灰度图片(图3) 用sobel算子(图4)进行过滤,将得到如图5所示的图片。

 

1.5小结

       上面的内容主要是为了统一一下概念上的认识:

       图1的蓝色部分、图2中的xn、图3的图像都是神经元的输入部分;图1的红色部分数值值、图2的wn值、图4的矩阵值都可以叫做权重(或者滤波器或者卷积核,下文统称权重)。而权重(或卷积核)的大小(如图4的3×3)叫做接受域(也叫感知野或者数据窗口,下文统称接受域)

 

2.卷积神经网络

    在介绍卷积神经网络定义之前,先说几种比较流行的卷积神经网络的结构图。

2.1 常见的几种卷积神经网络结构图  

           

(图6)     

(图7)

(图8)

          

 (图9)

        图8中的C-层、S-层是6中的Convolutions层和subsampling层的简写,C-层是卷积层,S-层是子抽样和局部平均层。在图6和图7中C-层、S-层不是指具体的某 一个层,而是指输入层和特征映射层、特征映射层和特征映射层之间的计算过程,而特征映射层则保持的是卷积、子抽样(或下采样)和局部平均的输出结果。而图 6和图7的区别在于最终结果输出之前是否有全连接层,而有没有全连接层会影响到是否还需要一个扁平层(扁平层在卷积层和全连接层之间,作用是多维数据一维化)。

       图9中CONV层是卷积层(即C-层),但是新出现的RELU层和POOL层是什么呢?RELU层其实是激活层(relu只是激活函数的一种,sigmoid/tanh比较常见于全连接层,relu常见于卷积层),为什么会多出来一个激活层呢?请看下图:

 (图10)(图2,方便对比复制了过来)

      神经元的完整的数学建模应该是图10所示,与图2相比,把原来在一起的操作拆成了两个独立的操作:∑wx(卷积)和f(激活),因此多出了一个激活层。所以,在Keras中组建卷积神经网络的话,即可以采用如图2的方式(激活函数作为卷积函数的一个参数)也可采用图10所示的方式(卷积层和激活层分开)。激活层不需要参数。

      Pool层,即池化层,其作用和S-层一样:进行子抽样然后再进行局部平均。它没有参数,起到降维的作用。将输入切分成不重叠的一些 n×n 区域。每一个区域就包含个值。从这个值计算出一个值。计算方法可以是求平均、取最大 max 等等。假设 n=2,那么4个输入变成一个输出。输出图像就是输入图像的1/4大小。若把2维的层展平成一维向量,后面可再连接一个全连接前向神经网络。

      从图7可以看出,无论是卷积层还是池化层都可以叫做特征映射层,而两层之间的计算过程叫做卷积或者池化,但是这么表述容易在概念上产生混淆,所以本文不采用这种表述方式,只是拿来作为对比理解使用。

      下面对上面的内容做一下总结:

       卷积层(C-层或Convolutions层或CONV层或特征提取层,下文统称卷积层):主要作用就是进行特征提取。

       池化层(S-层或子抽样局部平均层或下采样局部平均层或POOL层,下文统称池化层):主要作用是减小特征图,起到降维的作用。常用的方法是选取局部区域的最大值或者平均值。如下图所示:    (图11)

         对于第一个卷积层来说(图6的C1-层),一个特征对应一个通道(或叫feature map或特征映射或者叫滤波器,下文统称特征映射),例如三原色(RGB)的图像就需要三个特征映射层。但是经过第一个池化层(图6的S2-层,PS:图中错标成了S1-层)之后,下一个特征提取层 (图6的C3-层)的特征映射 (feature map)个数并不一定与开始的相同了(图6中从8特征变成了20特征),一般情况下会比初始的特征映射个数多,因为根据视觉系统原理—-底层的结构构成上层更抽象的结构,所以当前层的特征映射是上一层的特征映射的组合,也就是一个特征映射会对应上一层的一个或多个特征映射。

2.2 接受域和步长

2.2.1 接受域

 (图12)

             如图所示,中间的正方形都表示接受域,其大小为5*5。这里再重复一下:权重(卷积核)指的是数字,接受域指的是权重(卷积核)的大小。

2.2.2 步长

      (图13)

          接受域的对应范围从输入的区域1移到区域2的过程,或者从区域3移动到区域4都涉及到一个参数:步长,即每次移动的幅度。在此例中的步长可以表示成3或 者(3,3),单个3表示横纵坐标方向都移动3个坐标点,如果(3,2)则表示横向移动3个坐标点,纵向移动2个坐标点。每次移动是按一个方向移动,不是两个方向都移 动(图13中,从区域1移动到区域2、区域3,然后才移动到区域4,如果两个方向都移动三个坐标点则从区域1到了区域5,是不对的)。

 2.3卷积神经网络

2.3.1卷积神经网络定义

               卷积神经网络是一个多层的神经网络,每层由多个二维平面(特征映射)组成,而每个平面由多个独立神经元组成。

2.3.2卷积神经网络特点

               卷积神经网络是为识别二维形状而特殊设计的一个多层感知器,这种网络结构对二维形状的平移、比例缩放、倾斜或者共他形式的变形具有高度不变性。

               卷积神经网络是前馈型网络。

2.3.3 卷积神经网络的形式的约束

          2.3.3.1 特征提取

         每一个神经元从上一层的局部接受域得到突触输人,因而迫使它提取局部特征。一旦一个特征被提取出来,只要它相对于其他特征的位置被近似地保留下来,它的精确位置就变得没有那么重要了。

        下图两个X虽然有点稍微变形,但是还是可以识别出来都是X。

  (图14)

         2.3.3.2 特征映射

         网络的每一个计算层都是由多个特征映射组成的,每个特征映射都是平面形式的。平面中单独的神经元在约束下共享相同的突触权值集(权重),这种结构形式具有如下的有益效果:

               a.平移不变性(图14的两个X)

               b.自由参数数量的缩减(通过权值共享实现)

        这里重点说下共享权值,以及卷积层神经单元个数的确定问题。

        以图12的结构为基础,做以下假设:

        假设一:输入范围在横纵方向与接受域正好是倍数关系

(图15)

      先说没有接受域的情况,如果特征映射有9个神经元,这9个神经元与9*9的输入做全连接,那么需要的权重个数为9*9*9=729个。添加了接受域后,9个神经元 分别与接受域做链接,这种情况下如果一个神经元对应一组权重,则有9*9=81个权重值,再假如这一组权重的值是固定的(可参考图12的接受域值,这组值不变,而不是每个值相等),那么就只剩下了9个权重值。从729个权重值减少到9个权重值,这个过程就是权值共享的过程。也许从729减少到9个,差别不是特别大,如果输入是1000*1000,神经元个数是1000万呢?这样的话由权值共享导致的减少的计算量就很客观了。

             假设二:输入范围在横纵方向与接受域不是是倍数关系

            (图16 ) 

 (图17)

          以图15为参考,只是把输入域变成图16所示的8*8的情况,若步长还是(3,3),那么横纵向各移动一次后就无法移动了,即接受域的可视范围为粉色边框内的6*6的区域(图16的粉红框A区域和B区域),外侧的2行2列的数据是读不到的。这种情况有两种处理方式,一是直接放弃,但是这种方式几乎不用,另外一种方式在周围补0(图17所示),使输入域变成(3,3)的倍数。

       通过上述内容,可以知道每个特征映射的神经元的个数是由输入域大小、接受域、步长共同决定的。如图15,9*9的输入域、3*3的接受域、(3,3)的步长,可 以计算出特征映射的神经元个数为9个。

    2.3.3.3 子抽样

        每个卷积层后面跟着一个实现局部平均和子抽样的计算层(池化层),由此特征映射的分辨率降低。这种操作具有使特征映射的输出对平移和其他形式的变形 的敏感度下降的作用。

 

3.用Keras构建一个卷积神经网络

3.1 卷积神经网络结构图

 

(图18)

3.2 Keras中的输入及权重

3.2.1 示例代码(小数字方便打印验证)

              PS:Convolution2D 前最好加ZeroPadding2D,否则需要自己计算输入、kernel_size、strides三者之间的关系,如果不是倍数关系,会直接报错。

model = Sequential()

model.add(ZeroPadding2D((1, 1), batch_input_shape=(1, 4, 4, 1)))
model.add(Convolution2D(filters=1,kernel_size=(3,3),strides=(3,3), activation=\'relu\', name=\'conv1_1\'))
model.layers[1].get_weights()

model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(filters=1,kernel_size=(2,3),strides=(2,3), activation=\'relu\', name=\'conv1_2\'))
model.layers[3].get_weights()
复制代码

  

 3.2.2 代码解释

           1) batch_input_shape=(1, 4, 4, 1)

               表示:输入1张1通道(或特征映射)的4*4的数据.因为我采用的是用Tensorflow做后端,所以采用“channels_last”数据格式。

           2) filters = 1

              表示:有1个通道(或特征映射)

           3) kernel_size = (2,3)

              表示:权重是2*3的矩阵

          4) striders = (2,3)

             表示:步长是(2,3)

3.2.3 权重(默认会初始化权重)

         3.2.3.1 第一个model.layers[1].get_weights()输出(格式整理后)

         

                                   (图19)

         3.2.3.2 第二个model.layers[1].get_weights()输出

         

                                (图20)

         3.2.3.3 另外两个输出(没有写的参数值同上)

            1)batch_input_shape=(1, 4, 4, 3),filters = 1, kernel_size = (3,3)

              

                                               (图21)

           2) batch_input_shape=(1, 4, 4, 3),filters = 3, kernel_size = (2,3)

             

                                               (图22)

     3.2.4 小结

         图19到图22的内容是为了说明在Keras中权重的表示方式,为下面的实验做准备。

         通过以上参数得出的权重对比,当权重是二维矩阵(n*n)时:

         1) [[[…]]]:表示横轴数目

         2) [[…]]:表示纵轴数目

         3) […]:表示通道的个数。图21和图22的区别:因为图22是3通道3filter,所以每个通道对应一个filter(图20),而图22相当于把3个图20合一起了。

        4) […]内逗号隔开的数:filter的个数

        5) [[[[…]]]]:这个可能代表层数(前面几个参数是平面的,这个参数是立体的。在下面的实验中没有得到具体验证,只是根据最外层是5个方括号推测的)

   3.3 sobel算子转化成权重

        由3.2.4的结论,可以把图4的sobel算子转换成Keras中的权重:

        weights = [[[[[-1]],[[0]],[[1]]],[[[-2]],[[0]],[[2]]],[[[-1]],[[0]],[[1]]]]]

        weights =np.array(weights)

        PS:注意最外围是5个方括号

 3.4 实验代码

from PIL import Image
import  numpy as np
from keras.models import Sequential
from keras.layers import Convolution2D, ZeroPadding2D, MaxPooling2D
from keras.optimizers import SGD

\'\'\'
   第一步:读取图片数据
   说明:这个过程需要安装pillow模块:pip install pillow
\'\'\'
##1张1通道的256*256的灰度图片
##data[0,0,0,0]:表示第一张图片的第一个通道的坐标为(0,0)的像素值
img_width, img_height = 256, 256
data = np.empty((1,1,img_width,img_height),dtype="float32")
##打开图片
img = Image.open("D:\\keras\\lena.jpg")
##把图片转换成数组形式
arr = np.asarray(img,dtype="float32")
data[0,:,:,:] = arr

\'\'\'
    第二步:设置权重
    说明:注意最外围是5个方括号
\'\'\'
weights = [[[[[-1]],[[0]],[[1]]],[[[-2]],[[0]],[[2]]],[[[-1]],[[0]],[[1]]]]]
weights =np.array(weights)

\'\'\'
   第三步:组织卷积神经网络
   说明:
   1.因为实验采用的是默认的tensorflow后端,而输入图像是channels_first模式,所以注意data_format参数的设置
   2.为了实验的效果,所以strides、pool_size等参数设置成了1
\'\'\'

##第一次卷积
model = Sequential()
model.add(ZeroPadding2D(padding=(2, 2), data_format=\'channels_first\', batch_input_shape=(1, 1,img_width, img_height)))
model.add(Convolution2D(filters=1,kernel_size=(3,3),strides=(1,1), activation=\'relu\', name=\'conv1_1\', data_format=\'channels_first\'))
model.set_weights(weights)

##第二次卷积
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(filters=1,kernel_size=(3,3),strides=(1,1), activation=\'relu\', name=\'conv1_2\',data_format=\'channels_first\'))
model.set_weights(weights)

##池化操作
model.add(ZeroPadding2D((0, 0)))
model.add(MaxPooling2D(pool_size=1, strides=None,data_format=\'channels_first\'))

\'\'\'
   第四步: 设置优化参数并编译网络
\'\'\'

# 优化函数,设定学习率(lr)等参数
sgd = SGD(lr=0.01, decay=1e-6, momentum=0.9, nesterov=True) 
# 使用mse作为loss函数
model.compile(loss=\'mse\', optimizer=sgd, class_mode=\'categorical\') 

\'\'\'
    第五步:预测结果
\'\'\'
result = model.predict(data,batch_size=1,verbose=0)

\'\'\'
    第六步:保存结果到图片
\'\'\'
img_new=Image.fromarray(result[0][0]).convert(\'L\')
img_new.save("D:\\keras\\tt123.jpg")

  

3.5 实验效果

   (图23)

(图24)

         图24是池化层参数改为pool_size=2时的效果。

  

 

本文笔记参考:http://www.cnblogs.com/lc1217/p/7324935.html 

https://blog.csdn.net/hahajinbu/article/details/79535172

等博客  目的是为自己学习深度学习知识,如有侵权请联系我,不喜勿喷,谢谢!!

  同时本文章写得时候,也只是对文章中知识点理解明白,希望后面进行再整理。

版权声明:本文为wj-1314原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/wj-1314/p/9621901.html