深度学习初步,全连接神经网络,MLP从原理到实现(三)实现部分,用java实现MLP

原理部分完全参照另外2篇:

深度学习初步,全连接神经网络,MLP从原理到实现(一)原理部分,反向传播详细解释和实际计算例子

深度学习初步,全连接神经网络,MLP从原理到实现(二)原理部分,过拟合,激活函数,batchsize和epochs,训练DL模型的建议

说明:这个实现全部用java实现,依赖矩阵运算库jblas和画图的一个库jfree(不需要画学习率衰减曲线的可以不用这个库),无论是预测还是反向传播求梯度都是基于矩阵/向量运算的,这样更简洁明了。使用了多线程来并行的预测和训练,但是速度仍然比GPU跑的慢好多,项目 用eclipse构建,导入到eclipse可直接使用。完整源码见我的GitHub:https://github.com/colin0000007/MLP4j,如果觉得可以,点个star啊!!

这篇文章我会挑一些重要部分的源码说明,主要是反向传播,参数更新等。整体的源码设计参照了keras。使用new Sequential,然后添加layer,最后train,完整的例子参照源码中的MinistExample(测试Minist数据集)。

 

	public static void main(String[] args) {
		loadData();
		Sequential model = new Sequential();
		//必须首先设置Input
		model.setInput(new Input(x[0].length));
		model.addLayer(new Dense(400,ActivationFunction.RELU));
		model.addLayer(new Dense(400,ActivationFunction.RELU));
		model.addLayer(new Dense(10,SoftMax.SOFTMAX));
		System.out.println("x_train:("+x.length+","+x[0].length+")");
		//衰减可以使用默认的ExponentialLearningRateDecay.defaultDecay,默认lr初始0.2可以修改
		//model.trainParallel(x, y, 400, 20, ExponentialLearningRateDecay.defaultDecay);
		//衰减使用ExponentialLearningRateDecay.noDecay可以不进行lr的衰减,lr默认0.2可以修改
		//model.trainParallel(x, y, 400, 20, ExponentialLearningRateDecay.noDecay);
		ExponentialLearningRateDecay decay = 
				new ExponentialLearningRateDecay(2, 0.2, 0.8, false);
		model.trainParallel(x, y, 400, 20, decay);
		//以上参数迭代20个epoch后训练集acc=0.9860282
	}

好了,下面直接说说源码中比较重要的部分:

前向传播的源码:

	/**
	 * 前向传播计算a和z这两个list
	 * 每个list保存了每一层的a或者z
	 * 只计算单个样本
	 * 每个样本的forwardPropagation可以并行,单个样本的forwardPropagation矩阵运算可以并行
	 * @return
	 */
	public ArrayList<double[]>[] forwardPropagation(double[] x) {
		ArrayList<double[]>[] za = new ArrayList[2];
		za[0] = new ArrayList<>(layers.size());
		za[1] = new ArrayList<>(layers.size());
		double[] input = x;
		for(int i = 0; i < layers.size(); i++) {
			double[][] weights = layers.get(i).getWeights();
			double[] biases = layers.get(i).getBiases();
			DoubleMatrix w = new DoubleMatrix(weights);
			DoubleMatrix a = new DoubleMatrix(input);
			DoubleMatrix b = new DoubleMatrix(biases);
			// z = w*input + b
			DoubleMatrix zMatrix = w.mmul(a).addi(b);
			double[] zs = zMatrix.toArray();
			//计算这一层的输出
			ActivationFunction sigma = layers.get(i).getActivationFunction();
			double[] as = sigma.functionValue(zs);
			za[0].add(zs);
			za[1].add(as);
			input = as;
		}
		return za;
	}

这里的forwardPropagation是针对单个样本计算的,前向传播就是要计算一个样本输入经过每一层的z值和a值,所以这里就是遍历所有layer,计算z和a,z和计算直接对应原理部分的矩阵运算,w*input+b,w就是一个权重矩阵,b是偏置向量,参照Dense(全连接层)的设计:

/**
 * 全连接层
 * @author outsider
 *
 */
