神经网络变得轻松(第三十部分):遗传算法

进化优化方法被称为无梯度方法。 它们允许优化以前研究过的方法无法优化的模型。 然而,还有许多其它应用方式。 有时,观察如何利用进化方法和其它方法训练模型很有趣,这意味着误差梯度下降算法的应用。

该方法的主要思路是从自然科学中借鉴而来的。 特别是来自达尔文的进化论。 根据这一理论,任何生物种群都足以生育后代,并令种群增长。 但有限的生活资源限制了种群增长。 这就是自然选择发挥关键作用的地方。 这意味着适者生存。 即,那些最适应环境的个体才能幸存下来。 因此,每一代,种群都会更好地发展和适应环境。 种群成员发展出新的属性和能力,帮助它们生存。 此外,它们忘记了一切无关紧要的事情。

但是这种理论上的高度简洁概括不包含任何数学。 当然,可以根据可用资源的总数及其消耗,来计算最大可能的种群规模。 然而,这并不影响该理论的一般原则。

正是由该理论作为构成整个进化方法家族的原型。 在本文中,我建议熟悉遗传优化算法。 它是进化方法的基本算法之一。 该算法基于达尔文进化论的两个主要假设:遗传和自然选择。

该方法的本质是观察种群的每一代,并选择其最佳代表。 但首要之事依然是首要。

由于我们观察的是整个种群,因此基本要求是每一代的寿命有限。 与之前研究的强化学习算法类似,这里的过程必须是有限的。 好了,在此我们将采用相同的方法。 特别是,场次的时间有限。

如上所述,我们将观察整个种群。 因此,与之前讨论的算法不同,我们创建的不是一个模型,而是整个种群。 种群在相同的条件下同时“生存”。 种群规模是一个超参数,它决定了种群探索环境的能力。 每个种群成员都根据其个体政策采取行动。 相应地,观察的种群越大,我们观察到的不同策略就越多。 相应地,环境研究越好。

这个过程可与强化学习期间相同状态下代理者动作的重复随机选择进行比较。 但是现在我们同时使用多个代理者,每个代理者都有自己的选择。

使用独立总体种群成员便于并行化优化过程。 最常用的,为了减少最佳模型搜索时间,优化过程在多台机器上利用所有可用资源并行运行。 在这种情况下,种群的每个成员都“生存在”自己的微处理器线程当中。 整个优化过程由节点机控制和处理,节点机评估每个代理者的结果,并生成新的种群。

自然选择是在一代个体场次结束后执行的。 这个过程从整个种群中选择最好的代表,然后繁衍后代。 这意味着它们产生了新一代的种群。 最佳代表的数量是一个超参数,通常表示为总种群规模的比例。

选择最佳代表的准则取决于优化过程的架构。 例如,它们可以使用奖励,就像我们在强化学习中所做的那样。 备选项是,它们可以像监督学习一样使用损失函数。 相应地,我们将选择具有最大总奖励或损失函数最小值的代理者。

请注意,我们没有使用误差梯度。 因此,我们可以利用不可微函数来选择最佳代表。

在为未来的后代选择亲本之后,我们需要创造新一代的种群。 为此,我们从选定的最佳代表中随机选择几个模型 — 它们将作为新模型的亲本模型。 选择一对来创建新模型是不是象征性的?

在创建新模型的过程中,其所有参数都被视为染色体。 每个单独的权重都是遗传自亲本之一的单独基因。

继承算法也许会不同,但它们都基于两个规则:

  • 每个基因都不会改变其位置
  • 随机选择每个基因的亲本

我们可以为新一代种群的每个成员随机选择亲本,或者我们可以创建一对具有基因镜像遗传的代理者。

循环重复这个过程,直到新一代种群完全填满。 在新一代种群中不包括以前选择的亲本。 它们在产生后代后即被删除。

针对新的一代,我们开始一个新场次,并重复优化过程。

请注意,我故意说“优化”而不是“学习”。 上述过程与学习几乎没有相似之处。 这是进化过程中的纯粹自然选择。 如您所知,进化过程中有各种突变,但并不常见。 但它们是进化过程中不可或缺的一个环节。 故此,我们还会在优化过程中增加一些不确定性。

