Advertisement

An Introduction to Variational Autoencoders with a prac

阅读量:

作者:禅与计算机程序设计艺术

1.简介

1.1 发起原因

近年来,人工智能领域的重要研究方向包括深度学习(Deep Learning)及其在具体应用领域的实践应用(Practical Application)。在自然语言处理(NLP)领域,深度学习已展现出显著的应用价值,例如机器翻译、文本摘要和问答系统等。然而,在实际应用场景中,如何有效结合统计学、信息论和机器学习方法,构建出更加高效、精确且具有抗干扰能力的深度神经网络模型,仍是一个待解决的问题。在这一背景下,变分自动编码器(Variational Autoencoder,VAE)应运而生。VAE通过将高维数据映射至低维空间,并支持数据的重建生成,实现了对复杂数据分布的有效建模。本文将基于图像数据集MNIST,系统阐述VAE的基本概念、核心术语和算法原理,并通过Python代码实现一个简单的VAE模型,最后总结其优缺点及应用前景。

1.2 VAE概述

1.2.1 VAE的由来

在深度学习兴起之前,许多人工智能任务都依赖于特征工程的建立。传统的机器学习模型通常通过规则化方法来表征输入数据,将原始数据转换为一系列抽象的特征向量或特征图,然后利用这些特征向量或特征图来进行预测或分类。然而,由于特征工程的缺失,传统机器学习模型面临着两个主要问题:问题一是模型的预测能力较弱,问题二是模型的解释性较弱。

模型必须基于数据的内部结构才能进行建模,这表明模型对数据分布的假设通常过于简单;
高维原始数据难以直接应用于模型训练,必须通过抽样或变换提取出低维特征,这些低维特征又需要映射回高维空间,这会显著降低模型的表达能力。

基于以上两个特点,针对这两个问题,该方法于2013年提出了一种新思路。该方法通过多层堆叠,能够学习输入数据的复杂特征,无需了解数据内部结构,且所提取的特征具有高度通用性。然而,该方法仍存在两大关键问题:

  1. DBN模型提取了非概率输出,这使得其无法直接应用于监督学习;
  2. DBN模型提取的低维表示难以准确描述数据的复杂分布。

自2014年起,李宏毅团队等人提出了一种更符合实际需求的变分自动编码器(Variational AutoEncoder,VAE)。这种概率生成模型通过高斯分布参数来描述输入数据的分布,并显著增强了其监督学习能力。VAE所学习到的表示具有良好的平滑性,能够更准确地反映数据分布特征,并且在生成新数据方面表现出色。该模型由编码器模块和解码器模块两个主要部分组成。编码器模块的目标是提取数据的低维隐含表示,而解码器模块则负责将隐含表示还原为原始数据。数学上,VAE可通过以下公式表示:

\begin{aligned} \mu_{z}&=\mathbb{E}_{x}[z|x] \ \sigma^2_z &=\log(\mathbb{E}_{x}[e^{z}|x]) \ z&\sim \mathcal{N}(\mu_{z},\sigma^2_z) \ x'=g(z)&=\sigma(W^T_{dec}\cdot g(z)+b_{dec}) \ \end{aligned}

其中,\mathcal{N}表示高斯分布,z为隐含变量,x'为输出变量,g为激活函数(如sigmoid或tanh)。\mu_{z}\sigma^2_z分别表示隐含变量的均值和方差,W_{dec}b_{dec}为解码器的参数,e^{\sigma^2_z}为方差的取值范围。其主要优势体现在以下几个方面:

  1. 生成模型:VAE能够输出新的数据样本,可以直接应用于监督学习任务。
  2. 解码能力:VAE具备逆向重构能力,能够快速、精确地还原输入数据。
  3. 有助于防止过拟合:VAE模型有助于控制模型复杂度,从而有效防止过拟合现象。
  4. 自我监督学习:VAE模型可以在学习过程中进行自我监督学习,持续优化模型参数,从而提升整体性能。

VAE也存在一些局限性:

模型表现能力有限:VAE所学习的分布通常较为简单,难以捕捉复杂的分布特征。
该方法难以处理高维数据:VAE所学习的特征维度较低,导致无法直接提取高维数据中的特征。
推理速度较慢:VAE在生成新数据方面存在效率限制,其推理速度受硬件性能影响。
缺乏明确指导:VAE未提供明确的损失函数来指导模型优化,导致缺乏明确的改进方向。