public class Dense extends Layer{
	//wij 指的时当前层第i个单元到上一层的j个单元连接的权重。
	//所以weights的行数是本层的单元个数,列数是上一层单元的个数
	private double[][] weights;
	//偏置
	private double[] biases;
	//当前层的单元个数
	private int units;
	//激活函数
	private ActivationFunction activationFunction;
	public Dense(int units, ActivationFunction activationFunction) {
		this.units = units;
		this.activationFunction  = activationFunction;
	}
	/**
	 * 初始化权重参数
	 * @param lastUnits
	 */
	public void init(int lastUnits) {
		this.biases = new double[units];
		//权重随机初始化
		this.weights = DoubleMatrix.randn(units, lastUnits).divi(10).toArray2();
		DoubleMatrix doubleMatrix = new DoubleMatrix();
	}
	
}

a的计算是把计算后的z送到对应层的激活函数中一个个计算a值。这里所有layer的a和z都要保存。保存z是因为\frac{\partial l}{\partial z}中计算\sigma (z)'需要用到,保存a在计算\frac{\partial l}{\partial w}会用到,和原理部分对应的。

反向传播计算\frac{\partial l}{\partial z}部分源码:

	/**
	 * 
	 * @param x 单个样本的x
	 * @param y 单个样本的y
	 * @return 每一层的梯度矩阵,注意是一个3维数组,每一个矩阵的尺寸不一样
	 */
	public Gradient backPropagation(double[] x, double[] y) {
		//(1) 前向传播
		ArrayList<double[]>[] za = forwardPropagation(x);
		ArrayList<double[]> z = za[0];
		ArrayList<double[]> a = za[1];
		//(2) 反向传播计算 partial L / partial Z,这直接是偏置的梯度
		double[][] LZ = new double[layers.size()][];
		//计算最后一层的 partial L / partial z
		LZ[LZ.length - 1] = layers.get(layers.size() - 1).getActivationFunction()
				.partialLoss2PartialZ(null, a.get(a.size() - 1), y);
		//计算其他层的 partial L / partial z 
		for(int i = LZ.length -2; i >=0; i--) {
			Dense layer = layers.get(i);
			//1.求激活函数的一阶导数
			double[] sigmaP = layer.getActivationFunction().firstDerivativeValue(z.get(i));
			//2.后一层的权重矩阵的转置乘以 partial L / partial z 向量
			DoubleMatrix m1 = new DoubleMatrix(layers.get(i + 1).getWeights()).
					transpose().mmul(new DoubleMatrix(LZ[i+1]));
			// 1和2得到的向量对应元素相乘即为结果
			LZ[i] = new DoubleMatrix(sigmaP).muli(m1).toArray();
		}
		//(3).计算梯度
		//partial L / partial Z的 列向量乘以上一层的输入a行向量
		double[] input = x;
		double[][][] gradients = new double[layers.size()][][];
		for(int i = 0; i < gradients.length; i++) {
			DoubleMatrix aVec = new DoubleMatrix(input);
			DoubleMatrix lzVec = new DoubleMatrix(LZ[i]);
			aVec = aVec.reshape(aVec.columns, aVec.rows);
			gradients[i] = lzVec.mmul(aVec).toArray2();
			input = a.get(i);
		}
		Gradient gradient = new Gradient(gradients, LZ);
		return gradient;
	}

计算\frac{\partial l}{\partial z}需要倒着来,最后一层的\frac{\partial l}{\partial z}在激活函数中被计算,也就是partialLoss2PartialZ,然后从倒数第二层开始计算,先求这一层的激活函数的一阶导数,这时候需要用到forwardPropagation中对应层的z值,计算后是一个double[] sigmaP向量,然后计算一个中间结果,后一层权重矩阵的转置和后一层\frac{\partial l}{\partial z}向量的乘积得到一个向量,最后将这2个向量对应元素相乘得到该层的\frac{\partial l}{\partial z},对应原理部分的公式:

迭代完所有的layer即可求得全部层的\frac{\partial l}{\partial z}

求梯度源码:(求梯度实在train中实现的所以直接看train,为了便于理解这里不看并行训练的方法)

