keras小数据集扩充



原文地址:http://blog.keras.io/building-powerful-image-classification-models-using-very-little-data.html

参考译文地址:http://keras-cn.readthedocs.io/en/latest/blog/image_classification_using_very_little_data/

本文作者:Francois Chollet

概述

在本文中,将使用VGG-16模型提供一种面向小数据集(几百张到几千张图片)构造高效、实用的图像分类器的方法并给出试验结果。

本文将探讨如下几种方法:

  • 从图片中直接训练一个小网络(作为基准方法)

  • 利用预训练网络的bottleneck(瓶颈)特征

  • fine-tune预训练网络的高层

本文需要使用的Keras模块有:

  • fit_generator:用于从Python生成器中训练网络

  • ImageDataGenerator:用于实时数据提升

  • 层参数冻结和模型fine-tune


配置情况

我们的实验基于下面的配置

  • 2000张训练图片构成的数据集,一共两个类别,每类1000张

  • 安装有Keras,SciPy,PIL的机器,如果有NVIDIA GPU那就更好了,但因为我们面对的是小数据集,没有也可以。

  • 数据集按照下面的形式存放(图片名可不遵循以下规则)


   
   
  1. data/
  2. train/
  3. dogs/
  4. dog001.jpg
  5. dog002.jpg
  6. ...
  7. cats/
  8. cat001/jpg
  9. cat002.jpg
  10. ...
  11. validation/
  12. dogs/
  13. dog001.jpg
  14. dog002.jpg
  15. ...
  16. cats/
  17. cat001/jpg
  18. cat002.jpg
  19. ...

这份数据集来源于Kaggle,可以使用百度网盘下载。原数据集有12500只猫和12500只狗,我们只取了各个类的前1000张图片。另外我们还从各个类中取了400张额外图片用于测试。