总体而言,VAE是一种高效的生成模型,在多个应用场景中展现出强大的能力。作为DBN的升级版,VAE作为一种更为通用的无监督学习框架,不仅能够生成具有特定特征的新数据样本,而且可以直接应用于监督式学习任务。与传统的DBN相比,VAE在保持生成能力的同时,其潜在空间的维度有所降低。此外,该模型还提供了丰富的研究方向,例如:

  1. 可微分学习能力:VAE可以用更复杂的模型结构来刻画数据分布,提升模型的拟合能力。
  2. 分类任务上的效果:VAE可以用于图像分类任务,扩展到更广泛的任务类型。
  3. 半监督学习:VAE可以用半监督学习来训练模型,提升模型的泛化能力。
  4. 深度学习模型压缩:VAE可以用来进行模型压缩,进一步减少模型大小和计算量。

1.2.2 VAE的基本原理

VAE模型可被视为一种生成式模型,其核心思想是通过将高维数据映射到连续的 latent 空间中的变量z,来实现对数据分布的建模。该模型假设有一组参数化先验分布\pi_{\theta}(z),用于近似逼近复杂的后验分布p_{\theta}(x)。具体而言,VAE的学习过程主要包含两个关键阶段:第一阶段是通过训练一个编码器网络来学习数据的潜在表示,第二阶段则是通过解码器网络生成新的数据样本。在这一过程中,VAE不仅能够有效捕捉数据的统计特性,还能够生成具有较高质量的样本数据。

  1. 推断阶段(Inference Stage):在此阶段,VAE试图找到一种编码方式,将输入数据X转换为隐含变量Z的分布。这里的编码方式可以定义为一个概率分布,其将输入数据X的各个维度转换为隐含变量Z的元素。
  2. 学习阶段(Learning Stage):在此阶段,VAE利用已知的隐含变量Z及其对应的观测数据X,最大化似然elihood的对数。也就是说,VAE希望通过对已知的观测数据X及其对应的隐含变量Z的假设,来找到最佳的模型参数\theta

整个学习过程可以用下图来表示:

1.2.2.1 推断阶段(Inference Stage)

在推断阶段,VAE基于前馈网络,利用已有的参数θ,将输入数据X编码为隐含变量Z的条件概率分布q_φ(z|x;θ)。其中,q_φ被定义为编码器模块,φ代表可训练的参数集。q_φ(z|x;θ)表示隐含变量Z在输入数据X下的条件概率分布。通过参数θ,VAE的编码器模块将输入数据X映射到隐含变量Z的条件概率分布上。具体而言,q_φ(z|x;θ)是一个概率分布,其每个元素对应隐含变量Z的一个维度。这个分布基于已有的模型参数θ,用于估计输入数据X对应隐含变量Z的后验概率分布。当输入数据X的所有维度都被编码到隐含变量Z的某个维度时,q_φ(z|x;θ)将形成一个参数化的正态分布概率密度函数。

在下文中,q_{\phi}(z|x;\theta)可以表示为正态分布的形式,具体如下:

q_{\phi}(z|x;\theta)=\mathcal{N}(\mu_{\phi}(x),\Sigma_{\phi}(x))=\frac{1}{\sqrt{(2\pi)^k\vert\Sigma_{\phi}(x)\vert}}\exp(-\frac{1}{2}(z-\mu_{\phi}(x))^\top\Sigma^{-1}_{\phi}(x)(z-\mu_{\phi}(x)))

其中,\mu_{\phi}(x)\Sigma_{\phi}(x)分别代表隐含变量Z的期望值和协方差矩阵,k表示隐含变量Z的维度。

通过VAE,可以实现对输入数据X的编码过程,并基于条件概率模型q_{\phi}(z|x;\theta)生成潜在空间中的样本点。

1.2.2.2 学习阶段(Learning Stage)

在学习阶段,VAE基于已知的隐含变量Z及其对应的观测样本集X,来估计参数θ的值。具体而言,VAE通过设定优化目标,即最大化观测样本集X的对数似然likelihood,这一目标可通过以下两个公式表达:其中,L_{\theta}(X)表示观测样本集X的似然度,C为权重矩阵,y代表观测样本集XD为数据维度,m为隐含变量Z的维数。

