购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

第2章
TensorFlow基础知识

2015年11月,Google首次宣布开源TensorFlow,经过多次迭代,2017年2月,Google发布了更加稳定并且性能更加强劲的TensorFlow 1.0。2019年10月,Google正式发布了TensorFlow 2.0。在1.0的基础上,2.0版本的TensorFlow在以下方面进行了增强。

1)对开发者更加友好,默认为即时执行模式(Eager Mode,或称为动态图模式)。TensorFlow代码现在可以像正常的Python代码一样运行,这大大提高了人们的开发效率。

2)在需要提高性能的地方可利用@tf.function切换成Autograph模式。

3)Keras已经成为TensorFlow 2.0版本的官方高级API,推荐使用tf.keras。

4)清理了大量的API,以简化和统一TensorFlow API。

5)改进tf.data功能,基于tf.data API,可使用简单的代码来构建复杂的输入。

6)提供了更加强大的跨平台能力。通过TensorFlow Lite,我们可以在Android、iOS以及各种嵌入式系统中部署和运行模型。通过TensorFlow.js,我们可以将模型部署在JavaScript环境中。本章将从以下几个方面介绍TensorFlow的基础内容。

❑简单说明TensorFlow 2+的安装;

❑层次架构;

❑张量与变量;

❑动态计算图;

❑自动图;

❑自动微分;

❑损失函数、优化器等;

❑通过实例把这些内容贯穿起来。

2.1 安装配置

TensorFlow支持多种环境,如Linux、Windows、Mac等,本章主要介绍基于Linux系统的TensorFlow安装。TensorFlow的安装又分为CPU版和GPU版。CPU版相对简单一些,无须安装显卡驱动CUDA和基于CUDA的加速库cuDNN等;GPU版的安装步骤更多一些,需要安装CUDA、cuDNN等。不过,无论选择哪种安装版本,我们都推荐使用Anaconda作为Python环境,因为这样可以避免大量的兼容性和依赖性问题,而且使用其中的conda进行后续更新及维护也非常方便。接下来将简单介绍如何安装TensorFlow,更详细的安装内容请参考附录A。

2.1.1 安装Anaconda

Anaconda内置了数百个Python经常使用的库,其中包含的科学包有:conda、NumPy、Scipy、Pandas、IPython Notebook等,还包括机器学习或数据挖掘的库,如Scikit-learn。Anaconda是目前最好的Python安装环境,它不仅便于安装,还便于后续版本的升级维护。

Anaconda有Windows、Linux、MacOS等版本,这里我们以Windows环境为例,包括以下TensorFlow也是基于Windows的。基于Linux的安装请参考附录A。

1. 下载安装包

打开Anaconda的官网(https://www.anaconda.com/products/individual),可看到如图2-1所示的界面。

图2-1 Anaconda下载界面

选择Python 3.8,64-Bit Graphical Installer,大小为477MB。下载后可得类似如下文件:Anaconda3-2021.05-Windows-x86_64.exe。

2. 安装

双击文件Anaconda3-2021.05-Windows-x86_64.exe,按默认或推荐选项安装即可,最后勾选“Add Anaconda to my PATH environment variable”,则系统会自动把Anaconda的安装目录写入PATH环境变量中。至此,安装结束。

3. 验证

打开Anaconda prompt输入conda list,安装成功后可以看到已安装的库。

2.1.2 安装TensorFlow CPU版

在Windows上安装TensorFlow CPU版比较简单,可以使用conda或pip进行安装。

使用conda安装时将自动安装TensorFlow依赖的模块,但因conda能安装的最新版本与TensorFlow的最新版本相比有点滞后,所以如果要安装最新版本,可使用pip安装。

打开Anaconda prompt界面,先用search命令查看用conda能安装的版本:

图2-2展示了运行结果的最后几行。

图2-2 查看conda能安装的TensorFlow(CPU版)版本

使用conda安装,选择版本号和安装源。如安装2.5.0版本,使用豆瓣源。

然后启动Jupyter Notebook服务,验证安装是否成功。在Jupyter Notebook中输入以下代码,如果没有报错信息,且显示已安装TensorFlow的版本为2.5.0,说明安装成功。

2.1.3 安装TensorFlow GPU版

安装TensorFlow GPU版的步骤相对多一些,这里采用一种比较简洁的方法。目前TensorFlow对CUDA的支持比较好,所以在安装GPU版之前,首先需要安装一块或多块GPU显卡。本节以NVIDIA显卡为例,当然也可以使用其他显卡。

接下来我们需要安装:

❑显卡驱动

❑CUDA

❑cuDNN

其中CUDA(Compute Unif ied Device Architecture,统一计算设备架构)是NVIDIA公司推出的一种基于新的并行编程模型和指令集架构的通用计算架构,它能利用NVIDIA GPU的并行计算引擎,解决许多复杂计算任务比CPU更高效。NVIDIA cuDNN是用于深度神经网络的GPU加速库,它强调性能、易用性和低内存开销。NVIDIA cuDNN可以集成到更高级别的机器学习架构中,其插入式设计可以让开发人员专注于设计和实现神经网络模型,而不是调整性能,也可以在GPU上实现高性能并行计算。目前大部分深度学习架构使用cuDNN来驱动GPU计算。

这里假设Windows上的显卡及驱动已安装好,如果你还没有安装显卡及驱动,可参考附录A了解安装方法。

1. 查看显卡信息

安装好GPU显卡及驱动后,在Anaconda prompt端输入nvidia-smi命令可看到如图2-3所示的GPU信息。

图2-3 显示GPU及驱动的相关信息

2. 安装CUDA和cuDNN

1)进入NVIDIA官网(https://developer.nvidia.com/cuda-toolkit-archive)下载对应版本的CUDA并安装。

2)进入NVIDIA官网下载对应版本的cuDNN并解压缩。解压后的文件如图2-4所示。