这听起来也许很奇怪。 在优化过程中,几乎所有内容都基于随机选择。 首先,我们随机生成第一个种群。 然后我们随机选择亲本。 最后,我们随机复制模型参数。 但所有这些随机性背后并无新鲜感。 由此,我们通过突变来增加新颖性。

我们在优化过程中添加另一个超参数,它将负责产生一些突变。 它替代复制,表示将随机基因添加到新后代的概率。 换言之,群体中的每个新成员都接受一个具有突变参数概率的随机基因,替代从亲本那里继承。 因此,除了继承自亲本之外,每一代都会引入新的东西。 这与我们的发展有最大相似之处。

2. 利用 MQL5 实现

在研究了算法的理论层面之后,我们继续讨论本文的实施部分。 我们将利用 MQL5 实现所研究的算法。 当然,所提出的算法中几乎不含数学。 但它还有别的东西 — 清晰的构建的一套动作算法。 这就是我们即将实现的。

我们之前构建的模型不适合解决此类问题。 在构建处理神经网络的 CNet 类时,我们希望仅使用单个线性模型。 这次,我们需要实现几个线性模型的并行运算。 有两种途径可以解决此问题。

第一个对程序员来说劳动密集度较低,但资源密集度较高:我们可以简单地创建一个动态的对象数组,在其中创建几个相同的模型。 然后,我们交替地从数组中提取模型,并逐个处理它们。 在此变体中,每个单独模型的所有操作都将在现有功能的框架内实现。 我们只需要实现选择亲本,并产生新一代的方法,以及代理者选择过程。

此方法的缺点包括资源消耗过高,以及需要创建大量额外对象。 对于每个代理者,我们需要创建一个单独的类实例来处理 OpenCL 关联环境。 除此之外,我们还要创建了一个单独的关联环境,包括程序和所有内核对象的副本。 当并行使用多个计算设备时,这是可以接受的。 否则,创建额外的对象会导致资源使用效率低下,并严重限制种群规模。 这反过来又会对优化过程的结果产生负面影响。

故此,我决定修改我们的类,从而可操控神经网络模型。 但为了不破坏工作流程,我创建了一个新类 CNetGenetic,该类继承自公开类 CNet

class CNetGenetic : public CNet
  {
protected:
   uint              i_PopulationSize;
   vector            v_Probability;
   vector            v_Rewards;
   matrixf           m_Weights;
   matrixf           m_WeightsConv;

   //---
   bool              CreatePopulation(void);
   int               GetAction(CBufferFloat * probability);
   bool              GetWeights(uint layer);
   float             NextGenerationWeight(matrixf &array, uint shift, vector &probability);
   float             GenerateWeight(uint total);

public:
                     CNetGenetic();
                    ~CNetGenetic();
   //---
   bool              Create(CArrayObj *Description, uint population_size);
   bool              SetPopulationSize(uint size);
   bool              feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true);
   bool              Rewards(CArrayFloat *targetVals);
   bool              NextGeneration(double quantile, double mutation, double &average, double &mamximum);
   bool              Load(string file_name, uint population_size, bool common = true);
   bool              SaveModel(string file_name, int model, bool common = true);
   //---
   bool              CopyModel(CArrayLayer *source, uint model);
   bool              Detach(void);
  };

我将提供方法用途及其实现的解释。 现在,我们来看一下变量:

  • i_PopulationSize — 种群规模
  • v_Probability — 选择模型作为亲本的概率的向量
  • v_Rewards — 由每个独立模型累积的总奖励的向量
  • m_Weights — 记录所有模型参数的矩阵
  • m_WeightsConv — 记录卷积神经层所有参数的类似矩阵

在类构造函数中,我们只初始化上述变量。 在此,我们设置默认的种群规模,并调用更改相应变量的方法。

CNetGenetic::CNetGenetic() :  i_PopulationSize(100)
  {
   SetPopulationSize(i_PopulationSize);
  }

