手写数字识别 CNN MNIST_data LeNet

MNIST_data数据集下载

链接:https://pan.baidu.com/s/1wble_t39TR4uAgTFZjnSnQ 
提取码:5wxq 

一、CNN识别手写数字

# coding = utf-8
# 2019/7/22  Luckyxxt:有趣的事,Python永远不会缺席!

import tensorflow as tf
import time
import tensorflow.examples.tutorials.mnist.input_data as input_data
old_v = tf.logging.get_verbosity()
tf.logging.set_verbosity(tf.logging.ERROR)
t1 = time.time()
mnist_data_folder = './MNIST_data'
mnist = input_data.read_data_sets(mnist_data_folder, one_hot=True)
tf.logging.set_verbosity(old_v)
print('mnist.train.images.shape', mnist.train.images.shape)
print('mnist.train.labels', mnist.train.labels.shape)
print('mnist.test.images.shape', mnist.test.images.shape)
print('mnist.test.labels.shape', mnist.test.labels.shape)
'''
1、训练数据 55000训练样本 784 为28 * 28 的像素值 mnist.train.images.shape (55000, 784)
2、训练数据label 10个种类 minist.train.labels (55000, 10)
3、测试数据 10000 minist.test.images.shape (10000, 784)
4、测试数据label 10个种类minist.test.labeld.shape (10000, 10)
'''
# 下面先构建数据流图 最后在会话里面启动
'''
#placeholder()函数是在神经网络构建graph的时候在模型中的占位,
#此时并没有把要输入的数据传入模型,它只会分配必要的内存。
#等建立session,在会话中,运行模型的时候通过feed_dict()函数向占位符传入数据
#这里建立了两个占位
'''

image_place = tf.placeholder(tf.float32, shape=([None, 784]))
label_place = tf.placeholder(tf.float32, shape=([None, 10]))

'''
#我们需要创建大量的权重和偏置量
#所以我们定义了两个函数
#权重应用少量的噪声来打破对称性以及避免0梯度
#用一个较小的正数来初始化偏置项
'''


def weight_variable(shape):
    # tf.truncated_normal从截断的正态分布中输出随机值。
    # shape表示生成张量的维度,stddev是标准差,均值默认为0
    initial = tf.truncated_normal(shape, stddev=0.1)
    # 定义一个图变量,用于在会话中启动
    return tf.Variable(initial)


def bias_variable(shape):
    # 创建一个常数张量,shape代表张量的维度
    initial = tf.constant(0.1, shape=shape)
    # 定义一个图变量,用于在会话中启动
    return tf.Variable(initial)


'''
tf.nn.conv2d (  input/输入图片,
                filter/滤镜,
                strides/步幅,
                padding/是否填充,
                use_cudnn_on_gpu=None,是否使用cudnn加速,默认为true
                data_format=None,
                name=None)
input是要进行卷积的图片,形状为[ batch, in_height, in_weight, in_channel ]
batch为图片的数量,in_height 为图片高度,in_weight 为图片宽度,in_channel 为图片的通道数(彩色为3,黑白为1)

filter/滤镜 也是一个张量,形状为为 [ filter_height, filter_weight, in_channel, out_channels ]
filter_height 为卷积核高度,filter_weight 为卷积核宽度,
in_channel 是图像通道数 ,和 input 的 in_channel 要保持一致,out_channel 是卷积核数量。

strides/ 步幅  卷积时在图像每一维的步长,这是一个一维的向量,
[ 1, strides, strides, 1],第一位和最后一位固定必须是1

padding/是否填充 "SAME"是考虑边界,不足的时候用0去填充周围,"VALID"则不考虑
'''
# 定义一个构建卷积层的函数


def conv2d(x, W):
    return tf.nn.conv2d(x, W, strides=[1, 1, 1, 1], padding='SAME')


'''
tf.nn.max_pool(value, ksize, strides, padding, name=None)

value 卷积层的输入  [batch, height, width, channels]
ksize 池化窗口的大小,取一个四维向量,一般是[1, height, width, 1],不在batch和channels上做池化
strides/ 步幅  卷积时在图像每一维的步长,这是一个一维的向量,
[ 1, strides, strides, 1],第一位和最后一位固定必须是1
padding/是否填充 "SAME"是考虑边界,不足的时候用0去填充周围,"VALID"则不考虑
'''