下面是数据集的一些示例图片,图片的数量非常少,这对于图像分类来说是个大麻烦。但现实是,很多真实世界图片获取是很困难的,我们能得到的样本数目确实很有限(比如医学图像,每张正样本都意味着一个承受痛苦的病人。对数据科学家而言,我们应该有能够榨取少量数据的全部价值的能力,而不是简单的伸手要更多的数据。

cats_and_dogs

在Kaggle的猫狗大战竞赛种,参赛者通过使用现代的深度学习技术达到了98%的正确率,本文方法只使用了全部数据的8%,因此这个问题对我们来说更难。


针对小数据集的深度学习

我经常听到的一种说法是,深度学习只有在你拥有海量数据时才有意义。虽然这种说法并不是完全不对,但却具有较强的误导性。当然,深度学习强调从数据中自动学习特征的能力,没有足够的训练样本,这几乎是不可能的。尤其是当输入的数据维度很高(如图片)时。然而,卷积神经网络作为深度学习的支柱,被设计为针对“感知”问题最好的模型之一(如图像分类问题),即使只有很少的数据,网络也能把特征学的不错。针对小数据集的神经网络依然能够得到合理的结果,并不需要任何手工的特征工程。一言以蔽之,卷积神经网络大法好!

动。尤其在计算机视觉领域,许多预训练的模型现在都被公开下载,并被重用在其他问题上以提升在小数据集上的性能。另一方面,深度学习模型天然就具有可重用的特性:比方说,你可以把一个在大规模数据上训练好的图像分类或语音识别的模型重用在另一个很不一样的问题上,而只需要做有限的一点改


数据预处理与数据提升

为了尽量利用我们有限的训练数据,我们将通过一系列随机变换堆数据进行提升,这样我们的模型将看不到任何两张完全相同的图片,这有利于我们抑制过拟合,使得模型的泛化能力更好。

在Keras中,这个步骤可以通过keras.preprocessing.image.ImageGenerator来实现,这个类使你可以:

  • 在训练过程中,设置要施行的随机变换

  • 通过.flow.flow_from_directory(directory)方法实例化一个针对图像batch的生成器,这些生成器可以被用作keras模型相关方法的输入,如fit_generatorevaluate_generatorpredict_generator

现在让我们看个例子:


   
   
  1. from keras.preprocessing.image import ImageDataGenerator
  2. datagen = ImageDataGenerator(
  3. rotation_range= 40,
  4. width_shift_range= 0.2,
  5. height_shift_range= 0.2,
  6. rescale= 1./ 255,
  7. shear_range= 0.2,
  8. zoom_range= 0.2,
  9. horizontal_flip= True,
  10. fill_mode= 'nearest')

上面显示的只是一部分选项,请阅读文档的相关部分来查看全部可用的选项。我们来快速的浏览一下这些选项的含义:

  • rotation_range是一个0~180的度数,用来指定随机选择图片的角度。

  • width_shiftheight_shift用来指定水平和竖直方向随机移动的程度,这是两个0~1之间的比例。

  • rescale值将在执行其他处理前乘到整个图像上,我们的图像在RGB通道都是0~255的整数,这样的操作可能使图像的值过高或过低,所以我们将这个值定为0~1之间的数。

  • shear_range是用来进行剪切变换的程度,参考剪切变换

  • zoom_range用来进行随机的放大

  • horizontal_flip随机的对图片进行水平翻转,这个参数适用于水平翻转不影响图片语义的时候

  • fill_mode用来指定当需要进行像素填充,如旋转,水平和竖直位移时,如何填充新出现的像素

下面我们使用这个工具来生成图片,并将它们保存在一个临时文件夹中,这样我们可以感觉一下数据提升究竟做了什么事。为了使图片能够展示出来,这里没有使用rescaling


   
   
  1. from keras.preprocessing.image import ImageDataGenerator, array_to_img, img_to_array, load_img
  2. datagen = ImageDataGenerator(
  3. rotation_range= 40,
  4. width_shift_range= 0.2,
  5. height_shift_range= 0.2,
  6. shear_range= 0.2,
  7. zoom_range= 0.2,
  8. horizontal_flip= True,
  9. fill_mode= 'nearest')
  10. img = load_img( 'data/train/cats/cat.0.jpg') # this is a PIL image
  11. x = img_to_array(img) # this is a Numpy array with shape (3, 150, 150)
  12. x = x.reshape(( 1,) + x.shape) # this is a Numpy array with shape (1, 3, 150, 150)
  13. # the .flow() command below generates batches of randomly transformed images
  14. # and saves the results to the `preview/` directory
  15. i = 0
  16. for batch in datagen.flow(x, batch_size= 1,
  17. save_to_dir= 'preview', save_prefix= 'cat', save_format= 'jpeg'):
  18. i += 1
  19. if i > 20:
  20. break # otherwise the generator would loop indefinitely

下面是一张图片被提升以后得到的多个结果:

cat_data_augmentation


在小数据集上训练神经网络:40行代码达到80%的准确率

进行图像分类的正确工具是卷积网络,所以我们来试试用卷积神经网络搭建一个初级的模型。因为我们的样本数很少,所以我们应该对过拟合的问题多加注意。当一个模型从很少的样本中学习到不能推广到新数据的模式时,我们称为出现了过拟合的问题。过拟合发生时,模型试图使用不相关的特征来进行预测。例如,你有三张伐木工人的照片,有三张水手的照片。六张照片中只有一个伐木工人戴了帽子,如果你认为戴帽子是能将伐木工人与水手区别开的特征,那么此时你就是一个差劲的分类器。

数据提升是对抗过拟合问题的一个武器,但还不够,因为提升过的数据仍然是高度相关的。对抗过拟合的你应该主要关注的是模型的“熵容量”——模型允许存储的信息量。能够存储更多信息的模型能够利用更多的特征取得更好的性能,但也有存储不相关特征的风险。另一方面,只能存储少量信息的模型会将存储的特征主要集中在真正相关的特征上,并有更好的泛化性能。

有很多不同的方法来调整模型的“熵容量”,常见的一种选择是调整模型的参数数目,即模型的层数和每层的规模。另一种方法是对权重进行正则化约束,如L1或L2.这种约束会使模型的权重偏向较小的值。

在我们的模型里,我们使用了很小的卷积网络,只有很少的几层,每层的滤波器数目也不多。再加上数据提升和Dropout,就差不多了。Dropout通过防止一层看到两次完全一样的模式来防止过拟合,相当于也是一种数据提升的方法。(你可以说dropout和数据提升都在随机扰乱数据的相关性)

下面展示的代码是我们的第一个模型,一个很简单的3层卷积加上ReLU激活函数,再接max-pooling层。这个结构和Yann LeCun在1990年发布的图像分类器很相似(除了ReLU)

这个实验的全部代码在这里


   
   
  1. from keras.models import Sequential
  2. from keras.layers import Convolution2D, MaxPooling2D
  3. from keras.layers import Activation, Dropout, Flatten, Dense
  4. model = Sequential()
  5. model.add(Convolution2D( 32, 3, 3, input_shape=( 3, 150, 150)))
  6. model.add(Activation( 'relu'))
  7. model.add(MaxPooling2D(pool_size=( 2, 2)))
  8. model.add(Convolution2D( 32, 3, 3))
  9. model.add(Activation( 'relu'))
  10. model.add(MaxPooling2D(pool_size=( 2, 2)))
  11. model.add(Convolution2D( 64, 3, 3))
  12. model.add(Activation( 'relu'))
  13. model.add(MaxPooling2D(pool_size=( 2, 2)))
  14. # the model so far outputs 3D feature maps (height, width, features)

然后我们接了两个全连接网络,并以单个神经元和sigmoid激活结束模型。这种选择会产生二分类的结果,与这种配置相适应,我们使用binary_crossentropy作为损失函数。


   
   
  1. model.add(Flatten()) # this converts our 3D feature maps to 1D feature vectors
  2. model.add(Dense( 64))
  3. model.add(Activation( 'relu'))
  4. model.add(Dropout( 0.5))
  5. model.add(Dense( 1))
  6. model.add(Activation( 'sigmoid'))
  7. model.compile(loss= 'binary_crossentropy',
  8. optimizer= 'rmsprop',
  9. metrics=[ 'accuracy'])

然后我们开始准备数据,使用.flow_from_directory()来从我们的jpgs图片中直接产生数据和标签。


   
   
  1. # this is the augmentation configuration we will use for training
  2. train_datagen = ImageDataGenerator(
  3. rescale= 1./ 255,
  4. shear_range= 0.2,
  5. zoom_range= 0.2,
  6. horizontal_flip= True)
  7. # this is the augmentation configuration we will use for testing:
  8. # only rescaling
  9. test_datagen = ImageDataGenerator(rescale= 1./ 255)
  10. # this is a generator that will read pictures found in
  11. # subfolers of 'data/train', and indefinitely generate
  12. # batches of augmented image data
  13. train_generator = train_datagen.flow_from_directory(
  14. 'data/train', # this is the target directory
  15. target_size=( 150, 150), # all images will be resized to 150x150
  16. batch_size= 32,
  17. class_mode= 'binary') # since we use binary_crossentropy loss, we need binary labels
  18. # this is a similar generator, for validation data
  19. validation_generator = test_datagen.flow_from_directory(
  20. 'data/validation',
  21. target_size=( 150, 150),
  22. batch_size= 32,
  23. class_mode= 'binary')

然后我们可以用这个生成器来训练网络了,在GPU上每个epoch耗时20~30秒,在CPU上耗时300~400秒,所以如果你不是很着急,在CPU上跑这个模型也是完全可以的。


   
   
  1. model.fit_generator(
  2. train_generator,
  3. samples_per_epoch= 2000,
  4. nb_epoch= 50,
  5. validation_data=validation_generator,
  6. nb_val_samples= 800)
  7. model.save_weights( 'first_try.h5') # always save your weights after training or during training

这个模型在50个epoch后的准确率为79%~81%,别忘了我们只用了8%的数据,也没有花时间来做模型和超参数的优化。在Kaggle中,这个模型已经可以进前100名了(一共215队参与),估计剩下的115队都没有用深度学习:)