该类未用到其它对象的实例。 因此,类的析构函数保持为空。

我们已经提到了指定种群规模的方法 SetPopulationSize。 它的算法非常简单。 在参数中,该方法接收种群规模。 在方法的主体中,我们将接收到的值保存在相应的变量中,并用零值初始化概率和奖励的向量。

bool CNetGenetic::SetPopulationSize(uint size)
  {
   i_PopulationSize = size;
   v_Probability = vector::Zeros(i_PopulationSize);
   v_Rewards = vector::Zeros(i_PopulationSize);
//---
   return true;
  }

接下来,我们看一下 Create 类对象初始化方法。 与父类的类似方法类似,该方法在参数中接收指向一个代理者的描述对象的指针。 我们还添加了种群规模。

bool CNetGenetic::Create(CArrayObj *Description, uint population_size)
  {
   if(CheckPointer(Description) == POINTER_INVALID)
      return false;
//---
   if(!SetPopulationSize(population_size))
      return false;
   CNet::Create(Description);
   return CreatePopulation();
  }

在方法主体中,我们首先检查收到的指向模型架构描述对象指针的有效性。 验证成功后,调用已知方法来指定种群规模。

接下来,调用父类的类似方法,在其中将根据收到的描述创建一个代理者,并初始化所有其它对象。

且最后,调用种群创建方法 CreatePopulation,在其中通过复制先前创建的模型来填充种群。 我们仔细看看该方法的算法。

在方法开始时,我们检查所创建模型中的神经层数。 必须至少有两层。

