卷积神经网络VGG 论文细读 + Tensorflow实现

一. 背景介绍

VERY DEEP CONVOLUTIONAL NETWORKS FOR LARGE-SCALE IMAGE RECOGNITION

是牛津大学计算机视觉实验室参加2014年ILSVRC(ImageNet Large Scale Visual Recognition Challenge)比赛的网络结构。解决ImageNet中的1000类图像分类和localization。

分类、定位、检测、分割

实验结果是VGGNet斩获了2014年ILSVRC分类第二,定位第一。(当年分类第一是GoogleNet,后续会介绍)

Oxford Visual Geometry Group

Robotics Research Group

Paper link

二. Abstract

Abstract
1.VGGNet 探索的是神经网络的深度(depth)与其性能之间的关系。

VGG通过反复堆叠3×3的小型卷积核和2×2的最大池化层,VGG成功构建了16-19层的卷积神经网络。是当时在论文发表前最深的深度网络。实际上,VGG在探索深度对神经网络影响的同时,其实本身广度也是很深的。那么:

神经网络的深度和广度对其本身的影响是什么呢?

  • 卷积核的种类对应了网络的广度,卷积层数对应了网络的深度。这两者对网络的拟合都有影响。但是在现代深度学习中,大家普遍认为深度比广度的影响更加高。
  • 宽度即卷积核的种类个数,在LeNet那篇文章里我们说了,权值共享(每个神经元对应一块局部区域,如果局部区域是10*10,那么就有100的权重参数,但如果我们把每个神经元的权重参数设置为一样,相当于每个神经元用的是同一个卷积核去卷积图像,最终两层间的连接只有 100 个参数 !)可以大大减少我们的训练参数,但是由于使用了同一个卷积核,最终特征个数太少,效果也不会好,所以一般神经网络都会有多个卷积核,这里说明宽度的增加在一开始对网络的性能提升是有效的。但是,随着广度的增加,对网络整体的性能其实是开始趋于饱和,并且有下降趋势,因为过多的特征(一个卷积核对应发现一种特征)可能对带来噪声的影响。
  • 深度即卷积层的个数,对网络的性能是极其重要的,ResNet已经表明越深的深度网络性能也就越好。深度网络自然集成了低、中、高层特征。多层特征可以通过网络的堆叠的数量(深度)来丰富其表达。挑战imagenet数据集的优秀网络都是采用较深的模型。网络的深度很重要,但是否能够简单的通过增加更多的网络层次学习更好的网络?这个问题的障碍就是臭名昭著的梯度消失(爆炸)问题,这从根本上阻碍了深度模型的收敛。
  • 增加更多的卷积核可以发现更多的特征,但是特征是需要进行组合的,只有知道了特征之间的关系才能够更好的表达图片内容,而增加深度就是组合特征的过程。
2. VGG结构全部都采用较小的卷积核(3×3,部分1×1)

在VGG出现之前的深度网络,比如ZFNet或Overfeat普遍都采用了7×7和11×11的卷积核。VGG通篇全部采用很小的卷积核。我们再回顾一下在深度学习中卷积核的感受野的作用。


卷积核的感受野
如何选择卷积核的大小?越大越好还是越小越好?

答案是小而深,单独较小的卷积核也是不好的,只有堆叠很多小的卷积核,模型的性能才会提升。

  • 如上图所示,CNN的卷积核对应一个感受野,这使得每一个神经元不需要对全局图像做感受,每个神经元只感受局部的图像区域,然后在更高层,将这些感受不同局部的神经元综合起来就可以得到全局信息。这样做的一个好处就是可以减少大量训练的参数。
  • VGG经常出现多个完全一样的3×3的卷积核堆叠在一起的情况,这些多个小型卷积核堆叠的设计其实是非常有效的。如下图所示,两个3×3的卷积层串联相当于1个5×5的卷积层,即一个像素会和周围5×5的像素产生关联,可以说感受野是5×5。同时,3个串联的3×3卷积层串联的效果相当于一个7×7的卷积层。除此之外,3个串联的3×3的卷积层拥有比一个7×7更少的参数量,只有后者的 (3×3×3) / (7×7) = 55%。最重要的是3个3×3的卷积层拥有比一个7×7的卷积层更多的非线性变换(前者可以使用三次ReLu激活,而后者只有一次)。