图2-4 解压后的文件

3. 将文件复制到对应目录

将cuDNN中的bin、include和lib文件复制到CUDA的对应目录,如C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v11.1。

4. 验证

在命令行输入:

如果能看到如下类似信息,说明安装成功!

5. 安装TensorFlow GPU版

直接使用国内的源进行安装,这样下载速度比较快。

安装验证:

运行结果如下:

如果出现类似信息,说明TensorFlow GPU版安装成功!

2.2 层次架构

TensorFlow 2.0从低到高可以分成如下6个层次架构,如图2-5所示。

图2-5 TensorFlow 2.0的层次架构图

❑最底层为硬件层,TensorFlow支持CPU、GPU或TPU。

❑第2层为网络通信层,TensorFlow支持gRPC、RDMA(Remote Direct Memory Access,远程直接内存访问)、GDR(GPU Direct)和MPI通信协议。

❑第3层为C++实现的内核层,如实现矩阵运算、卷积运算等,内核可以跨平台分布运行。

❑第4层为Python实现的各种操作符,TensorFlow提供了封装C++内核的低阶API,例如各种张量操作算子、计算图、自动微分等,其中大部分继承自基类tf.Module,具体包括tf.Variable、tf.constant、tf.function、tf.GradientTape、tf.nn.softmax等。

❑第5层为Python实现的模型组件,TensorFlow对中阶API进行了函数封装,主要包括各种模型层、损失函数、优化器、数据管道、特征列等,如tf.keras.layers、tf.keras.losses、tf.keras.metrics、tf.keras.optimizers、tf.data.DataSet等。

❑第6层为Python实现的各种模型,一般为TensorFlow按照面向对象方式封装的高阶API,主要为tf.keras.models提供的模型类接口。

接下来就各层的主要内容进行说明。

2.3 张量

张量(Tensor)是具有统一类型(通常是整型或者浮点类型)的多维数组,它和NumPy里面的ndarray非常相似。TensorFlow中张量(tf.Tensor)的基本属性与ndarray类似,具有数据类型和形状维度等,同时TensorFlow提供了丰富的操作库(tf.add、tf.matmul和tf.linalg.inv等),用于使用和生成tf.Tensor。tf.Tensor与NumPy还可以互相转换。除这些相似点之外,tf.Tensor与NumPy也有很多不同点,最大的不同就是NumPy只能在CPU上计算,没有实现GPU加速计算,而tf.Tensor不但可以在CPU上计算,也可以在GPU上加速计算。接下来,我们就张量的基本属性、基本操作进行简单说明,同时总结tf.Tensor与NumPy的异同点。

2.3.1 张量的基本属性

张量有几个重要的属性。

❑形状(shape):张量的每个维度的长度,与NumPy数组的shape一样。