# 定义一个构建最大池化层的函数


def max_pool(x):
    return tf.nn.max_pool(
        x, ksize=[
            1, 2, 2, 1], strides=[
            1, 2, 2, 1], padding='SAME')


'''
构建第一层的滤镜矩阵W和偏置量B
表示卷积核的大小为5×5,输入为1,输出为32,即共有32个卷积核(滤镜)
偏置量的值为0.1,因为有32个滤镜 所以有32个偏置量
'''

W_conv1 = weight_variable([5, 5, 1, 32])
b_conv1 = bias_variable([32])

print('W_conv1', W_conv1)
# W_conv1 
print('b_conv1', b_conv1)
# b_conv1 
# 改变X的形状,方便用于卷积
image_place_input = tf.reshape(image_place, [-1, 28, 28, 1])
'''
conv2d(image_place_input, W_conv1)  滤镜矩阵和图像矩阵进行卷积运算
conv2d(image_place_input, W_conv1) + b_conv1 运算后加上偏置量
tf.nn.relu 使用relu函数修正线性单元
'''
h_conv1 = tf.nn.relu(conv2d(image_place_input, W_conv1) + b_conv1)
# 使用池化函数对其进行池化
h_pool1 = max_pool(h_conv1)  # 这个时候,图片已经从28*28变成了14*14了

'''
同理,继续进行一次卷积和池化
表示卷积核的大小为5×5,输入为32,输出为64,即共有64个卷积核(滤镜)
偏置量的值为0.1,因为有64个滤镜 所以有64个偏置量
这样一波操作之后,图片会变成7*7了
'''

W_conv2 = weight_variable([5, 5, 32, 64])
b_conv2 = bias_variable([64])

h_conv2 = tf.nn.relu(conv2d(h_pool1, W_conv2) + b_conv2)
h_pool2 = max_pool(h_conv2)

'''
我们加入一个有1024个神经元的全连接层,用于处理整个图片。
将输入的值展平为一维向量 为7*7*64 = 3136个
7*7的图片 64个滤镜
将展平后的数据乘以矩阵w,并且加上偏置量b
做一个运算转化
tf.matmul()将矩阵a乘以矩阵b,生成a * b
转化后再次使用relu函数修正线性单元
'''
W_fc1 = weight_variable([3136, 1024])
b_fc1 = bias_variable([1024])
# 展平
h_pool2_flat = tf.reshape(h_pool2, [-1, 3136])
# 展平后的数据做运算
h_fc1 = tf.nn.relu(tf.matmul(h_pool2_flat, W_fc1) + b_fc1)

'''
dropout是指在深度学习网络的训练过程中,对于神经网络单元,按照一定的概率将其暂时从网络中丢弃。
注意是暂时,对于随机梯度下降来说,由于是随机丢弃,所以每一次运算都用的是不同的神经网络
我们用一个placeholder来代表一个神经元的输出在dropout中保持不变的概率。
这样我们可以在训练过程中启用dropout,在测试过程中关闭dropout。
tf.nn.dropout(x, keep_prob, noise_shape=None, seed=None,name=None)

x:指输入值
keep_prob: 设置神经元被选中的概率,在初始化时keep_prob是一个占位符
keep_prob = tf.placeholder(tf.float32)
tensorflow在run时设置keep_prob具体的值,例如keep_prob: 0.5
'''
keep_prob = tf.placeholder(tf.float32)
h_fc1_drop = tf.nn.dropout(h_fc1, keep_prob)

'''
增加一个softmax层,作为全连接层的一部分
tf.matmul()将矩阵a乘以矩阵b,生成a * b
转化后使用softmax函数去转化
'''
W_fc2 = weight_variable([1024, 10])
b_fc2 = bias_variable([10])
# tf.matmul()将矩阵a乘以矩阵b,生成a * b。
y_conv = tf.nn.softmax(tf.matmul(h_fc1_drop, W_fc2) + b_fc2)

# 定义损失函数
# logits代表神经网络最后一层的输出
# labels代表具体的标签值