3.VGG获得了2014年ILSVRC分类第二,定位第一。(当年分类第一是GoogleNet,后续会介绍)

三. Architecture

architecture

1. vgg模型的输入是固定的224×224的彩色RGB通道图像。
2. 输入做的唯一一个数据预处理就是各自减去 RGB 3个通道的均值
3. 使用的是非常小的3×3的卷积核。
4. 其中一个结构采用了一些1×1的卷积核。

1×1的卷积核到底有什么作用呢?

  • 1×1的卷积核和正常的滤波器完全是一样的,只不过它不再感受一个局部区域,不考虑像素与像素之间的关系。1×1的卷积本身就是不同feature channel的线性叠加。1×1的卷积最早出现在Network in Network这篇文章中,在Google的inception结构中也采用了大量1×1的卷积。
  • NIN论文中解释1×1的卷积实现了多个feature map的结合,从而整合了不同通道间的信息。(个人认为这个作用并不是特点,因为其它大小的卷积核也可以实现)
  • 1×1的卷积可以实现通道数量的升维和降维。并且是低成本的特征变换(计算量比3×3小很多)。是一个性价比很高的聚合操作。怎么理解1×1是性价比很高的升降通道数的操作呢?
    (以google inception为例)


    原始

原始结构:
参数:(1×1×192×64) + (3×3×192×128) + (5×5×192×32) = 153600
最终输出的feature map:64+128+32+192 = 416

加入不同channel的1×1卷积后:
参数:1×1×192×64+(1×1×192×96+3×3×96×128)+(1×1×192×16+5×5×16×32)=15872
最终输出的feature map: 64+128+32+32=256

所以加入1×1的卷积后,在降低大量运算的前提下,降低了维度。

5. 卷积步长是一个像素
6.采用最大池化层
7. 不是所有卷积层后面都接一个池化层。(和之前的网络有区别,是反复堆叠几个3×3的卷积)
8. 最大池化是2×2,步长为2.
9. 最后接了3个全连接层
10. 前两个全连接都是4096,最后一个根据imagenet1000类定为1000个输出
11. 分类层是softmax
12. 所有隐层都进行了ReLU激活。
13. 只有一个地方使用了LRN,并且实验表明LRN没有任何用处。

四. ConvNet Configurations

ConvNet Configurations

参数数量
  • VGG全部使用了3×3的卷积核和2×2的池化核,通过不断加深网络结构来提升性能。上图为VGG各个级别的网络结构图。
  • VGG各种级别的结构都采用了5段卷积,每一段有一个或多个卷积层。同时每一段的尾部都接着一个最大池化层来缩小图片尺寸。每一段内的卷积核数量一致,越靠后的卷积核数量越多 64-128-256-512-512。经常出现多个完全一样的卷积层堆叠在一起的情况。
  • A-LRN结构使用了LRN,结果表明并没有什么用处。
  • C 结构比B多了几个1×1的卷积。在VGG里,1×1的卷积意义主要是线性变换,输入输出通道数量并没有变化。没有发生降维。所以作者认为1×1没有3×3好,大一些的卷积核可以学到更大的空间特征。
  • A-E 每一级网络逐渐变深,但是参数并没有变多很多。这是因为参数量主要消耗在最后3个全连接层,卷积虽然深但是参数消耗并不多。但是训练耗时的仍然是卷积,因其计算量大。
  • D E就是我们经常说的VGG-16和VGG-19

五. Training


这个部分是VGG当时是怎么训练的具体过程,有很多值得借鉴的地方。
1. 使用mini-batch的梯度下降法,并且是带动量的。batch_size设置为256,动量是0.9。
2. 前两个全连接使用了dropout,值是0.5, 用来缓解过拟合。
3. 学习率初始设置为0.01,衰减系数为10,每当验证集上准确率不再变好时,会降低学习率。学习率一共被衰减3次。总共训练了74个epoch,370k个iteration。

VGG的参数初始化方式是怎么样的?

  • 上图中间红框部分作者介绍了VGG训练时参数的初始化方式,这个部分比较有意思。作者认为这么深的网络(论文发表前最深)训练收敛是很困难的,必须借助有效的参数初始化方式。
  • 作者先训练上面网络结构中的A结构,A收敛之后呢,将A的网络权重保存下来,再复用A网络的权重来初始化后面几个简单模型。
  • 复用A的网络权重,只是前四个卷积层,以及后三层全连接层,其它的都是随机初始化。
  • 随机初始化,均值是0,方差是0.01。bias是0.