❑维度/轴(axis):可以理解为数组的维度,例如,二维数组或者三维数组等。

❑秩(rank):张量的维度数量,可用ndim查看。

❑大小(size):张量的总的项数,也就是所有元素的数量,与NumPy数组的size一样。

❑数据类型(dtype):张量元素的数据类型,如果在创建张量时不指定数据类型,则TensorFlow会自动选择合适的数据类型。

接下来通过一些实例进行说明。用tf.constant生成各种维度的张量:

我们看一个四维的张量及相关属性。

这是一个秩为4、形状为(3, 2, 4, 5)的张量,各轴的大小与张量形状之间的对应关系可参考图2-6。

图2-6 张量各轴大小与张量形状之间的对应关系

轴一般按照从全局到局部的顺序进行排序:首先是批次轴,随后是空间维度,最后是每个位置的特征。这样在内存中,特征向量就会位于连续的区域。

2.3.2 张量切片

张量切片与NumPy切片一样,也是基于索引。切片或者索引是Python语言中针对字符串、元组或者列表进行读写的魔法方法,在第1章介绍NumPy的时候提到过,针对NumPy数组,我们可以进行索引或者切片操作。同样,我们也可以对TensorFlow里面的张量进行索引或者切片操作,并且遵循Python语言或者NumPy数组的索引规则。

❑索引从下标0开始。

❑负索引按照倒序进行索引,比如-1表示倒数第一个元素。

❑切片的规则是start:stop:step。

❑通过制定多个索引,可以对多维度张量进行索引或者切片。

示例如下:

2.3.3 操作形状

与NumPy中的reshape、transpose函数一样,TensorFlow也提供reshape、transpose函数帮助我们操作张量形状。

通过重构可以改变张量的形状。重构的速度很快,资源消耗很低,因为不需要复制底层数据,只是形成一个新的视图,原张量并没有改变。

数据在内存中的布局保持不变,同时使用请求的形状创建一个指向同一数据的新张量。TensorFlow采用C样式的“行优先”内存访问顺序,即最右侧的索引值依次递增,对应内存中的单步位移。

运行结果如下:

一般来说,tf.reshape唯一合理的用途是合并或拆分相邻轴(或添加/移除1维)。对于3×2×5张量,重构为(3×2)×5或者3×(2×5)都是合理的,因为切片不会混淆。

运行结果如下:

重构可以处理总元素个数相同的任何新形状,但是如果不遵从轴的顺序,则不会发挥任何作用。利用tf.reshape无法实现轴的交换,所以交换轴时需要使用tf.transpose。

2.4 变量

深度学习在训练模型时,用变量来存储和更新参数。建模时它们需要被明确地初始化,模型训练后它们必须被存储到磁盘。这些变量的值可在之后模型训练和分析时被加载。变量通过tf.Variable类进行创建和跟踪,对变量执行运算可以改变其值。可以利用特定运算读取和修改变量的值,也可以通过使用张量或者数组的形式创建新的变量:

运行结果如下:

变量与常量的定义方式以及操作行为都十分相似,实际上,它们都是tf.Tensor支持的一种数据结构。与常量类似,变量也有数据类型(dtype)和形状(shape),也可以与NumPy数组相互交换,并且大部分能够作用于常量的运算操作都可以应用于变量,形状变形(变量的reshape方法会生成一个新的常量)除外。示例如下:

运行结果如下:

2.5 NumPy与tf.Tensor比较

前文提到,NumPy数组与TenosrFlow中的张量(即tf.Tensor)有很多相似的地方,而且可以互相转换。表2-1总结了NumPy与tf.Tensor的异同点。

表2-1 NumPy与tf.Tensor的异同点

它们可以互相转换,具体分析如下:

❑通过使用np.array或tensor.numpy方法,可以将TensorFlow张量转换为NumPy数组;

❑通过使用tf.convert_to_tensor或者tf.constant、tf.Variable可以把Python对象转换为TensorFlow张量。

2.6 计算图

TensorFlow有3种计算图:TensorFlow 1.0时代的静态计算图、TensorFlow 2.0时代的动态计算图和自动图(Autograph)。对于静态计算图,我们需要先使用TensorFlow的各种算子创建计算图,再开启一个会话(Session)执行计算图。而在TensorFlow 2.0时代,默认采用的是动态计算图,即每使用一个算子后,该算子就会被动态加入隐含的默认计算图中立即执行并获取返回结果,而无须执行Session。