VAE中的似然度函数可以被理解为观测数据X与隐含变量Z之间某种形式的"接近程度",基于此,VAE的学习目标是通过参数θ的不断优化,使得这一"接近程度"得以最大化,从而使似然度尽可能趋近于真实分布p_{\theta}(x)。在VAE的训练过程中,我们实际上是在确定一个最优的C矩阵,使其与向量z的线性变换后与目标y之间的差异趋近于零的同时,最小化\ell_2范数。值得注意的是,\ell_2范数反映了数据分布的稀疏性,因此在优化过程中,通过施加惩罚项来降低模型复杂度是十分有效的策略。

在VAE的学习过程中,当隐含变量Z与输入数据X之间存在某种关联时,例如Z仅与X中的一个维度相关联,我们可以通过施加相应的限制条件来优化模型参数θ。然而,通常情况下,我们无需过分关注这些约束,因为隐含变量Z所包含的信息量通常远超单个维度的影响。此外,当输入数据X的维度远高于隐含变量Z的维度时,直接优化所有隐含变量的取值范围变得不可行。因此,VAE所学到的模型参数通常呈现出一种"因子分解"的结构。

1.2.2.3 期望风险极小化(ELBO)

在掌握了推断阶段和学习阶段的相关知识后,接下来我们将阐述VAE体系中一个至关重要的概念——期望风险极小化。VAE的学习目标在于在推断阶段寻求最优的编码方式,以使后验概率分布p_{\theta}(z|x)达到最大值,而这一后验概率分布的计算则依赖于学习阶段中所获得的似然度信息。那么,我们该如何实现这一目标呢?答案就是引入另一个关键概念——期望风险极小化(Evidence Lower Bound,ELBO)。

VAE的推断阶段的目的是找到一种编码方式,将输入数据X映射到隐含变量Z的分布。但事实上,显然不是所有的编码方式都能成功地生成有效的隐含变量,因此VAE还要额外设计一个损失函数来评价不同编码方式的质量。VAE的ELBO公式如下所示:

\begin{aligned} \mathop{E}_{\theta}\left[log p_{\theta}(x^{(i)})+\sum_{l=1}^{L-1}\int q_{\phi}(z_{l+1}|z_l,x^{(i)};\theta)log \frac{p_{\theta}(x_{l+1}|z_{l+1},z_l)p_{\theta}(z_{l}|z_l;w_l)}{q_{\phi}(z_{l+1}|z_l,x^{(i)};\theta)}\mathrm{d}z_{l}\right]&\geq \mathcal{L}_{\theta}(x)\ &=-\frac{1}{K}\sum_{k=1}^Kp_{\theta}(x^{(k)}|\omega_k)-KL(q_{\phi}(z|x^{(i)};\theta)||p(z))\ KL(q||p)&=\int q(z)\log\frac{q(z)}{p(z)}\mathrm{d}z-\int q(z)\log q(z)\mathrm{d}z\end{aligned}

其中,\omega_k是第k次采样得到的观测数据xK是采样次数。q_{\phi}(z_{l+1}|z_l,x^{(i)};\theta)表示隐含变量Z_{l+1}的条件分布,它由编码器q_{\phi}和隐含变量Z_l和输入数据X共同决定。w_lp_{\theta}(z_{l+1}|z_l,x^{(i)};\theta)表示隐含变量Z_{l+1}和其对应的生成分布。

VAE的证据下界(ELBO)等于观测数据的对数似然度与一个关于期望推断误差(Expected Inference Error,EIE)的正则化项之和。EIE由两个部分构成:第一部分是KL散度,第二部分是隐含变量间的交叉熵误差。因此,VAE的目标就是找到一种编码策略,以使这个证据下界最大化。

至此,我们已经全面阐述了VAE的核心理论。接下来,我们选择MNIST图像数据集作为案例,系统地讲解VAE的基本概念、专业术语、算法机制以及代码实现步骤。

2. VAE基本概念、术语、算法原理及代码实现

2.1 VAE的基本概念、术语及意义

2.1.1 概念