六. Image Size

在训练和测试阶段,VGG都采用了Multi-scale的方式。


training

testing

VGG的Multi-Scale方法

  • VGG在训练阶段使用了Multi-Scale的方法做数据增强,将原始图片缩放到不同的尺寸S,然后再随机裁剪224×224的图片,这样能增加很多数据量,对于防止模型过拟合有很不错的效果。
  • 实验中,作者令S在[256, 512]这个区间,使用Multi-Scale获得了多个版本的数据,并将多个版本的数据合在一起训练。
  • 在测试时,也采用了Multi-Scale的方法,将图像scale到一个尺寸Q,并将图片输入卷积网络计算,然后再最后一个卷积层使用滑窗的方式进行分类预测,将不同窗口的分类结果平均,再将不同尺寸Q的结果平均,得到最后的结果。这样可以提高数据的利用率和预测准确率。
  • 下图是VGG各种不同scale的训练结果,融合了Multi-Scale的D和E是最好的。


七. Tensorflow实现简单的VGG-16的结构

本部分整理自《Tensorflow实战》

1. 实现卷积操作函数

VGG包含很多卷积,函数conv_op创建卷积层,并把本层参数存入参数列表。这样可以方便后面VGG结构中多次使用卷积操作。

输入参数

  • input_op: 输入tensor,是一个4D的tensor,可以理解为image batch。shape=[batch, in_height, in_width, in_channels],即:[训练时一个batch的图片数量, 图片高度, 图片宽度, 图像通道数]
  • kh:卷积核的高
  • kw:卷积核的宽
  • n_out:输出通道数;这三个参数恰好定义了一个卷积核的参数,卷积核kernel的参数即为: shape=[filter_height, filter_width, in_channels, out_channels],即:[卷积核的高度,卷积核的宽度,图像通道数,卷积核个数]
  • dh:卷积的步长高
  • dw:卷积的步长宽。 进行卷积操作时,我们除了需要输入的tensor和卷积核参数外,还需要定义卷积的步长strides,strides卷积时在每一维上的步长,strides[0]=strides[3]=1。
  • p:参数列表。将卷积核和bias的参数写入p,以便后面使用

输出:
经过卷积,和激活函数的tensor。[batch_size, new_h, new_w, n_out]

def conv_op(input_op, name, kh, kw, n_out, dh, dw, p):
    n_in = input_op.get_shape()[-1].value
    with tf.name_scope(name) as scope:
        kernel = tf.get_variable(scope+'w', shape=[kh, kw, n_in, n_out], dtype = tf.float32,
                                initializer = tf.contrib.layers.xavier_initializer_conv2d())
        conv = tf.nn.conv2d(input_op, kernel, (1, dh, dw, 1), padding = 'SAME')
        bias_init_val = tf.constant(0.0, shape = [n_out], dtype = tf.float32)
        biases = tf.variable(bias_init_val, trainable = True, name = 'b')
        z = tf.nn.bias_add(conv, biases)
        activation = tf.nn.relu(z, name = scope)
        p += [kernel, biases]
        return activation
  • 整体过程非常简单,建议熟背这一段代码。首先获得输入的tensor,即image batch,并且定义name,卷积核的大小,卷积的步长(输入参数)。
  • 使用get_shape()获得输入tensor的输入通道数。
  • name_scope将scope内生成的variable自动命名为name/xxx,用于区分不同卷积层的组件
  • get_variable()定义卷积核的参数,注意shape和初始化方式
  • conv2d对输入tensor,使用刚刚的卷积核进行卷积操作,注意此处定义strides和padding
  • constant定义bias,注意shape等于输出通道数。一个卷积核对应一个输出通道,对应一个bias。tf.variable再将其转换成可训练的参数。
  • 进行relu激活

2. 实现池化操作函数

输入参数

  • input_op: 输入tensor
  • kh:池化的高
  • kw:池化的宽
  • dh:池化的步长高
  • dw:池化的步长宽。
def maxpool_op(input_op, name, kh, kw, dh, dw):
    return tf.nn.max_pool(input_op,
                          ksize=[1, kh, kw, 1],
                          strides=[1, dh, dw, 1],
                          padding='SAME',
                          name=name)

这部分代码也很容易理解,nn.max_pool可以直接使用,需要定义池化的大小ksize,步长strides,以及边界填充方式padding。此部分没有需要训练的参数。