使用动态计算图(即Eager Excution立即执行)的好处是方便调试程序,执行TensorFlow代码犹如执行Python代码一样,而且可以使用Python,非常便捷。不过使用动态计算图的坏处是运行效率相对会低一些,因为在执行动态计算图期间会有很多次Python进程和TensorFlow的C++进程之间的通信。静态计算图则不通过Python这个中间环节,基本在TensorFlow内核上使用C++代码执行,效率更高。

为了兼顾速度与性能,在TensorFlow 2.0中我们可以使用@tf.function装饰器将普通Python函数转换成对应的TensorFlow计算图构建代码。与执行静态计算图方式类似,使用@tf.function构建静态计算图的方式叫作自动图,更多详细内容将在2.7节介绍。

2.6.1 静态计算图

在TensorFlow 1.0中,静态计算图的使用过程一般分两步:第1步是定义计算图,第2步是在会话中执行计算图。

以上代码在TensorFlow 2.0环境中运行时将报错,因为该环境中已取消了占位符(placeholder)及会话(Session)等内容,不过考虑对老版本Tensorflow 1.0的兼容性,tf.compat. v1子模块中保留了对TensorFlow 1.0那种静态计算图构建风格的支持,但是添加tf.compat. v1来对老版本提供支持的方式并不是官方推荐使用的方式。

运行结果如下:

2.6.2 动态计算图

上一节的代码如果采用动态计算图的方式实现,需要做如下处理。

1)把占位符改为其他张量,如tf.constant或tf.Variable。

2)无须显式创建计算图。

3)无须变量初始化。

4)无须执行Session,把sess.run中的feed_dict改为传入函数的参数,把fetches改为执行函数即可。

采用TensorFlow 2.0动态计算图执行的代码如下:

运行结果如下:

2.7 自动图

与静态计算图相比,动态计算图虽然调试编码效率高但是执行效率偏低,TensorFlow 2.0之后的自动图可以将动态计算图转换成静态计算图,兼顾开发效率和执行效率。通过给函数添加@tf.function装饰器就可以实现自动图的功能,但是在编写函数时需要遵循一定的编码规范,否则可能达不到预期的效果,这些编码规范主要包括如下几点。

❑避免在函数内部定义变量(tf.Variable)。

❑函数体内应尽可能使用TensorFlow中的函数而不是Python的自有函数。比如使用tf.print而不是print,使用tf.range而不是range,使用tf.constant(True)而不是True。

❑函数体内不可修改该函数外部的Python列表或字典等数据结构变量。

用@tf.fuction装饰2.6.2节的函数,把动态计算图转换为自动图。

运行代码,出现如下错误信息:

这是为什么呢?报错是因为函数定义中定义了一个tf.Variable变量。实际上,在动态模式中,这个对象就是一个普通的Python对象,在定义范围之外会被自动回收,然后在下次运行时被重新定义,因此不会有错误。但是现在tf.Variable定义了一个持久的对象,如果函数被@tf.function修饰,动态模型被禁止,而tf.Variable定义的实际上是图中的一个节点,这个节点不会被自动回收,且图一旦编译成功,不能再创建变量,故执行函数时会报错。那么,如何避免这样的错误呢?方法有多种,列举如下。

1)把tf.Variable变量移到被@tf.function装饰的函数外面。

运行结果如下:

在函数外部定义tf.Variable变量,你可能会感觉这个函数有外部变量依赖,封装不够完美。那么,是否有两全其美的方法呢?利用类的封装性就可以完美解决这个问题,即创建一个包含该函数的类,并将相关的tf.Variable创建放在类的初始化方法中。

2)通过封装成类方法来解决这个问题。

运行结果如下:

其他两个规范比较好理解,后面将详细说明。

2.8 自动微分

机器学习,尤其是深度学习,通常依赖反向传播求梯度来更新网络参数,而求梯度通常非常复杂且容易出错。TensorFlow深度学习架构帮助我们自动地完成了求梯度运算。它一般使用梯度磁带tf.GradientTape来记录正向运算过程,然后使用反播磁带自动得到梯度值。这种利用tf.GradientTape求微分的方法叫作TensorFlow的自动微分机制,其基本流程如图2-7所示。