bool CNetGenetic::CreatePopulation(void)
  {
   if(!layers || layers.Total() < 2)
      return false;

接下来,将指向源数据神经层的指针保存到局部变量当中。

   CLayer *layer = layers.At(0);
   if(!layer || !layer.At(0))
      return false;
//---
   CNeuronBaseOCL *neuron_ocl = layer.At(0);
   int prev_count = neuron_ocl.Neurons();

请注意,第一神经层仅用于记录源数据。 我们种群的所有代理者都将取用相同的源数据。 因此,按种群中的代理者数量复制源数据层是没有意义的。 神经层的复制从索引为 1 的下一个神经层开始。

我们回顾一下神经网络对象的结构。 CNet 类负责在顶层组织模型的操作。 它包含神经层动态数组的 CArrayLayer 对象实例。 在这个动态数组中,我们直接从 CLayer 神经层存储指向嵌套动态数组对象的指针。 在那里,我们将编写指向神经元对象 CNeuronBaseOCL 和其它对象的指针。

CNet -> CArrayLayer -> CLayer -> CNeuronBaseOCL

此结构最初是在我们在 CPU 上以 MQL5 实现计算过程时创建的。 每个单独的神经元都是一个单独的对象。 后来,当我们利用 OpenCL 技术将计算转移动到 GPU 上时,我们被迫采用数据缓冲区。 实际上,每个神经层都由一个 CNeuronBaseOCL 神经元中表达,其执行神经层的功能。 这同样适用于其它类型的神经元。

因此,CLayer 神经层的每个对象现在只包含一个神经元对象。 以前,我们没有更改数据存储体系结构,这是为了保持与之前版本的兼容性。 这个事实现在有另一层重要性。 我们将简单地把所需数量的对象添加到 CLayer 动态数组当中,以便存储我们的整个代理者种群。 因此,在一个模型中,我们拥有的并行对象涵盖我们的群体中所有代理者的神经层。 如此,我们只需要根据相应的代理者索引来实现它们的操作。

遵循这个逻辑,我们随之创建一个循环来复制神经层。 在这个循环中,我们将遍历模型的所有神经层,并添加所需数量的神经元,类似于之前在每一层中创建的第一个神经元。

在循环体中,我们首先检查指向之前所创建神经层指针的有效性。

   for(int i = 1; i < layers.Total(); i++)
     {
      layer = layers.At(i);
      if(!layer || !layer.At(0))
         return false;
      //---

然后获取神经元结构的描述。

      neuron_ocl = layer.At(0);
      CLayerDescription *desc = neuron_ocl.GetLayerInfo();
      int outputs = neuron_ocl.getConnections();

创建类似的对象,并将神经层填充到所需的种群规模。 为此目的,我们需要创建另一个嵌套循环。

      for(uint n = layer.Total(); n < i_PopulationSize; n++)
        {
         CNeuronConvOCL *neuron_conv_ocl = NULL;
         CNeuronProofOCL *neuron_proof_ocl = NULL;
         CNeuronAttentionOCL *neuron_attention_ocl = NULL;
         CNeuronMLMHAttentionOCL *neuron_mlattention_ocl = NULL;
         CNeuronDropoutOCL *dropout = NULL;
         CNeuronBatchNormOCL *batch = NULL;
         CVAE *vae = NULL;
         CNeuronLSTMOCL *lstm = NULL;
         switch(layer.At(0).Type())
           {

            case defNeuron:
            case defNeuronBaseOCL:
               neuron_ocl = new CNeuronBaseOCL();
               if(CheckPointer(neuron_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_ocl.Init(outputs, n, opencl, desc.count, desc.optimization, desc.batch))
                 {
                  delete neuron_ocl;
                  return false;
                 }
               neuron_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_ocl))
                 {
                  delete neuron_ocl;
                  return false;
                 }
               neuron_ocl = NULL;
               break;

            case defNeuronConvOCL:
               neuron_conv_ocl = new CNeuronConvOCL();
               if(CheckPointer(neuron_conv_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_conv_ocl.Init(outputs, n, opencl, desc.window, desc.step, desc.window_out,
                                                           desc.count, desc.optimization, desc.batch))
                 {
                  delete neuron_conv_ocl;
                  return false;
                 }
               neuron_conv_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_conv_ocl))
                 {
                  delete neuron_conv_ocl;
                  return false;
                 }
               neuron_conv_ocl = NULL;
               break;

            case defNeuronProofOCL:
               neuron_proof_ocl = new CNeuronProofOCL();
               if(!neuron_proof_ocl)
                  return false;
               if(!neuron_proof_ocl.Init(outputs, n, opencl, desc.window, desc.step, desc.count,
                                                                   desc.optimization, desc.batch))
                 {
                  delete neuron_proof_ocl;
                  return false;
                 }
               neuron_proof_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_proof_ocl))
                 {
                  delete neuron_proof_ocl;
                  return false;
                 }
               neuron_proof_ocl = NULL;
               break;

            case defNeuronAttentionOCL:
               neuron_attention_ocl = new CNeuronAttentionOCL();
               if(CheckPointer(neuron_attention_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_attention_ocl.Init(outputs, n, opencl, desc.window, desc.count, desc.optimization, desc.batch))
                 {
                  delete neuron_attention_ocl;
                  return false;
                 }
               neuron_attention_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_attention_ocl))
                 {
                  delete neuron_attention_ocl;
                  return false;
                 }
               neuron_attention_ocl = NULL;
               break;

            case defNeuronMHAttentionOCL:
               neuron_attention_ocl = new CNeuronMHAttentionOCL();
               if(CheckPointer(neuron_attention_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_attention_ocl.Init(outputs, n, opencl, desc.window, desc.count, desc.optimization, desc.batch))
                 {
                  delete neuron_attention_ocl;
                  return false;
                 }
               neuron_attention_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_attention_ocl))
                 {
                  delete neuron_attention_ocl;
                  return false;
                 }
               neuron_attention_ocl = NULL;
               break;

            case defNeuronMLMHAttentionOCL:
               neuron_mlattention_ocl = new CNeuronMLMHAttentionOCL();
               if(CheckPointer(neuron_mlattention_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_mlattention_ocl.Init(outputs, n, opencl, desc.window, desc.window_out,
                                               desc.step, desc.count, desc.layers, desc.optimization, desc.batch))
                 {
                  delete neuron_mlattention_ocl;
                  return false;
                 }
               neuron_mlattention_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_mlattention_ocl))
                 {
                  delete neuron_mlattention_ocl;
                  return false;
                 }
               neuron_mlattention_ocl = NULL;
               break;