注意这个准确率的变化可能会比较大,因为准确率本来就是一个变化较高的评估参数,而且我们只有800个样本用来测试。比较好的验证方法是使用K折交叉验证,但每轮验证中我们都要训练一个模型。


使用预训练网络的bottleneck特征:一分钟达到90%的正确率

一个稍微讲究一点的办法是,利用在大规模数据集上预训练好的网络。这样的网络在多数的计算机视觉问题上都能取得不错的特征,利用这样的特征可以让我们获得更高的准确率。

我们将使用vgg-16网络,该网络在ImageNet数据集上进行训练,这个模型我们之前提到过了。因为ImageNet数据集包含多种“猫”类和多种“狗”类,这个模型已经能够学习与我们这个数据集相关的特征了。事实上,简单的记录原来网络的输出而不用bottleneck特征就已经足够把我们的问题解决的不错了。不过我们这里讲的方法对其他的类似问题有更好的推广性,包括在ImageNet中没有出现的类别的分类问题。

VGG-16的网络结构如下:

vgg_16

我们的方法是这样的,我们将利用网络的卷积层部分,把全连接以上的部分抛掉。然后在我们的训练集和测试集上跑一遍,将得到的输出(即“bottleneck feature”,网络在全连接之前的最后一层激活的feature map)记录在两个numpy array里。然后我们基于记录下来的特征训练一个全连接网络。