# tf.reduce_mean 函数用于计算张量tensor沿着指定的数轴(tensor的某一维度)上的的平均值,主要用作降维或者计算tensor(图像)的平均值。
# 在计算loss的时候,最常见的一句话就是tf.nn.softmax_cross_entropy_with_logits
# softmax_loss = tf.reduce_mean(
#     tf.nn.softmax_cross_entropy_with_logits(logits=y_conv, labels=label_place))
# https://blog.csdn.net/qq_35203425/article/details/79773459
softmax_loss = tf.reduce_sum(
    tf.nn.softmax_cross_entropy_with_logits_v2(logits=y_conv, labels=label_place))
'''
tf.equal(x, y)  判断x, y是否相等,相等返回true,不相等false
tf.cast(x, dtype, name=None)  数据类型转换
x  待转换的数据     dtype 目标数据类型   name=None  操作的名称
tf.reduce_mean() 求均值
tf.argmax(input,axis)根据axis取值的不同返回每行或者每列最大值的索引
accuracy代表精度
首先将输出层每个数据所对应最大概率那个值所对应的数据索引与标签值所对应的索引相比较
如果两者一样,则返回1,如果两者不一致,则返回0,最终求出平均值作为精确度

使用AdamOptimizer为损失函数计算梯度
Adam优化算法:是一个寻找全局最优点的优化算法
'''

train_step = tf.train.AdadeltaOptimizer(1e-4).minimize(softmax_loss)
correct_prediction = tf.equal(tf.argmax(y_conv, 1), tf.argmax(label_place, 1))
# tf.cast()函数的作用是执行 tensorflow 中张量数据类型转换,比如读入的图片如果是int8类型的,
# 一般在要在训练前把图像的数据格式转换为float32。
accuracy = tf.reduce_mean(tf.cast(correct_prediction, tf.float32))

# 下面在会话里面启动

with tf.Session() as sess:
    # 初始化模型的参数,一般情况下,都需要加上这一句
    sess.run(tf.global_variables_initializer())
    # 循环训练
    for i in range(20000):
        '''xs,ys = mnist.train.next_batch(BATCH_SIZE) ,xs.shape,ys.shape,
        BATCH_SIZE=200 表示xs 的形状为(200,784),对应的 ys 的形状为(200,10)。
        mnist.train.next_batch()函数包含一个参数 BATCH_SIZE,表示随机从训练集中抽取 
        BATCH_SIZE 个样本输入神经网络,并将样本的像素值和标签分别赋给 xs 和 ys。
        '''
        batch = mnist.train.next_batch(50)  # 400

        # 每间隔500个,就输出一次精确度, 报告一次在验证集上的准确率
        if i % 500 == 0:  # 40
            # accuracy.eval() 函数的作用:在一个Seesion里面“评估”tensor的值(其实就是计算),首先执行之前的所有必要
            # 的操作来产生这个计算这个tensor需要的输入,然后通过这些输入产生这个tensor。在激发tensor.eval()这个函数
            # 之前,tensor的图必须已经投入到session里面,或者一个默认的session是有效的,或者显式指定session.
            train_accuracy = accuracy.eval(feed_dict={image_place: batch[0], label_place: batch[1], keep_prob: 1.0})
            print('step %d, training accuracy %g' % (i, train_accuracy))

        # 用相关的输入变量替换 feed_dict 中的值
        train_step.run(feed_dict={image_place: batch[0],label_place: batch[1],keep_prob: 0.5})

    print('test accuracy %g' % accuracy.eval(feed_dict={image_place: mnist.test.images, label_place: mnist.test.labels,keep_prob: 1.0}))


t2 = time.time()
print('time', t2 - t1)

二、LeNet识别手写数字

# coding = utf-8
# 2019/7/17  Luckyxxt:有趣的事,Python永远不会缺席!
''
'''
卷积就是根据卷积窗口,进行像素的加权求和。
所谓的池化,就是图片下采样。

CNN的每一个卷积层我们都要人为的选取合适的卷积核个数,及卷积核大小。每个卷积核与图片进行卷积,就可以得到一张特征图了,
比如LeNet-5经典结构中,第一层卷积核选择了6个,我们可以得到6个特征图,这些特征图也就是下一层网络的输入了。
我们也可以把输入图片看成一张特征图,作为第一层网络的输入。

输入尺寸:32*32,也就是相当于1024个神经元
卷积层:3个
降采样层:2个
全连接层:1个
输出:10个类别(数字0-9的概率)
'''
import tensorflow as tf
import matplotlib.pyplot as plt
import time
import datetime
import tensorflow.examples.tutorials.mnist.input_data as input_data
start1 = datetime.datetime.now()
start2 = time.time()
start3 = time.clock()