VAE(变分自编码器)是一种无监督学习框架,其核心概念是通过隐含变量捕捉输入数据的统计特性,并假设先验分布作为真实分布的近似。在训练过程中,VAE通过最大化证据下界来进行参数优化,并利用梯度下降方法最小化该下界。具体而言,VAE由编码器和解码器两个主要组件构成。编码器的作用是推断隐含变量Z的后验分布,而解码器则负责根据推断出的隐含变量Z生成新的样本数据。

2.1.2 名词解释

  • 参数(Parameters):模型的训练参数体系,由可学习的模型参数和固定模型的超参数体系共同构成。
  • 编码器(Encoder):将输入数据X映射至隐含变量Z的条件概率分布q_φ(Z|X;θ),其中Z为隐含变量,X为输入数据,θ为参数,φ为编码器参数,q_φ(Z|X;θ)表示隐含变量Z关于输入数据X的条件概率分布。
  • 解码器(Decoder):生成器模型,基于隐含变量Z生成样本X',其中X'为输入数据X生成的样本。
  • 推断(Inference):基于已知参数θ,通过输入数据X生成隐含变量Z的条件概率分布q_φ(Z|X;θ)。
  • 潜变量(Latent Variable):由潜在变量集合构成的潜在表示向量,通常在机器学习领域称为隐变量或隐藏变量。
  • 再现性(Reproducibility):在相同初始条件下获得相同结果的能力。在机器学习中,此能力特指在相同初始条件下重复获得相同预测结果。
  • 维度(Dimensionality):特征空间的维度数。
  • 流形(Manifold):具有局部几何特性的流形结构,通常指高维空间中的部分区域。
  • 混淆矩阵(Confusion Matrix):用于评估分类模型性能的矩阵,其中行表示实际分类,列表示预测类别。
  • 特征向量(Feature Vector):由样本固定属性提取而形成的向量,用于表征样本特征。

2.1.3 术语

  • 高斯分布(Gaussian Distribution):高斯分布是数学上由联合正态分布(Joint Normal Distribution)或叫高斯混合模型(Gaussian Mixture Model)所表示的连续型随机变量。
  • 概率密度函数(Probability Density Function,PDF):概率密度函数描述了随机变量的概率密度。
  • 矩估计(Moment Estimation):用已知的随机变量的样本来估计其数学期望。
  • 欧拉准则(Euler’s Formula):欧拉准则是指关于希腊字母中的lambda、sigma和rho的一条公式,定义了函数f的泰勒级数的收敛速率。
  • 凸函数(Convex Function):在区间上具有一阶导数的函数,并且在该区间的任一点处的值都是极小值。
  • 对数似然(Log Likelihood):对数似然是指给定模型参数\theta和观测数据X,模型的对数似然函数的期望值,即P(X|\theta)。
  • 维数(Dimensionality):特征空间的维度。
  • 拉普拉斯分布(Laplace Distribution):拉普拉斯分布是具有两个参数的连续型随机变量的分布,其中第一个参数是位置参数μ,第二个参数是尺度参数λ。在分布中,随机变量的概率密度函数的形式与钟形曲线类似,尖峰比例趋于无穷大。
  • 混合高斯分布(Mixture of Gaussians):混合高斯分布是指由多组高斯分布组成的分布,每个高斯分布的权重都不同且相等。
  • 卡方分布(Chi-squared distribution):卡方分布是一种广泛使用的非负连续分布。
  • 惩罚项(Penalty Term):惩罚项是指通过对参数加以限制来防止模型过于复杂,使之不能很好的拟合训练数据。

2.2 VAE的算法原理

VAE的算法原理比较复杂,为了方便理解,我们可以分步进行分析。

2.2.1 VAE推断阶段

VAE的推断阶段主要目的是建立一种编码机制,将输入数据X映射至隐含变量Z的分布。具体而言,它需要学习估计先验分布和后验分布。

  1. q_{\phi}(Z|X;\theta)被定义为隐含变量Z在输入数据X上的条件概率分布。
  2. p(X|Z;\beta)被定义为生成模型,即由隐含变量Z生成样本X'的概率分布。

假定隐含变量Z服从高斯分布:

那么,就有:

即生成模型是依靠Z生成样本的概率分布。