/**
	 * 训练
	 * @param x 
	 * @param y 每一个标签必须one-hot编码,所以是二维数组
	 * @param lr 学习率
	 * @param batchSize 每次梯度下降的更新参数用到的样本个数
	 * @param epochs 在整个样本集上做多少次的训练
	 * @param lrDecay 学习率指数衰减
	 */
	public void train(double[][] x, double[][] y, 
			int batchSize, int epochs, ExponentialLearningRateDecay lrDecay) {
		//一个epoch中的各个batch可以并行,一个batch之间各单个样本之间可以并行
		double lr = lrDecay.lr;
		DoubleMatrix yM = new DoubleMatrix(y);
		int[] yTrue = yM.rowArgmaxs();
		this.classNum = layers.get(layers.size() - 1).getUnits();
		//1.划分样本
		int sampleNum = x.length;
		//分多少批
		int batchNum = sampleNum / batchSize;
		System.out.println("batchNum:"+batchNum);
		int left = sampleNum % batchSize;//样本余数
		//保存样本的索引位置
		List<Integer> sampleIndices = new ArrayList<>(x.length);
		for(int i = 0; i < sampleNum; i++) {
			sampleIndices.add(i);
		}
		// epoch循环
		for(int i = 0; i < epochs; i++) {
			//对学习率衰减
			lr = lrDecay.decayLR(i);
			System.out.println("epoch "+(i+1)+"/"+epochs+",lr="+lr);
			//batch循环
			for(int j = 0; j < batchNum; j++) {
				int c = left *((j+1)/batchNum);
				double[][] batchX = new double[batchSize + c][];
				double[][] bathcY = new double[batchSize + c][];
				System.out.println("epoch..."+(i+1)+"/"+epochs+",batch..."+(j+1)+"/"+batchNum+","+"batchSize="+batchX.length);
				int offset = j * batchSize;
				for(int k = j * batchSize; k < (j+1)*batchSize + c; k++) {
					batchX[k-offset] = x[sampleIndices.get(k)];
					bathcY[k-offset] = y[sampleIndices.get(k)];
				}
				//训练,一个一个样本计算梯度,这个过程可以同时进行。
				//将每个样本的梯度累加
				Gradient gradient = backPropagation(batchX[0], bathcY[0]);
				double[][][] totalWeightsGradient = gradient.weightsGradient;
				double[][] totalBiasesGradient = gradient.biasesGradient;
				for(int m = 1; m < batchX.length; m++) {
					Gradient gradient2 = backPropagation(batchX[m], bathcY[m]);
					double[][][] weightsGradient = gradient2.weightsGradient;
					double[][] biasesGradient = gradient2.biasesGradient;
					for(int f = 0; f < weightsGradient.length; f++) {
						totalWeightsGradient[f] = new DoubleMatrix(totalWeightsGradient[f]).
								addi(new DoubleMatrix(weightsGradient[f])).toArray2();
						totalBiasesGradient[f] = new DoubleMatrix(totalBiasesGradient[f]).
								addi(new DoubleMatrix(biasesGradient[f])).toArray();
					}
				}
				//梯度除以N
				for(int k = 0; k < layers.size(); k++) {
					totalBiasesGradient[k] = new DoubleMatrix(totalBiasesGradient[k])
							.divi(batchX.length).toArray();
					totalWeightsGradient[k] = new DoubleMatrix(totalWeightsGradient[k]).
							divi(batchX.length).toArray2();
				}
				//更新参数
				for(int f = 0; f < layers.size(); f++) {
					Dense dense = layers.get(f);
					double[][] oldW = dense.getWeights();
					double[] oldBia = dense.getBiases();
					// w = w - lr * gradient
					double[][] newW = new DoubleMatrix(oldW).
							subi(new DoubleMatrix(totalWeightsGradient[f]).mul(lr)).toArray2();
					double[] newBia = new DoubleMatrix(oldBia).
							subi(new DoubleMatrix(totalBiasesGradient[f]).mul(lr)).toArray();
					dense.setWeights(newW);
					dense.setBiases(newBia);
				}
				//每20倍批次输出一次训练信息
				/*if((j+1) % 20 == 0) {
					int[] yPredicted = predictAndReturnClassIndex(x);
					//训练集上的准确率
					float acc = accuracy(yPredicted, yTrue);
					System.out.println("\n"+(j+1)*batchSize + "/" + x.length+"........acc:"+acc+"\n");
				}*/
			}
			double[][] yPre = predictParallel(x);
			int[] yPredicted = new DoubleMatrix(yPre).rowArgmaxs();
			double loss = loss(yPre, y);
			float acc = accuracy(yPredicted, yTrue);
			String print = "epoch " +(i+1)+ " done"+"........acc:"+acc+",total loss:"+loss;
			System.out.println(print);
			//打乱数据位置
			//acc会有一些不稳定,估计也和这个操作有关系。
			//测试后发现,shuffle有利用梯度下降
			Collections.shuffle(sampleIndices);
		}
	}