图2-7 TensorFlow自动微分机制的流程图

下面通过一些示例进行说明:

对常量张量也可以求导,但需要增加watch。例如:

利用tape嵌套方法,可以求二阶导数。

默认情况下,只要调用GradientTape.gradient方法,系统就会自动释放GradientTape保存的资源。在同一计算图中计算多个梯度时,可创建一个persistent=True的梯度磁带,这样便可以对GradientTape.gradient方法进行多次调用。最后用del显式方式删除梯度磁带,例如:

梯度磁带会自动监视tf.Variable,但不会监视tf.Tensor。如果无意中将变量(tf.Variable)变为常量(tf.Tensor)(如tf.Variable与一个tf.Tensor相加,其和就变成常量了),梯度磁带将不再监控tf.Tensor。为避免出现这种情况,可使用Variable.assign给tf.Variable赋值,示例如下:

运行结果如下:

如果在函数的计算中有TensorFlow之外的计算(如使用NumPy算法),则梯度磁带将无法记录梯度路径;同时,如果变量的值为整数,则无法求导。例如:

2.9 损失函数

TensorFlow内置了很多损失函数(又称为目标函数),如tf.keras模块中就有很多内置损失函数,这里仅列出一些常用的模块及功能说明。

用于分类的损失函数如下所示。

❑binary_crossentropy(二元交叉熵):用于二分类,参数from_logits说明预测值是否是logits(logits没有使用sigmoid激活函数全连接的输出),类实现形式为BinaryCross-entropy。

❑categorical_crossentropy(类别交叉熵):用于多分类,要求标签(label)为独热(One-Hot)编码(如:[0, 1, 0]),类实现形式为CategoricalCrossentropy。

❑sparse_categorical_crossentropy(稀疏类别交叉熵):用于多分类,要求label为序号编码形式(一般取整数),类实现形式为SparseCategoricalCrossentropy。

❑hinge(合页损失函数):用于二分类,最著名的应用是作为支持向量机(SVM)的损失函数,类实现形式为Hinge。

用于回归的损失函数如下所示

❑mean_squared_error(平方差误差):用于回归,简写为mse,类实现形式为MeanSqu-aredError和MSE。

❑mean_absolute_error(绝对值误差):用于回归,简写为mae,类实现形式为MeanAbs-oluteError和MAE。

❑mean_absolute_percentage_error(平均百分比误差):用于回归,简写为mape,类实现形式为MeanAbsolutePercentageError和MAPE。

❑Huber:只有类实现形式,用于回归,介于mse和mae之间,对异常值进行比较。鲁棒性比mse好。

2.10 优化器

优化器(optimizer)在机器学习中占有重要地位,它是优化目标函数的核心算法。在进行低阶编程时,我们通常使用apply_gradients方法把优化器传入变量和对应梯度,从而对给定变量进行迭代,或者直接使用minimize方法对目标函数进行迭代优化。

在实现中高阶API编程时,我们往往会在编译时将优化器传入Keras的Model,通过调用model.fit实现对损失的迭代优化。优化器与tf.Variable一样,一般需要在@tf.function外创建。

tf.keras.optimizers和tf.optimizers完全相同,tf.optimizers.SGD即tf.keras.optimizers.SGD。最常用的优化器列举如下。

1. 随机梯度下降法(Stochastic Gradient Descent, SGD)

tf.keras.optimizers.SGD默认参数为纯SGD,其语法格式为:

设置momentum参数不为0,即SGD实际上变成SGDM。如果仅考虑一阶动量,设置nesterov为True,则SGDM变成NAG(Nesterov Accelerated Gradient),在计算梯度时计算的是向前走一步所在位置的梯度。

2. 自适应矩估计(Adaptive Moment Estimation, Adam)

tf.keras.optimizers.Adam的语法格式为:

它是自适应(所谓自适应主要是自适应学习率)优化器的典型代表,同时考虑了一阶动量和二阶动量,可以看成是在RMSprop的基础上进一步考虑了一阶动量。自适应类的优化器还有Adagrad、RMSprop、Adadelta等。

2.11 使用TensorFlow 2.0实现回归实例

在1.7节,我们用纯NumPy实现一个回归实例,这里我们使用TensorFlow 2.0中的自动微分来实现。数据一样,目标一样,但实现方法不一样,大家可以自行比较。