编码器主要负责识别输入数据X的特征,并推导出潜在变量Z的概率分布模型。编码器网络通过分析输入数据的特征,推导出潜在变量的概率分布模型,从而构建了联合概率分布的数学表达式。

Z的分布为由输入数据X生成的样本X'的条件概率分布。

2.2.2 VAE学习阶段

在VAE的学习过程中,隐含变量Z被假设为服从高斯分布,但其分布受到限制。具体而言,我们对Z的均值和方差施加了限制,使其满足先验分布的要求。数学表达式为:Z \sim \mathcal{N}(\mu, \sigma^2),其中\mu\sigma^2被约束以符合先验分布的条件。

这两个分布分别代表Z的均值和方差的先验分布。其中,\mu_r\sigma^2_r分别表示Z的真实均值和方差,而\alpha\beta则作为Z方差分布的超参数。基于先验分布的限制条件,可以得到:

然后,我们可以最大化以下的对数似然:

该方法在实现目标方面表现出显著的效果。具体而言,该公式通过数学推导实现了对数据分布的优化。具体而言,该方法通过构建高效的变分推断框架,实现了对复杂数据的生成建模。具体而言,该损失函数设计巧妙地平衡了生成过程与判别过程的优化。具体而言,该模型通过端到端的学习策略,实现了对多模态数据的联合建模。具体而言,该算法通过梯度下降方法实现了参数的最优估计。

其中,x^{(i)}代表第i个观测数据,z_j即第j个隐含变量,而w_l则表示隐含变量z_l的生成分布。通过求解这一问题,我们采用变分推断方法,即假设隐含变量Z遵循一个参数化的分布q_{\phi}(Z|X;\theta)。进而通过最大化对数似然函数,我们能够获得该分布的最优参数。进而可以表示为:

通过该公式,我们可以推导出隐含变量Z的后验分布p_{\theta}(Z|X;\theta)

此时,我们还可以得到生成模型p_{\theta}(X|Z;\beta)

2.2.3 ELBO

最后,我们可以得到VAE的整体损失函数,即ELBO:

该模型通过最大化数据生成的对数似然来实现对参数θ的优化,具体包括三个部分:第一部分是条件分布pθ(x(i)|z(i);β)的对数似然期望;第二部分是条件分布pθ(z_{l+1}|z_l;θ)的对数似然期望;第三部分是KL散度的负期望。此外,模型还通过最小化重构误差来进一步提升生成质量。

在该模型中,\beta代表参数的先验分布,这里跳过了公式中的某些符号。基于此,我们可以利用梯度下降方法来寻找最优解。

2.3 VAE的代码实现

下面我们使用TensorFlow来实现VAE模型,并用MNIST数据集进行训练。

2.3.1 准备数据

首先,我们导入相关模块,加载MNIST数据集。

复制代码
    import tensorflow as tf
    from tensorflow import keras
    from sklearn.model_selection import train_test_split
    
    # Load MNIST dataset
    mnist = keras.datasets.mnist
    (train_images, _), (test_images, _) = mnist.load_data()
    
    # Normalize the images
    train_images = train_images / 255.0
    test_images = test_images / 255.0
    
    # Add channel dimension and flatten the images
    train_images = train_images.reshape((len(train_images), 28, 28, 1)).astype('float32')
    test_images = test_images.reshape((len(test_images), 28, 28, 1)).astype('float32')
    
      
      
      
      
      
      
      
      
      
      
      
      
      
      
    
    代码解读

2.3.2 定义模型