# (1)模型参数设置
'''
学习率(Learning rate)作为监督学习以及深度学习中重要的超参,其决定着目标函数能否收敛到局部最小值以及何时收敛到最小值。
合适的学习率能够使目标函数在合适的时间内收敛到局部最小值。
学习率(Learning rate)作为监督学习以及深度学习中重要的超参,其决定着目标函数能否收敛到局部最小值以及何时收敛到最小值。
合适的学习率能够使目标函数在合适的时间内收敛到局部最小值。

在tensorflow中指数型衰减通过调用tf.train.exponential_decay(learning_rate, global_step, decay_steps, decay_rate,
staircase=False, name=None)实现。这里介绍一下decay_steps,若decay_steps=100,即表示100轮迭代后进行一次衰减,
staircase=True时,global_step/decay_steps会被转化为整数,

'''
# 设置学习率,
learning_rate = 0.01
# 设置训练次数
train_steps = 1000
# (2)导入数据
# mnist = input_data.read_data_sets('./MNIST_data', one_hot=True)

mnist_data_folder='./CNN/MNIST_data'
mnist = input_data.read_data_sets(mnist_data_folder,one_hot=True)
print('mnist.train.images.shape',mnist.train.images.shape)
print('mnist.train.labels',mnist.train.labels.shape)
print('mnist.test.images.shape',mnist.test.images.shape)
print('mnist.test.labels.shape',mnist.test.labels.shape)

'''
1、训练数据 55000训练样本 784 为28 * 28 的像素值 mnist.train.images.shape (55000, 784)
2、训练数据label 10个种类 minist.train.labels (55000, 10)
3、测试数据 10000 minist.test.images.shape (10000, 784)
4、测试数据label 10个种类minist.test.labeld.shape (10000, 10)
'''


# (3)定义相关函数
# 定义卷积层
def conv(input,filter_shape,bias_shape,strides_shape):
    filter = tf.get_variable('filter',filter_shape,initializer=tf.truncated_normal_initializer())
    # tf.get_variable(name, shape, initializer): name就是变量的名称,shape是变量的维度,initializer是变量初始化的方式
    # tf.truncated_normal_initializer:截取的正态分布
    bias = tf.get_variable('bias',bias_shape,initializer=tf.truncated_normal_initializer())
    conv = tf.nn.conv2d(input,filter,strides=strides_shape,padding='SAME')
    # https://cuijiahua.com/blog/2018/01/dl_3.html
    #对于卷积层C1内的每个像素都与输入图像中的卷积核像素和1个bias有连接。结果通过sigmoid
    # tf.sigmoid函数具有以下所列的别名:tf.nn.sigmoid
    output = tf.nn.sigmoid(conv+bias)
    return output
# 定义池化层
def pooling(input,ksize_shape,strides_shape):
    output = tf.nn.max_pool(input,ksize=ksize_shape,strides=strides_shape,padding='SAME')
    return output
'''
参数是四个,和卷积很类似:
第一个参数value:需要池化的输入,一般池化层接在卷积层后面,所以输入通常是feature map,依然是[batch, height, width, channels]这样的shape

第二个参数ksize:池化窗口的大小,取一个四维向量,一般是[1, height, width, 1],因为我们不想在batch和channels上做池化,所以这两个维度设为了1

第三个参数strides:和卷积类似,窗口在每一个维度上滑动的步长,一般也是[1, stride,stride, 1]

第四个参数padding:和卷积类似,可以取'VALID' 或者'SAME'

返回一个Tensor,类型不变,shape仍然是[batch, height, width, channels]这种形式
'''
# 定义全连接层
def connection(input, weight_shape, bias_shape, flat_shape):
    weight = tf.get_variable("weight", weight_shape, initializer= tf.truncated_normal_initializer())
    bias = tf.get_variable("bias", bias_shape, initializer= tf.truncated_normal_initializer())

    flat = tf.reshape(input, flat_shape)
    output = tf.nn.sigmoid(tf.matmul(flat, weight) + bias)
    return output