我们将这些特征保存为离线形式,而不是将我们的全连接模型直接加到网络上并冻结之前的层参数进行训练的原因是处于计算效率的考虑。运行VGG网络的代价是非常高昂的,尤其是在CPU上运行,所以我们只想运行一次。这也是我们不进行数据提升的原因。

我们不再赘述如何搭建vgg-16网络了,这件事之前已经说过,在keras的example里也可以找到。但让我们看看如何记录bottleneck特征。


   
   
  1. generator = datagen.flow_from_directory(
  2. 'data/train',
  3. target_size=( 150, 150),
  4. batch_size= 32,
  5. class_mode= None, # this means our generator will only yield batches of data, no labels
  6. shuffle= False) # our data will be in order, so all first 1000 images will be cats, then 1000 dogs
  7. # the predict_generator method returns the output of a model, given
  8. # a generator that yields batches of numpy data
  9. bottleneck_features_train = model.predict_generator(generator, 2000)
  10. # save the output as a Numpy array
  11. np.save(open( 'bottleneck_features_train.npy', 'w'), bottleneck_features_train)
  12. generator = datagen.flow_from_directory(
  13. 'data/validation',
  14. target_size=( 150, 150),
  15. batch_size= 32,
  16. class_mode= None,
  17. shuffle= False)
  18. bottleneck_features_validation = model.predict_generator(generator, 800)
  19. np.save(open( 'bottleneck_features_validation.npy', 'w'), bottleneck_features_validation)

记录完毕后我们可以将数据载入,用于训练我们的全连接网络:


   
   
  1. train_data = np.load(open( 'bottleneck_features_train.npy'))
  2. # the features were saved in order, so recreating the labels is easy
  3. train_labels = np.array([ 0] * 1000 + [ 1] * 1000)
  4. validation_data = np.load(open( 'bottleneck_features_validation.npy'))
  5. validation_labels = np.array([ 0] * 400 + [ 1] * 400)
  6. model = Sequential()
  7. model.add(Flatten(input_shape=train_data.shape[ 1:]))
  8. model.add(Dense( 256, activation= 'relu'))
  9. model.add(Dropout( 0.5))
  10. model.add(Dense( 1, activation= 'sigmoid'))
  11. model.compile(optimizer= 'rmsprop',
  12. loss= 'binary_crossentropy',
  13. metrics=[ 'accuracy'])
  14. model.fit(train_data, train_labels,
  15. nb_epoch= 50, batch_size= 32,
  16. validation_data=(validation_data, validation_labels))
  17. model.save_weights( 'bottleneck_fc_model.h5')

因为特征的size很小,模型在CPU上跑的也会很快,大概1s一个epoch,最后我们的准确率是90%~91%,这么好的结果多半归功于预训练的vgg网络帮助我们提取特征。


在预训练的网络上fine-tune

为了进一步提高之前的结果,我们可以试着fine-tune网络的后面几层。Fine-tune以一个预训练好的网络为基础,在新的数据集上重新训练一小部分权重。在这个实验中,fine-tune分三个步骤

  • 搭建vgg-16并载入权重

  • 将之前定义的全连接网络加在模型的顶部,并载入权重

  • 冻结vgg16网络的一部分参数

vgg16_modified

注意:

  • 为了进行fine-tune,所有的层都应该以训练好的权重为初始值,例如,你不能将随机初始的全连接放在预训练的卷积层之上,这是因为由随机权重产生的大地图将会破坏卷积层预训练的权重。在我们的情形中,这就是为什么我们首先训练顶层分类器,然后再基于它进行fine-tune的原因

  • 我们选择只fine-tune最后的卷积块,而不是整个网络,这是为了防止过拟合。整个网络具有巨大的熵容量,因此具有很高的过拟合倾向。由底层卷积模块学习到的特征更加一般,更加不具有抽象性,因此我们要保持前两个卷积块(学习一般特征)不动,只fine-tune后面的卷积块(学习特别的特征)。

  • fine-tune应该在很低的学习率下进行,通常使用SGD优化而不是其他自适应学习率的优化算法,如RMSProp。这是为了保证更新的幅度保持在较低的程度,以免毁坏预训练的特征。