复制代码
    class CVAE(tf.keras.Model):
    def __init__(self, latent_dim):
        super().__init__()
    
        self.latent_dim = latent_dim
        self.encoder = tf.keras.Sequential([
            tf.keras.layers.Conv2D(filters=32, kernel_size=3, strides=(2, 2), activation='relu', input_shape=(28, 28, 1)),
            tf.keras.layers.Conv2D(filters=64, kernel_size=3, strides=(2, 2), activation='relu'),
            tf.keras.layers.Flatten(),
            # No activation
            tf.keras.layers.Dense(latent_dim + latent_dim)])
    
    @staticmethod
    def sampling(args):
        """
        Reparameterization trick by performing random walk over the space of Z
        """
        mean, logvar = args
        epsilon = tf.random.normal(shape=mean.shape)
        return mean + tf.exp(0.5 * logvar) * epsilon
    
    def call(self, inputs):
        features = self.encoder(inputs)
        mean, logvar = tf.split(features, num_or_size_splits=2, axis=1)
        z = self.sampling((mean, logvar))
        reconstructed = self.decoder(z)
        return reconstructed
    
    def decoder(self, z):
        dense1 = tf.keras.layers.Dense(units=7*7*32, activation='relu')(z)
        reshape1 = tf.keras.layers.Reshape(target_shape=(7, 7, 32))(dense1)
        conv1 = tf.keras.layers.Conv2DTranspose(filters=64,
                                                 kernel_size=3,
                                                 strides=(2, 2),
                                                 padding="SAME",
                                                 activation='relu')(reshape1)
        conv2 = tf.keras.layers.Conv2DTranspose(filters=32,
                                                 kernel_size=3,
                                                 strides=(2, 2),
                                                 padding="SAME",
                                                 activation='relu')(conv1)
        output = tf.keras.layers.Conv2DTranspose(filters=1,
                                                  kernel_size=3,
                                                  strides=(1, 1),
                                                  padding="SAME")(conv2)
        return output
    
    # Define hyperparameters
    batch_size = 128
    num_epochs = 10
    latent_dim = 2
    
    # Create an instance of our model
    vae = CVAE(latent_dim)
    
    # Compile the model
    optimizer = tf.keras.optimizers.Adam(learning_rate=1e-4)
    loss_object = tf.keras.losses.BinaryCrossentropy(from_logits=False)
    
    def loss_function(real, pred):
    reconstruction_loss = loss_object(real, pred)
    kl_loss = -0.5 * tf.reduce_sum(1 + vae.encoder.output_log_variance -
                                    tf.square(vae.encoder.output_mean) -
                                    tf.exp(vae.encoder.output_log_variance), 1)
    total_loss = tf.reduce_mean(reconstruction_loss + kl_loss)
    return {'loss': total_loss,'reconstruction_loss': reconstruction_loss, 'kl_loss': kl_loss}
    
    vae.compile(optimizer=optimizer, loss=loss_function)
    
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
    
    代码解读

2.3.3 训练模型

复制代码
    # Split training data into validation set
    validation_images = test_images[:500]
    validation_labels = None
    train_images, test_images = train_test_split(train_images, test_size=0.2, shuffle=True, random_state=42)
    
    checkpoint_path = "./checkpoints"
    ckpt = tf.train.Checkpoint(step=tf.Variable(0), optimizer=optimizer, net=vae)
    manager = tf.train.CheckpointManager(ckpt, checkpoint_path, max_to_keep=3)
    
    ckpt.restore(manager.latest_checkpoint).expect_partial()
    if manager.latest_checkpoint:
      print("Restored from {}".format(manager.latest_checkpoint))
    else:
      print("Initializing from scratch.")
    
    # Train the model on the training data
    history = {}
    for epoch in range(num_epochs):
    avg_loss = []
    for step, image_batch in enumerate(train_dataset):
        if len(image_batch) == batch_size:
            with tf.GradientTape() as tape:
                predictions = vae(image_batch)[0]
                loss = loss_function(image_batch, predictions)['loss']
    
            grads = tape.gradient(loss, vae.trainable_weights)
            optimizer.apply_gradients(zip(grads, vae.trainable_weights))
    
            avg_loss.append(loss)
    
        if step % 100 == 99 or step == len(train_dataset)-1:
            template = "Epoch {}, Step {}, Loss: {:.4f}"
            print(template.format(epoch+1,
                                  step+1,
                                  np.average(avg_loss)))
    
    val_reconstructions = vae(validation_images)[0].numpy().squeeze()
    
    fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(10,3))
    axes[0].imshow(validation_images[0], cmap='gray')
    axes[0].set_title('Original Image')
    axes[1].imshow(val_reconstructions[0], cmap='gray')
    axes[1].set_title('Reconstructed Image')
    plt.show()
    
    # Save the trained model
    os.makedirs('./saved_models/', exist_ok=True)
    vae.save("./saved_models/vae")
    print("Saved VAE model at./saved_models/vae")
    
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
      
    
    代码解读

全部评论 (0)

还没有任何评论哟~