添加对象的算法类似于在父类中创建新对象。

一个神经网络的所有种群元素一旦添加完毕后,把层数与种群规模对齐,并删除神经元描述对象。

        }
      if(layer.Total() > (int)i_PopulationSize)
         layer.Resize(i_PopulationSize);
      delete desc;
     }
//---
   return true;
  }

一旦循环系统的所有迭代都完成,我们将在单个模型实例中获得全部种群,并以正面结果退出该方法。

此方法和整个类的完整代码均可在附件中找到。

方法完成 CNetGene 类对象的初始化之后,即开始定义前馈方法。 它的名称和参数重复父类方法中所用的内容。 它包含一个指向源数据动态数组对象的指针,以及用于创建源数据时间戳的参数。

在方法主体中,检查收到的指针和所用部对象的有效性。

bool CNetGenetic::feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true)
  {
   if(CheckPointer(layers) == POINTER_INVALID || CheckPointer(inputVals) == POINTER_INVALID || layers.Total() <= 1)
      return false;

准备局部变量。

   CLayer *previous = NULL;
   CLayer *current = layers.At(0);
   int total = MathMin(current.Total(), inputVals.Total());
   CNeuronBase *neuron = NULL;
   if(CheckPointer(opencl) == POINTER_INVALID)
      return false;
   CNeuronBaseOCL *neuron_ocl = current.At(0);
   CBufferFloat *inputs = neuron_ocl.getOutput();
   int total_data = inputVals.Total();
   if(!inputs.Resize(total_data))
      return false;

将源数据移到源数据层缓冲区,并将其写入 OpenCL 关联环境。 如有必要,添加时间戳。

   for(int d = 0; d < total_data; d++)
     {
      int pos = d;
      int dim = 0;
      if(window > 1)
        {
         dim = d % window;
         pos = (d - dim) / window;
        }
      float value = pos / pow(10000, (2 * dim + 1) / (float)(window + 1));
      value = (float)(tem ? (dim % 2 == 0 ? sin(value) : cos(value)) : 0);
      value += inputVals.At(d);
      if(!inputs.Update(d, value))
         return false;
     }
   if(!inputs.BufferWrite())
      return false;

之后,创建一个循环系统,为所分析种群的所有代理者实现前馈验算。 外循环将按升序遍历神经层。 嵌套循环将迭代遍历代理者。

请注意,在指定前一层的神经元时,我们必须明确控制代理者的对应关系。 每个代理者在其自己的垂直神经元中运行,这由层中神经元的序列号决定。 与此同时,我们没有复制原始数据层。 因此,在指定前一层对应神经元的索引时,我们首先检查神经层本身的序列号。 对于源数据层,前一层神经元的序列号将始终为 0。 对于所有其它层,它则对应于代理者的序列号。

由于所有代理者都是绝对独立的,因此我们可以针对所有代理者同时执行操作。

   for(int l = 1; l < layers.Total(); l++)
     {
      previous = current;
      current = layers.At(l);
      if(CheckPointer(current) == POINTER_INVALID)
         return false;
      //---
      for(uint n = 0; n < i_PopulationSize; n++)
        {
         CNeuronBaseOCL *current_ocl = current.At(n);
         if(!current_ocl.FeedForward(previous.At(l == 1 ? 0 : n)))
            return false;
         continue;
        }
     }
//---
   return true;
  }

当然,使用循环并不能提供计算的完全并行性。 但与此同时,我们将依次针对所有代理者实现类似的迭代。 这将允许所有代理者使用生成的源数据。 顺推之,这又会降低为每个单独的代理者准备源数据时的成本。

不要忘记控制每一步的结果。 一旦嵌套循环系统的所有迭代完成后,退出该方法。