3. 定义全连接操作函数

输入参数

  • input_op: 输入的tensor
  • n_out: 输出向量长度。 全连接只需要这两个参数
def fc_op(input_op, name, n_out, p):
    n_in = input_op.get_shape()[-1].value
    with tf.name_scope(name) as scope:
        kernel = tf.get_variable(scope+"w", shape=[n_in, n_out], dtype=tf.float32,
                                 initializer=tf.contrib.layers.xavier_initializer())
        biases = tf.Variable(tf.constant(0.1, shape=[n_out], dtype=tf.float32), name='b')n
        activation = tf.nn.relu_layer(input_op, kernel, biases, name= scope)
        p += [kernel, biases]
        return activation

此部分代码也很简单,全连接层需要训练参数,并且比卷积层更多(卷积层是局部连接),同样获得输入图片tensor的通道数(向量长度),同样获得输入图片tensor的通道数,注意每个训练参数都需要给定初始化值或初始化方式。bias利用constant函数初始化为较小的值0.1,而不是0, 再做relu非线性变。

4. 根据论文结构创建VGG16网络

input_op是输入的图像tensor shape=[batch, in_height, in_width, in_channels]
keep_prob是控制dropout比率的一个placeholder

def inference_op(input_op,keep_prob):
    # 初始化参数p列表
    p = []

VGG16包含6个部分,前面5段卷积,最后一段全连接,
每段卷积包含多个卷积层和pooling层.

下面是第一段卷积,包含2个卷积层和一个pooling层,
利用前面定义好的函数conv_op,mpool_op 创建这些层

# 第一段卷积的第一个卷积层 卷积核3*3,共64个卷积核(输出通道数),步长1*1
# input_op:224*224*3 输出尺寸224*224*64
conv1_1 = conv_op(input_op, name="conv1_1", kh=3, kw=3, n_out=64, dh=1,
                      dw=1, p=p)

# 第一段卷积的第2个卷积层 卷积核3*3,共64个卷积核(输出通道数),步长1*1
# input_op:224*224*64 输出尺寸224*224*64
conv1_2 = conv_op(conv1_1, name="conv1_2", kh=3, kw=3, n_out=64, dh=1,
                      dw=1, p=p)

# 第一段卷积的pooling层,核2*2,步长2*2
# input_op:224*224*64 输出尺寸112*112*64
pool1 = mpool_op(conv1_2, name="pool1", kh=2, kw=2, dh=2, dw=2)

下面是第2段卷积,包含2个卷积层和一个pooling层

# 第2段卷积的第一个卷积层 卷积核3*3,共128个卷积核(输出通道数),步长1*1
# input_op:112*112*64 输出尺寸112*112*128
conv2_1 = conv_op(pool1, name="conv2_1", kh=3, kw=3, n_out=128, dh=1,
                      dw=1, p=p)

# input_op:112*112*128 输出尺寸112*112*128
conv2_2 = conv_op(conv2_1, name="conv2_2", kh=3, kw=3, n_out=128, dh=1,
                      dw=1, p=p)

# input_op:112*112*128 输出尺寸56*56*128
pool2 = mpool_op(conv2_2, name="pool2", kh=2, kw=2, dh=2, dw=2)

下面是第3段卷积,包含3个卷积层和一个pooling层

# 第3段卷积的第一个卷积层 卷积核3*3,共256个卷积核(输出通道数),步长1*1
# input_op:56*56*128 输出尺寸56*56*256
conv3_1 = conv_op(pool2, name="conv3_1", kh=3, kw=3, n_out=256, dh=1,
                      dw=1, p=p)

# input_op:56*56*256 输出尺寸56*56*256
conv3_2 = conv_op(conv3_1, name="conv3_2", kh=3, kw=3, n_out=256, dh=1,
                      dw=1, p=p)

# input_op:56*56*256 输出尺寸56*56*256
conv3_3 = conv_op(conv3_2, name="conv3_3", kh=3, kw=3, n_out=256, dh=1,
                      dw=1, p=p)

# input_op:56*56*256 输出尺寸28*28*256
pool3 = mpool_op(conv3_3, name="pool3", kh=2, kw=2, dh=2, dw=2)

下面是第4段卷积,包含3个卷积层和一个pooling层