1)生成数据。这些内容与1.7节的内容一样,只是需要把NumPy数据转换为TensorFlow格式的张量或变量。

运行结果如图2-8所示。

图2-8 回归使用的数据图形

2)把NumPy数据转换为TensorFlow 2.0格式的张量或变量。

3)定义回归模型。

4)自定义损失函数。

5)使用自动微分及自定义梯度更新方法。

对模型进行训练。

比较拟合程度。

使用TensorFlow实现回归问题的拟合结果如图2-9所示。

6)使用自动微分及优化器。

虽然上述梯度计算采用自动微分的方法,但梯度更新采用自定义方式,如果损失函数比较复杂,自定义梯度难度会陡增,是否有更好的方法呢?使用优化器可以轻松实现自动微分、自动梯度更新,而这正是方向传播的核心内容。

使用优化器的常见方法有3种,介绍如下。

图2-9 使用TensorFlow实现回归问题的拟合结果

❑使用apply_gradients方法:先计算损失函数关于模型变量的导数,然后将求出的导数值传入优化器,使用优化器的apply_gradients方法迭代更新模型参数以最小化损失函数。

❑用minimize方法:minimize(loss, var_list)计算损失所涉及的变量(tf.Variable)组成的列表或者元组,即tf.trainable_variables(),它是compute_gradients()和apply_gradients()方法的简单组合。用代码可表示如下:

❑在编译时将优化器传入Keras的Model,通过调用model.fit实现对损失的迭代优化。具体实例可参考本书3.3节。

下面,我们先来了解如何使用优化器的apply_gradients方法。

训练模型。

使用自动微分的拟合结果如图2-10所示。

图2-10 使用自动微分的拟合结果

由此可见,使用优化器不但可以使程序更简洁,也可以使模型更高效!

接下来,我们使用优化器的minimize(loss, var_list)方法更新参数。

训练模型。

使用优化器的拟合结果如图2-11所示。

图2-11 使用优化器的拟合结果

综上,使用优化器的minimize方法更简洁。

2.12 GPU加速

深度学习的训练过程一般会非常耗时,通常需要几个小时或者几天来训练一个模型。如果数据量巨大、模型复杂,甚至需要几十天来训练一个模型。一般情况下,训练模型的时间主要耗费在准备数据和参数迭代上。当准备数据成为训练模型的主要瓶颈时,我们可以使用多线程来加速。当参数迭代成为训练模型的主要瓶颈时,我们可以使用系统的GPU(或TPU)资源来加速。

如果没有额外的标注,TensorFlow将自动决定是使用CPU还是GPU。如果有必要,TensorFlow也可以在CPU和GPU内存之间复制张量。

查看系统的GPU资源以及张量的存放位置(系统内存还是GPU):

在必要时,我们可以显式地指定希望的常量的存储位置以及是使用CPU还是使用GPU进行科学计算。如果没有显式指定,TensorFlow将自动决定在哪个设备上执行,并且把需要的张量复制到对应的设备上。但是,在需要的时候,我们也可以用tf.device这个上下文管理器来指定设备。下面通过一个例子来说明。

从上面的日志中我们可以发现,在数据量不是很大的情况下(比如矩阵大小在100×100以内),使用GPU运算并没有太多的优势,这是因为前期的张量准备以及复制耗费了太多时间。但是随着数据量的逐步增加,GPU的运算速度的优势逐步体现出来,在我们的数据是一个10000×10000的矩阵的时候,CPU和GPU的运算速度会有1000倍的差距。我们把数据罗列在图表上会更加直观:

CPU与GPU耗时比较如图2-12所示。

图2-12 CPU与GPU耗时比较

2.13 小结

本章首先简单介绍了TensorFlow 2+版本的安装,然后介绍了TensorFlow的一些基本概念,如张量、变量以及计算图的几种方式,同时与TensorFlow 1+版本的对应概念进行了比较。随后,对TensorFlow的核心内容,如自动微分、损失函数、优化器等进行了说明。为帮助大家更好地理解这些概念和方法,最后通过几个相关实例进行了详细说明。 iTlsPQY8eidnP6JOhXK8f5Gb89cLeOnunKhxn5r9fJgls69dPnvfe8uNLI5fyPlO

点击中间区域
呼出菜单
上一章
目录
下一章
×