代码如下,首先在初始化好的vgg网络上添加我们预训练好的模型:


   
   
  1. # build a classifier model to put on top of the convolutional model
  2. top_model = Sequential()
  3. top_model.add(Flatten(input_shape=model.output_shape[ 1:]))
  4. top_model.add(Dense( 256, activation= 'relu'))
  5. top_model.add(Dropout( 0.5))
  6. top_model.add(Dense( 1, activation= 'sigmoid'))
  7. # note that it is necessary to start with a fully-trained
  8. # classifier, including the top classifier,
  9. # in order to successfully do fine-tuning
  10. top_model.load_weights(top_model_weights_path)
  11. # add the model on top of the convolutional base
  12. model.add(top_model)

然后将最后一个卷积块前的卷积层参数冻结:


   
   
  1. # set the first 25 layers (up to the last conv block)
  2. # to non-trainable (weights will not be updated)
  3. for layer in model.layers[: 25]:
  4. layer.trainable = False
  5. # compile the model with a SGD/momentum optimizer
  6. # and a very slow learning rate.
  7. model.compile(loss= 'binary_crossentropy',
  8. optimizer=optimizers.SGD(lr= 1e-4, momentum= 0.9),
  9. metrics=[ 'accuracy'])

然后以很低的学习率进行训练:


   
   
  1. # prepare data augmentation configuration
  2. train_datagen = ImageDataGenerator(
  3. rescale= 1./ 255,
  4. shear_range= 0.2,
  5. zoom_range= 0.2,
  6. horizontal_flip= True)
  7. test_datagen = ImageDataGenerator(rescale= 1./ 255)
  8. train_generator = train_datagen.flow_from_directory(
  9. train_data_dir,
  10. target_size=(img_height, img_width),
  11. batch_size= 32,
  12. class_mode= 'binary')
  13. validation_generator = test_datagen.flow_from_directory(
  14. validation_data_dir,
  15. target_size=(img_height, img_width),
  16. batch_size= 32,
  17. class_mode= 'binary')
  18. # fine-tune the model
  19. model.fit_generator(
  20. train_generator,
  21. samples_per_epoch=nb_train_samples,
  22. nb_epoch=nb_epoch,
  23. validation_data=validation_generator,
  24. nb_val_samples=nb_validation_samples)

在50个epoch之后该方法的准确率为94%,非常成功

通过下面的方法你可以达到95%以上的正确率:

  • 更加强烈的数据提升

  • 更加强烈的dropout

  • 使用L1和L2正则项(也称为权重衰减)

  • fine-tune更多的卷积块(配合更大的正则)


[TOC](这里写自定义目录标题)

欢迎使用Markdown编辑器

你好! 这是你第一次使用 Markdown编辑器 所展示的欢迎页。如果你想学习如何使用Markdown编辑器, 可以仔细阅读这篇文章,了解一下Markdown的基本语法知识。

新的改变

我们对Markdown编辑器进行了一些功能拓展与语法支持,除了标准的Markdown编辑器功能,我们增加了如下几点新功能,帮助你用它写博客:

  1. 全新的界面设计 ,将会带来全新的写作体验;
  2. 在创作中心设置你喜爱的代码高亮样式,Markdown 将代码片显示选择的高亮样式 进行展示;
  3. 增加了 图片拖拽 功能,你可以将本地的图片直接拖拽到编辑区域直接展示;
  4. 全新的 KaTeX数学公式 语法;
  5. 增加了支持甘特图的mermaid语法1 功能;
  6. 增加了 多屏幕编辑 Markdown文章功能;
  7. 增加了 焦点写作模式、预览模式、简洁写作模式、左右区域同步滚轮设置 等功能,功能按钮位于编辑区域与预览区域中间;
  8. 增加了 检查列表 功能。

功能快捷键