遗传算法中不存在误差梯度的反向传播。 然而,我们需要评估模型的性能。 在本文中,我将优化上一篇文章中的代理者,我们运用策略梯度算法对其进行了训练。 为了优化模型的性能,我们将最大化模型每个场次的总奖励。 因此,我们必须在每个动作后将其奖励返还给每个代理者。 您还记得,奖励取决于所选的动作。 每个代理者执行自己的动作。 之前,我们从代理者那里收到执行动作的概率分布,从该分布中抽取一个操作,并将相关奖励返回给代理者。 现在我们有很多这样的代理者。 为了外部程序中的每个代理者不再重复这些迭代,我们将其包装在一个单独的 Rewards 方法中。 外部程序(环境)将在其参数中传递所有可能动作的奖励。 这种方式允许我们每个动作只评估一次,无论用到的代理者数量如何。

在方法主体中,首先检查指向参数中收到的奖励向量指针的有效性,以及神经层的动态数组。

bool CNetGenetic::Rewards(CArrayFloat *rewards)
  {
   if(!rewards || !layers || layers.Total() < 2)
      return false;

接下来,从动态数组中提取指向代理者结果层的指针,并检查收到的指针的有效性。

   CLayer *output = layers.At(layers.Total() - 1);
   if(!output)
      return false;

之后,创建一个循环来迭代和查询我们的种群中的所有代理者。 对于每个代理者,我们从相应的分布中采样一个动作。 取决于所选动作,代理者会收到其奖励,并被加到早前由代理者索引指定的 v_Rewards 向量中收到的奖励当中。

   for(int i = 0; i < output.Total(); i++)
     {
      CNeuronBaseOCL *neuron = output.At(i);
      if(!neuron)
         return false;
      int action = GetAction(neuron.getOutput());
      if(action < 0)
         return false;
      v_Rewards[i] += rewards.At(action);
     }

基于代理者的评估结果,我们可为代理者进入下一代亲本的数量制作概率分布。

   v_Probability = v_Rewards - v_Rewards.Min();
   if(!v_Probability.Clip(0, v_Probability.Max()))
      return false;
   v_Probability = v_Probability / v_Probability.Sum();
//---
   return true;
  }

然后以肯定结果退出方法。 下面的附件中提供了所有方法和类的完整代码。

所创建功能足以为实现分析所分析种群的每个单独场次,并评估代理者动作。 一旦场次结束,我们需要选出最好的代表,并产生新一代的种群。 此功能将在 NextGeneration 方法中实现。 在此方法的参数中,我们将传递两个超参数:要删除的个体比例,和突变参数。 此外,方法参数包含两个变量,我们将在其中返回所选代理者的平均和最大奖励。

在方法主体中,我们首先设置选择代理者的概率为零,除了已选中那个。 并计算所选候选者的最大奖励和加权平均值。

bool CNetGenetic::NextGeneration(double quantile, double mutation, double &average, double &maximum)
  {
   maximum = v_Rewards.Max();
   v_Probability = v_Rewards - v_Rewards.Quantile(quantile);
   if(!v_Probability.Clip(0, v_Probability.Max()))
      return false;
   v_Probability = v_Probability / v_Probability.Sum();
   average = v_Rewards.Average(v_Probability);

请注意,我们正在使用最近添加的向量运算。 多亏了它们,我们不必再用循环,程序代码也减少了。 vector::Max() 方法允许在一行中判定整个向量的最大值。 vector::Quantile(...) 方法返回向量的指定分位数的值。 我们依据此值来删除弱势代理者。 并在向量减法运算之后,它们的概率将变为负数。

调用 vector::Clip(0, vector::Max()) 函数,将向量的所有负值重置为零。

此外,优雅地,在一行中,我们将 0 到 1 之间的所有向量值归一化,所有元素的总值为 1。

v_Probability = v_Probability / v_Probability.Sum();

运算 vector::Average(weights) 判定向量的加权平均值。 weights 向量包含向量每个元素的权重。 早前,我们将弱势代理者的概率设置为零,因此在计算向量的加权平均值时不会考虑它们的值。

因此,向量运算的使用大大减少了程序代码,并方便了程序员的工作。 特别鸣谢 MetaQuotes 团队提供这些可能性! 有关向量和矩阵运算的详细信息,请参阅文档的相关部分。

但回到我们的方法。 我们已判定了候选者,及其概率。 现在,我们将突变比例添加到分布中,并重新计算概率。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值