# 第3段卷积的第一个卷积层 卷积核3*3,共512个卷积核(输出通道数),步长1*1
# input_op:28*28*256 输出尺寸28*28*512
conv4_1 = conv_op(pool3, name="conv4_1", kh=3, kw=3, n_out=512, dh=1,
                      dw=1, p=p)

# input_op:28*28*512 输出尺寸28*28*512
conv4_2 = conv_op(conv4_1, name="conv4_2", kh=3, kw=3, n_out=512, dh=1,
                      dw=1, p=p)

# input_op:28*28*512 输出尺寸28*28*512
conv4_3 = conv_op(conv4_2, name="conv4_3", kh=3, kw=3, n_out=512, dh=1,
                      dw=1, p=p)

# input_op:28*28*512 输出尺寸14*14*512
pool4 = mpool_op(conv4_3, name="pool4", kh=2, kw=2, dh=2, dw=2)

前面4段卷积发现,VGG16每段卷积都是把图像面积变为1/4,但是通道数翻倍, 因此图像tensor的总尺寸缩小一半。
下面是第5段卷积,包含3个卷积层和一个pooling层

# 第5段卷积的第一个卷积层 卷积核3*3,共512个卷积核(输出通道数),步长1*1
# input_op:14*14*512 输出尺寸14*14*512
conv5_1 = conv_op(pool4, name="conv5_1", kh=3, kw=3, n_out=512, dh=1,
                      dw=1, p=p)

# input_op:14*14*512 输出尺寸14*14*512
conv5_2 = conv_op(conv5_1, name="conv5_2", kh=3, kw=3, n_out=512, dh=1,
                      dw=1, p=p)

# input_op:14*14*512 输出尺寸14*14*512
conv5_3 = conv_op(conv5_2, name="conv5_3", kh=3, kw=3, n_out=512, dh=1,
                      dw=1, p=p)

# input_op:28*28*512 输出尺寸7*7*512
pool5 = mpool_op(conv5_3, name="pool5", kh=2, kw=2, dh=2, dw=2)

下面要经过全连接,需将第五段卷积网络的结果扁平化, reshape将每张图片变为77512=25088的一维向量

shp = pool5.get_shape()
flattened_shape = shp[1].value * shp[2].value * shp[3].value
# tf.reshape(tensor, shape, name=None) 将tensor变换为参数shape的形式。
resh1 = tf.reshape(pool5, [-1, flattened_shape], name="resh1")

第一个全连接层,是一个隐藏节点数为4096的全连接层,后面接一个dropout层,训练时保留率为0.5,预测时为1.0

fc6 = fc_op(resh1, name="fc6", n_out=4096, p=p)
fc6_drop = tf.nn.dropout(fc6, keep_prob, name="fc6_drop")

第2个全连接层,是一个隐藏节点数为4096的全连接层,后面接一个dropout层,训练时保留率为0.5,预测时为1.0

fc7 = fc_op(fc6_drop, name="fc7", n_out=4096, p=p)
fc7_drop = tf.nn.dropout(fc7, keep_prob, name="fc7_drop")

最后是一个1000个输出节点的全连接层,利用softmax输出分类概率,argmax输出概率最大的类别。

fc8 = fc_op(fc7_drop, name="fc8", n_out=1000, p=p)
softmax = tf.nn.softmax(fc8)
predictions = tf.argmax(softmax, 1)
return predictions, softmax, fc8, p

个人原创作品,转载需征求本人同意

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容

  • CNN on TensorFlow 本文大部分内容均参考于: An Intuitive Explanation o...
    _Randolph_阅读 7,674评论 2 31
  • 卷积神经网络是基于人工神经网络的深度机器学习方法,成功应用于图像识别领域。CNN采用了局部连接和权值共享,保持了网...
    dopami阅读 1,023评论 0 0
  • 文章作者:Tyan博客:noahsnail.com | CSDN | 简书 声明:作者翻译论文仅为学习,如有侵权请...
    SnailTyan阅读 9,014评论 0 16
  • 文章主要分为:一、深度学习概念;二、国内外研究现状;三、深度学习模型结构;四、深度学习训练算法;五、深度学习的优点...
    艾剪疏阅读 21,786评论 0 58
  • 【超级贝贝】2017.9.9学习力践行day117 幼儿园回来第三天,一回来就兴奋地和我说,妈妈我学了新的手指谣,...
    huina_fb9e阅读 177评论 0 0