# (4)模型构建
'''
# 注意,这里的 with 和 python 中其他的 with 是不一样的
# 执行完 with 里边的语句之后,这个 conv1/ 和 conv2/ 空间还是在内存中的。这时候如果再次执行上面的代码
# 就会再生成其他命名空间

通常使用 with 语句调用上下文管理器,也可以通过直接调用其方法来使用。
'''
with tf.name_scope('Input'):
    #tf.name_scope 主要结合 tf.Variable() 来使用,方便参数命名管理,目的是为了解决命名冲突问题
    x_data = tf.placeholder(tf.float32, [None, 784])# 声明输入图片数据,类别
    y_data = tf.placeholder(tf.float32, [None, 10])
    x_image = tf.reshape(x_data, [-1, 28, 28, 1])# 输入图片数据转化
# 第一层卷积层,初始化卷积核参数、偏置值,该卷积层5*5大小,一个通道,共有6个不同卷积核
with tf.variable_scope('Conv1'):#tf.variable_scope() 主要结合 tf.get_variable() 来使用,实现 变量共享。
    conv1_output = conv(x_image, [5, 5, 1, 6], [6], [1, 1, 1, 1])
'''LeNet5的第一层,输入x_image,输出6个大小为28*28(32-5+1=28)的 feature maps, 
对输入图像进行第一次卷积运算(使用 6 个大小为 5*5 的卷积核),得到6个C1特征图(6个大小为28*28的 feature maps, 32-5+1=28)。
卷积核的大小为5*5,总共就有6*(5*5+1)=156个参数,其中+1是表示一个核有一个bias。
对于卷积层C1,C1内的每个像素都与输入图像中的5*5个像素和1个bias有连接,
神经元数量:6*28*28=4704
6*(5*5+1)=156个参数。
所以总共有156*28*28=122304个连接(connection)
'''
with tf.variable_scope('Pooling1'):
    pooling1_output = pooling(conv1_output, [1, 2, 2, 1], [1, 2, 2, 1])
'''LeNet5的第二层,输入conv1_output,即6个大小为14*14的,使用 2*2核 进行池化, 输出6个14*14的feature maps,
S2这个pooling层是对C1中的2*2区域内的像素求和乘以一个权值系数再加上一个偏置,然后将这个结果再做一次映射。
神经元数量:14*14*6=1176
参数:(2*2+1)*6=30
连接数:(2*2+1)*6*14*14=5880,S2中每个特征图的大小是C1中特征图大小的1/4。
'''
with tf.variable_scope('Conv2'):
    conv2_output = conv(pooling1_output, [5, 5, 6, 16], [16], [1, 1, 1, 1])
'''输入6个大小为14*14的组合,输出featureMap大小:16个10*10 (14-5+1)=10(之前是6个),卷积核大小:5*5,
C3中的每个特征map是连接到S2中的所有6个或者几个特征map的,表示本层的特征map是上一层提取到的特征map的不同组合。
神经元数量:16*10*10=1600
可训练参数:6*(3*5*5+1)+6*(4*5*5+1)+3*(4*5*5+1)+1*(6*5*5+1)=1516
连接数:10*10*1516=151600
'''
with tf.variable_scope('Pooling2'):
    pooling2_output = pooling(conv2_output, [1, 2, 2, 1], [1, 2, 2, 1])
'''输入16个大小为10*10的组合,使用 2*2核 进行池化,输出featureMap大小:16个5*5 (10/2),
神经元数量:5*5*16=400
可训练参数(2*2+1)*16=80
连接数:16*(2*2+1)*5*5=2000
'''
with tf.variable_scope('Conv3'):
    conv3_output = conv(pooling2_output, [5, 5, 16, 120], [120], [1, 1, 1, 1])
'''输入16个大小为5*5的组合,使用120个 5*5核 进行池化,输出featureMap大小:120个1*1 ,
神经元数量:120*1*1=120
可训练参数/连接:120*(16*5*5+1)=48120
'''
with tf.variable_scope('Connection'):
    connection_output = connection(conv3_output, [7*7*120, 80], [80], [-1, 7*7*120])# 权值参数