撤销:Ctrl/Command + Z
重做:Ctrl/Command + Y
加粗:Ctrl/Command + B
斜体:Ctrl/Command + I
标题:Ctrl/Command + Shift + H
无序列表:Ctrl/Command + Shift + U
有序列表:Ctrl/Command + Shift + O
检查列表:Ctrl/Command + Shift + C
插入代码:Ctrl/Command + Shift + K
插入链接:Ctrl/Command + Shift + L
插入图片:Ctrl/Command + Shift + G

合理的创建标题,有助于目录的生成

直接输入1次#,并按下space后,将生成1级标题。
输入2次#,并按下space后,将生成2级标题。
以此类推,我们支持6级标题。有助于使用TOC语法后生成一个完美的目录。

如何改变文本的样式

强调文本 强调文本

加粗文本 加粗文本

标记文本

删除文本

引用文本

H2O is是液体。

210 运算结果是 1024.

插入链接与图片

链接: link.

图片: Alt

带尺寸的图片: Alt

当然,我们为了让用户更加便捷,我们增加了图片拖拽功能。

如何插入一段漂亮的代码片

博客设置页面,选择一款你喜欢的代码片高亮样式,下面展示同样高亮的 代码片.

// An highlighted block
var foo = 'bar';

生成一个适合你的列表

  • 项目
    • 项目
      • 项目
  1. 项目1
  2. 项目2
  3. 项目3
  • 计划任务
  • 完成任务

创建一个表格

一个简单的表格是这么创建的:

项目Value
电脑$1600
手机$12
导管$1

设定内容居中、居左、居右

使用:---------:居中
使用:----------居左
使用----------:居右

第一列第二列第三列
第一列文本居中第二列文本居右第三列文本居左

SmartyPants

SmartyPants将ASCII标点字符转换为“智能”印刷标点HTML实体。例如:

TYPEASCIIHTML
Single backticks'Isn't this fun?'‘Isn’t this fun?’
Quotes"Isn't this fun?"“Isn’t this fun?”
Dashes-- is en-dash, --- is em-dash– is en-dash, — is em-dash

创建一个自定义列表

Markdown
Text-to- HTML conversion tool
Authors
John
Luke

如何创建一个注脚

一个具有注脚的文本。2

注释也是必不可少的

Markdown将文本转换为 HTML

KaTeX数学公式

您可以使用渲染LaTeX数学表达式 KaTeX:

Gamma公式展示 Γ ( n ) = ( n − 1 ) ! ∀ n ∈ N \Gamma(n) = (n-1)!\quad\forall n\in\mathbb N Γ(n)=(n1)!nN 是通过欧拉积分

Γ ( z ) = ∫ 0 ∞ t z − 1 e − t d t   . \Gamma(z) = \int_0^\infty t^{z-1}e^{-t}dt\,. Γ(z)=0tz1etdt.

你可以找到更多关于的信息 LaTeX 数学表达式here.

新的甘特图功能,丰富你的文章

Mon 06 Mon 13 Mon 20 已完成 进行中 计划一 计划二 现有任务 Adding GANTT diagram functionality to mermaid
  • 关于 甘特图 语法,参考 这儿,

UML 图表

可以使用UML图表进行渲染。 Mermaid. 例如下面产生的一个序列图::

张三 李四 王五 你好!李四, 最近怎么样? 你最近怎么样,王五? 我很好,谢谢! 我很好,谢谢! 李四想了很长时间, 文字太长了 不适合放在一行. 打量着王五... 很好... 王五, 你怎么样? 张三 李四 王五

这将产生一个流程图。:

链接
长方形
圆角长方形
菱形
  • 关于 Mermaid 语法,参考 这儿,

FLowchart流程图

我们依旧会支持flowchart的流程图:

Created with Raphaël 2.2.0 开始 我的操作 确认? 结束 yes no
  • 关于 Flowchart流程图 语法,参考 这儿.

导出与导入

导出

如果你想尝试使用此编辑器, 你可以在此篇文章任意编辑。当你完成了一篇文章的写作, 在上方工具栏找到 文章导出 ,生成一个.md文件或者.html文件进行本地保存。

导入

如果你想加载一篇你写过的.md文件或者.html文件,在上方工具栏可以选择导入功能进行对应扩展名的文件导入,
继续你的创作。


  1. mermaid语法说明 ↩︎

  2. 注脚的解释 ↩︎

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值