重点看//batch循环中的代码,再说说epoch和batchsize,指定了epoch和batchsize和,需要对训练样本划分成多少个batch对应源码中的batchNum,然后每一个batch计算梯度,更新一次参数。训练过程中每次batch的划分后每个batch里的样本是随机的,sampleIndices保存了最初的样本索引位置,每一轮epoch这些位置被打乱,我已经验证过,打乱后的效果更好。接下来有了一个batch的数据,batchX和batchY就需要计算分别计算里面样本的梯度,先进行一次forwardPropagation然后backPropagation就有了a,z和\frac{\partial l}{\partial z},然后梯度的公式是:

\frac{\partial l}{\partial z}列向量乘以a向量的转置就可以得到梯度,最后将所有样本的梯度累加起来,最后更新梯度w=w-lr*gradient

这些过程只要理解了理论部分就能实现。注意这里梯度总和一定要除以样本的个数。我之前没有除出现了准确率一直维持再0.1左右。至于原因我大概猜测了下,如果不除以N,那么优化的总的loss,如果除以N那么优化平均loss,想象loss的是一个曲面,这时候从较高部分到较低部分地势相差很高,梯度下降学习率足够大才可能会有效果,如果优化平均loss,那么最大的loss值也不过是单个样本的最大的loss,远远小于多个样本的loss总和,这时候较高部分与低洼部分距离不会相差很大,所以优化时学习效率小一点依然效果显著。

预测过程和forwardPropagation一样,只是我们不需要保存中间层的a和z,只需要最后一层softmax的a向量作为结果。

感受:实际上我觉得只要理解了反向传播和梯度计算,就很简单,我觉得写起来比CRF那种模型还简单些。

下面是实现过程中的一些问题记录:

1.遇到的问题:nn参数初始化
如果参数初始太大,而且没有负数,会造成溢出的情况,
因为softmax作为输出层,里面存在着指数,输入太大则
会溢出。建议初始化较小的参数,并且必须有正负,比如
-0.25到0.25之间,关于参数初始化也有很大的学问,好的
参数初始化可以使收敛更快。

2.遇到的问题
//之前不work主要有两个原因:
//(1)梯度最后一步计算的矩阵化公式有错
//(2)损失函数没有除以N(我不知道为什么这个影响这么大,这直接导致了效果完全不行)
//目前存在的问题
//(1)效率不够高
//(2)收敛不够快,前面设置的参数情况下,epoch 2才达到0.82左右的acc
//3.23更新:
//使用了学习率指数衰减,效果好多了
//参数建议,如果epochs比较小,那么lr也要小一点
3.关于并行
之前使用call(),或者线程的join()来实现主线程等待子线程完成后继续
发现效果都不好,比串行都慢,但使用countDownLatch这个对象后发现
竟然速度快了2倍左右
3.线程级别的计算基于单个样本还是多个样本?
在我自己的实现中,单个线程就是单个样本的计算,因为线程池最大也就处理器个数+1,
所以如果直接将样本划分为处理器个数份,来并行,可能效果会好一些,不用涉及到大量
线程的管理。
实验了下:差不多能快个1000毫秒
4.并行对比
//train_parallel:4m22s one epoch
//train:7m07s
//train_parallel 比 train快1.63倍
使用并行预测大约快出2倍

5.关于并行训练的实现
将每个batch拆分成处理器个数这么多份,然后并行,每个线程保存了该线程计算的所有样本的梯度和
最后将这些线程中的梯度加起来就可以求得这个batch的梯度
不能单个样本作为一个线程,这样保存梯度会占用很大的内存
不能并行去修改总的梯度变量,这样互斥使得并行效果不那么有效了