'''
输入:conv3_output 120维向量
计算输入向量和权重向量之间的点积,再加上一个偏置,结果通过sigmoid函数输出。
全连接层有84个节点,对应于一个7x12的比特图-1表示白色,1表示黑色,
这样每个符号的比特图的黑白色就对应于一个编码。该层的训练参数和连接数是(120 + 1)x84=10164。

可训练参数:84*(120+1)=10164


'''
with tf.name_scope('Output'):
    weight = tf.Variable( tf.truncated_normal([80, 10]),dtype= tf.float32)
    bias = tf.Variable(tf.truncated_normal([10]),dtype= tf.float32)
    y_model = tf.nn.softmax(tf.add(tf.matmul(connection_output, weight), bias))
'''
Output层也是全连接层,共有10个节点,分别代表数字0到9,且如果节点i的值为0,
则网络识别的结果是数字i。采用的是径向基函数(RBF)的网络连接方式。
该层有84x10=840个设定的参数和连接https://www.cnblogs.com/Edison25/p/11039673.html
'''
# 5)定义损失函数和训练精度:使用交叉熵作为损失函数
with tf.name_scope('Loss'):
    loss = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits_v2(labels=y_data, logits=y_model))
    tf.summary.scalar('The variation of the loss', loss)

with tf.name_scope('Accuracy'):
    prediction = tf.equal(tf.argmax(y_model, 1), tf.argmax(y_data, 1))
    accuracy = tf.reduce_mean(tf.cast(prediction, tf.float32))
    tf.summary.scalar('The variation of the accuracy', accuracy)


# (6)选择优化器及定义训练操作
# 此处选择Adam优化器
with tf.name_scope('Train'):
    train_op = tf.train.AdamOptimizer(learning_rate).minimize(loss)
'''
Adaptive Gradient,自适应梯度),它能够对每个不同的参数调整不同的学习率,
对频繁变化的参数以更小的步长进行更新,而稀疏的参数以更大的步长进行更新。
'''
# (7)创建会话进行训练
with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())
    x_batch, y_batch = mnist.train.next_batch(50)# 从train的集合中选取batch_size=50个训练数据
    writer = tf.summary.FileWriter('logs/', sess.graph)#指定一个文件用来保存图
    # https: // www.cnblogs.com / lyc - seu / p / 8647792.html
    merged = tf.summary.merge_all()#这将训练过程数据保存在filewriter指定的文件中
    batch_x, batch_y = mnist.train.next_batch(200)
    a = []
    for _ in range(train_steps):#train_steps = 1000
        sess.run(train_op, feed_dict={x_data: batch_x, y_data: batch_y})
        if _ % 50 == 0:#20次
            print(sess.run(accuracy, feed_dict={x_data: mnist.test.images, y_data: mnist.test.labels}))
            # 调用sess.run运行图,生成一步的训练过程数据 ,调用train_writer的add_summary方法将训练过程以及训练步数保存
            summary, acc = sess.run([merged, accuracy],
                                    feed_dict={x_data: mnist.test.images, y_data: mnist.test.labels})
            a.append(acc)
            writer.add_summary(summary, _)
    writer.close()

    plt.plot(a)
    plt.title('The variation of the acuracy')
    plt.xlabel('The sampling point')
    plt.ylabel('Accuracy')
    plt.tight_layout()
    plt.show()

    saver = tf.train.Saver(max_to_keep=6)#保存最后一代的模型,则只需要将max_to_keep设置为1即可
    saver.save(sess, 'logs/LeNet_mnist_model.ckpt')

end1 = datetime.datetime.now()
end2 = time.time()
end3 = time.clock()


print('time2:',end2-start2)


'''time2: 590.9529421329498约10min
0.1195
0.3366
0.2483
0.3491
0.3478
0.3356
0.3374
0.3553
0.3314
0.3327
0.3292
0.2954
0.3028
0.3141
0.3542
0.377
0.309
0.1759
0.1901
0.1898

'''
# (9)Tensorboard可视化  Tensorboard和tensorflow版本要一致

# tensorboard -logdir='E:\jupyter\PycharmProjects\AI_master\CNN\logs'


saver.restore(sess, './save_model')

 

手写数字识别 CNN MNIST_data LeNet_第1张图片

tensorboard


tensorboard -logdir='E:\jupyter\PycharmProjects\AI_master\CNN\logs'

手写数字识别 CNN MNIST_data LeNet_第2张图片

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

你可能感兴趣的:(CNN)