6.使用方法
借鉴了keras的设计,
model = new Sequential()
model.add(layer)
.....
model.train()


       

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
                《人工智能:深度学习入门到精通实战》课程主要就人工智能领域相关的深度学习基础、深度学习计算、卷积神经网络+经典网络、循环神经网络+RNN进阶、优化算法、计算机视觉和自然语言处理等,配套实战案例与项目部基于真实数据集与实际任务展开,结合深度学习框架进行建模实战。                由浅入深,每一个理论搭配一个实验,引领学员浸泡式逐步掌握各项技能和实战项目,且侧重技能不同,学员的知识体系会更加面课程大纲:第一章:深度学习基础-深度学习简介01.1-前置知识01.2-传统编程与数据编程01.3-深度学习起源01.4-深度学习崛起与发展01.5-深度学习成功案例01.6-深度学习特点 第二章:深度学习基础-Python基础02.1-PyTorch介绍与环境配置02.2-数据操作与创建Tensor02.3-算术操作、索引与改变形状02.4-线性代数、广播机制与内存开销02.5-Tensor和NumPy相互转换与Tensor on GPU02.6-实验01-创建和使用Tensor-102.7-实验01-创建和使用Tensor-202.8-梯度下降02.9-实验02-梯度下降-102.10-实验02-梯度下降-202.11-自动求梯度概念02.12-自动求梯度实例02.13-实验03-自动求梯度-102.14-实验03-自动求梯度-2 第三章:深度学习基础-线性回归03.1-线性回归讲解03.2-线性回归实例03.3-实验04-从零实现线性回归-103.4-实验04-从零实现线性回归-203.5-实验05-线性回归的简洁实现-103.6-实验05-线性回归的简洁实现-2 第四章:深度学习基础-softmax回归04.1-softmax回归04.2-实验06-FashionMNIST04.3-实验07-从零实现Softmax回归-104.4-实验07-从零实现Softmax回归-204.5-实验08-softmax回归的简洁实现 第五章:深度学习基础-多层感知机05.1-感知机05.2-多层感知机05.3-多层感知机与神经网络05.4-激活函数05.5-正向传播05.6-反向传播05.7-正向传播和反向传播05.8-批大小05.9-实验09-从零实现MLP05.10-实验10-MLP的简洁实现 第六章:深度学习基础-模型选择、欠拟合和过拟合06.1-训练误差和泛化误差06.2-模型选择06.3-欠拟合和过拟合06.4-权重衰减06.5-丢弃法06.6-实验11-多项式函数拟合实验06.7-实验12-高维线性回归实验-106.8-实验12-高维线性回归实验-206.9-实验13-Dropout 第七章:深度学习基础-数值稳定性和模型初始化07.1-数值稳定性和模型初始化-107.2-数值稳定性和模型初始化-207.3-实验14-房价预测案例-107.4-实验14-房价预测案例-207.5-实验14-房价预测案例-3 第八章:深度学习计算-模型构造08.1-模型构造-108.2-模型构造-208.3-模型构造-308.4-实验15-模型构造-108.5-实验15-模型构造-2 第九章:深度学习计算-模型参数的访问、初始化和共享09.1-模型参数的访问09.2-模型参数初始化和共享09.3-实验16-模型参数-109.4-实验16-模型参数-2 第十章:深度学习计算-自定义层与读取和储存10.1-不含模型参数的自定义层10.2-含模型参数的自定义层10.3-实验17-自定义层10.4-读取和储存10.5-GPU计算10.6-实验18-读取和储存  第十一章:卷积神经网络11.01-卷积神经网络11.02-卷积神经网络的组成层11.03-图像分类的局限性11.04-二维卷积层与卷积层11.05-卷积在图像中的直观作用11.06-实验19-二维卷积层11.07-填充与步幅11.08-卷积过程11.09-卷积层参数-111.10-卷积层参数-211.11-实验20-Pad和Stride11.12-多输入和输出通道11.13-实验21-多通道11.14-池化层11.15-实验22-池化层 第十二章:经典网络12.01-卷积神经网络12.02-实验23-LeNet模型12.03-深度卷积神经网络12.04-实验24-AlexNet模型12.05-使用重复元素的网络12.06-实验25-VGG模型12.07-网络中的网络12.08-实验26-NiN模型12.09-含并行连接的网络12.10-实验27-GoogLeNet模型12.11-批量归一化-112.12-批量归一化-212.13-实验28-批量归一化12.14-残差网络12.15-实验29-残差网络12.16-稠密连接网络12.17-实验30-稠密连接网络 第十三章:循环神经网络13.01-语言模型和计算13.02-n元语法13.03-RNN和RNNs13.04-标准RNN向前输出流程和语言模型应用13.05-vector-to-sequence结构13.06-实验31-语言模型数据集-113.07-实验31-语言模型数据集-213.08-实验32-从零实现循环神经网络-113.09-实验32-从零实现循环神经网络-213.10-实验32-从零实现循环神经网络-313.11-实验32-从零实现循环神经网络-413.12-实验33-简洁实现循环神经网络-113.13-实验33-简洁实现循环神经网络-2 第十四章:RNN进阶14.01-通过时间反向传播-114.02-通过时间反向传播-214.03-长短期记忆-114.04-长短期记忆-214.05-实验34-长短期记忆网络-114.06-实验34-长短期记忆网络-214.07-门控循环单元14.08-RNN扩展模型14.09-实验35-门控循环单元 第十五章:优化算法15.01-优化与深度学习15.02-局部最小值和鞍点15.03-提高深度学习的泛化能力15.04-实验36-小批量梯度下降-115.05-实验36-小批量梯度下降-215.06-动量法-115.07-动量法-215.08-实验37-动量法15.09-AdaGrad算法与特点15.10-实验38-AdaGrad算法15.11-RMSrop算法15.12-实验39-RMSProp算法15.13-AdaDelta算法15.14-实验40-AdaDelta算法15.15-Adam算法15.16-实验41-Adam算法15.17-不用二阶优化讲解与超参数 第十六章:计算机视觉16.01-图像增广与挑战16.02-翻转、裁剪、变化颜色与叠加16.03-实验42-图像增广-116.04-实验42-图像增广-216.05-微调16.06-迁移学习16.07-实验43-微调-116.08-实验43-微调-216.09-目标检测16.10-边界框16.11-实验44-边界框16.12-锚框与生成多个锚框16.13-交并比16.14-实验45-生成锚框-116.15-实验45-生成锚框-216.17-标注训练集的锚框-116.18-标注训练集的锚框-216.19-实验46-标注训练集的锚框-116.20-实验46-标注训练集的锚框-216.21-实验46-标注训练集的锚框-316.22-输出预测边界框16.23-实验47-输出预测边界框-116.24-实验47-输出预测边界框-216.25-多尺度目标检测16.26-实验48-多尺度目标检测16.27-目标检测算法分类16.28-SSD与模型设计16.29-预测层16.30-损失函数16.31-SSD预测16.32-实验49-目标检测数据集16.33-实验50-SSD目标检测-116.34-实验50-SSD目标检测-216.35-实验50-SSD目标检测-316.36-实验50-SSD目标检测-416.37-实验50-SSD目标检测-516.38-实验50-SSD目标检测-6 第十七章:自然语言处理17.01-词嵌入和词向量17.02-神经网络模型17.03-跳字模型17.04-训练跳字模型17.05-连续词袋模型17.06-负采样17.07-层序softmax17.08-子词嵌入17.09-Fasttext模型17.10-局向量的词嵌入17.11-实验51-word2vec之数据预处理-117.12-实验51-word2vec之数据预处理-217.13-实验52-word2vec之负采样-117.14-实验52-word2vec之负采样-217.15-实验53-word2vec之模型构建-117.16-实验53-word2vec之模型构建-217.17-实验54-求近义词和类比词-117.18-实验54-求近义词和类比词-217.19-实验55-文本情感分类RNN-117.20-实验55-文本情感分类RNN-217.21-实验55-文本情感分类RNN-317.22-实验55-文本情感分类RNN-417.23-TextCNN17.24-TextCNN流程17.25-实验56-文本情感分类textCNN-117.26-实验56-文本情感分类textCNN-217.27-Seq2Seq的历史与网络架构17.28-Seq2Seq的应用与存在的问题17.29-Attention机制与Bucket机制17.30-实验57-机器翻译之模型构建-117.31-实验57-机器翻译之模型构建-217.32-实验57-机器翻译之模型构建-317.33-实验58-机器翻译之训练评估-117.34-实验58-机器翻译之训练评估-217.35-实验58-机器翻译之训练评估-3

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值