我爱编程

深度学习零基础学习笔记一(优达学城)

2017-07-19  本文已影响0人  linanwx

前言

突发奇想想学机器学习,这里是学习过程的笔记

准备

我做了这些准备工作

学习这些知识应该足以进行接下来的优达学城的学习

课程一 从机器学习到深度学习

前言

小节1-8,主要介绍了深度学习的发展现状等等知识。

image.png

小节9-12介绍了softmax模型。

粗略浏览机器学习实战后,在机器学习实战这本书中,大致介绍了机器学习的几种算法。从表面上来看,机器学习是一些分类和聚类算法。在这些算法中,介绍了一种算法,叫做逻辑回归分类。

在小节9-12中,主要介绍了分类器模型——逻辑回归,分类函数使用的是softmax函数。

softmax

这张图片可以表明什么是softmax函数了。对原来数列中的每个数z求exp(z),新数的大小所占的比例就是新数的softmax概率。

如果输入同比例扩大,则分类器的结果越两极化,越自信,如果输入同比例缩小,分类器结果趋于平均,不自信。

import numpy as np
def softmax(x):
    """Compute softmax values for each sets of scores in x."""
    expList = [np.exp(i) for i in x]
    expSum = sum(expList)
    x = [i/expSum for i in expList]
    return np.array(x)
image.png

13-14节主要讲One-Hot编码。在softmax函数给出一组概率数列之后,如何确定分类呢?例如概率最高的为1,其他的为0,这样的一个数列,属于One-Hot编码。这种编码是已经确定了分类。

交叉熵

15-16节讲了交叉熵。softmax可以计算概率数列,OneHot是已经确定的分类,那如何计算概率数列到某个分类的距离呢?使用交叉熵来度量这个距离。

image.png image.png

17-20 节讲解了如何使用这个分类器。其中,18节讲了为什么需要采用一种特殊的初始数据。

sum = 1000000000

for i in range(1000000):
    sum += 0.000001

sum -= 1000000000
print(sum)

这段代码运行结果不是1。如果把sum换成一个很小的数字,例如1,而不是1000000000,我们发现结果误差变小了。基于这个原因,我们希望初始数据总是均值为0,并且各个方向的方差为一致的。例如一个灰度图片的像素值0-255,我们需要把它减去128,然后除以128,这样每一个数字都是-1到1之间的数字,这样的初始数据更适合用来训练。

image.png

这样,我们就可以进行训练了。回顾一下视频内容,xi是训练数据矩阵,w是随机权重矩阵,为了性能,随机值取自正态分布中轴为0,方差很小的分布函数,然后计算概率数列,和目标的距离。然后求出到所有目标的平均距离。我们的目的是让距离变小,所以我们沿着梯度下降的方向优化权重矩阵,同时优化截距b。不断重复这一个过程,直到局部最优为止。

https://www.docker-cn.com/community-edition#/download

配置官方中国镜像。

image.png

$ pip3 install jupyter
$ jupyter notebook
此时可以使用命令jupyter notebook打开一个jupyter编辑器

$ docker run -it -p 8888:8888 tensorflow/tensorflow
运行上述命令会自动下载tensorflow镜像,前提是仓库镜像设置成中国的镜像,否则下载很慢。运行命令后,会提示你打开网页,打开这个网址以后会显示tensorflow的jupyter编辑环境,前提是jupyter notebook安装正确

其中/Users/hahaha/tensorflow/是我的mac的一个文件夹,notebooks是tensorflow中的jupyter默认编辑目录。

在主机目录的挂载目录下面粘贴第一个作业文件,1_notmnist.ipynb。这个文件可以在这里找到: 1_notmnist.ipynb

作业一内容

作业代码段一

首先运行一下第一段代码的import,应该是没有任何出错的,此时什么也不会发生,如果出现了红色的输出错误,那就说明这些from import没有导入成功。

# These are all the modules we'll be using later. Make sure you can import them
# before proceeding further.
from __future__ import print_function
# print函数
import matplotlib.pyplot as plt
# 绘图工具
import numpy as np
# 矩阵计算
import os
# 文件路径
import sys
# 文件输出
import tarfile
# 解压缩
from IPython.display import display, Image
# 显示图片
from scipy import ndimage
# 图像处理
from sklearn.linear_model import LogisticRegression
# 逻辑回归模块线性模型
from six.moves.urllib.request import urlretrieve
# url处理
from six.moves import cPickle as pickle
# 数据处理

# Config the matplotlib backend as plotting inline in IPython
%matplotlib inline
# matplotlib是最著名的Python图表绘制扩展库,
# 它支持输出多种格式的图形图像,并且可以使用多种GUI界面库交互式地显示图表。
# 使用%matplotlib命令可以将matplotlib的图表直接嵌入到Notebook之中,
# 或者使用指定的界面库显示图表,它有一个参数指定matplotlib图表的显示方式。
# inline表示将图表嵌入到Notebook中。

作业代码段二

接下来是第二段代码,会进行下载用于训练和测试的字母集合,大概是300mb大小。下载成功后,可以看到挂载目录下面的这两个文件。

作业
url = 'https://commondatastorage.googleapis.com/books1000/'
last_percent_reported = None
data_root = '.' # Change me to store data elsewhere

def download_progress_hook(count, blockSize, totalSize):
  """A hook to report the progress of a download. This is mostly intended for users with
  slow internet connections. Reports every 5% change in download progress.
  """
# 钩子函数用来实时显示下载进度
  global last_percent_reported
  percent = int(count * blockSize * 100 / totalSize)

  if last_percent_reported != percent:
    if percent % 5 == 0:
      sys.stdout.write("%s%%" % percent)
      sys.stdout.flush()
    else:
      sys.stdout.write(".")
      sys.stdout.flush()
      
    last_percent_reported = percent
        
def maybe_download(filename, expected_bytes, force=False):
  """Download a file if not present, and make sure it's the right size."""
  dest_filename = os.path.join(data_root, filename)
#   data_root是当前目录,在这个目录上加上文件名,设置为要保存的文件位置
  if force or not os.path.exists(dest_filename):
#         force是强制下载,忽略已经下载的文件
    print('Attempting to download:', filename) 
    filename, _ = urlretrieve(url + filename, dest_filename, reporthook=download_progress_hook)
#     使用urlretrieve来下载文件,挂上钩子
    print('\nDownload Complete!')
  statinfo = os.stat(dest_filename)
# 获取下载到的文件的信息
  if statinfo.st_size == expected_bytes:
#         正确大小
    print('Found and verified', dest_filename)
  else:
#     错误大小,提示用户使用浏览器下载
    raise Exception(
      'Failed to verify ' + dest_filename + '. Can you get to it with a browser?')
  return dest_filename

train_filename = maybe_download('notMNIST_large.tar.gz', 247336696)
test_filename = maybe_download('notMNIST_small.tar.gz', 8458043)

作业代码段三

解压缩用例

num_classes = 10
# 数字总共有多少个
np.random.seed(133)
# 初始化随机种子
def maybe_extract(filename, force=False):
#     假设已经解压缩了
  root = os.path.splitext(os.path.splitext(filename)[0])[0]  # remove .tar.gz
#     splitext(filename)[0]用于去除一个后缀,用两次就是去除两次后缀,也就是去除.tar.gz这个后缀
  if os.path.isdir(root) and not force:
    # You may override by setting force=True.
#     已经解压缩了就不再解压缩了
    print('%s already present - Skipping extraction of %s.' % (root, filename))
  else:
    print('Extracting data for %s. This may take a while. Please wait.' % root)
    tar = tarfile.open(filename)
    sys.stdout.flush()
    tar.extractall(data_root)
    tar.close()
#     解压缩到当前目录下面
  data_folders = [
    os.path.join(root, d) for d in sorted(os.listdir(root))
    if os.path.isdir(os.path.join(root, d))]
  if len(data_folders) != num_classes:
    raise Exception(
      'Expected %d folders, one per class. Found %d instead.' % (
        num_classes, len(data_folders)))
  print(data_folders)
# 检查解压缩文件目录的数量与期待是否一致,并且打印解压缩出来文件的目录
  return data_folders
  
train_folders = maybe_extract(train_filename)
test_folders = maybe_extract(test_filename)

问题一

写出代码显示解压缩的文件内容信息

import random
import matplotlib.image as mpimg


def plot_samples(data_folders, sample_size, title=None):
    fig = plt.figure()
#     建立空图像
    if title: fig.suptitle(title, fontsize=16, fontweight='bold')
#         加入标题
    for folder in data_folders:
#         遍历每个字母
        image_files = os.listdir(folder)
        image_sample = random.sample(image_files, sample_size)
#         从该字母中随机选取一定数量的图片
        for image in image_sample:
            image_file = os.path.join(folder, image)
            ax = fig.add_subplot(len(data_folders), sample_size, sample_size * data_folders.index(folder) +
                                 image_sample.index(image) + 1)
#             创建一个子图
            image = mpimg.imread(image_file)
#     加载子图图片
            ax.imshow(image)
#     显示子图图片
            ax.set_axis_off() 
#     关闭子图坐标线

    fig.set_size_inches(18.5, 10.5)
#     设置图片显示的大小
    plt.show()


plot_samples(train_folders, 20, 'Train')
plot_samples(test_folders, 20, 'Test')

运行效果如下

训练.png 测试.png

可以看出,部分训练数据是有问题的

作业代码段四

这之后需要进行数据的归一化处理,就是让图像的每一个像素由0255变换到-1.01.0,并且持久化到文件中

image_size = 28  # Pixel width and height.
pixel_depth = 255.0  # Number of levels per pixel.
# 图片长宽和图片像素深度
def load_letter(folder, min_num_images):
  """Load the data for a single letter label."""
# 处理一个属于一个字母文件夹下面的文件
  image_files = os.listdir(folder)
#     列出该文件夹目录下面的所有文件
  dataset = np.ndarray(shape=(len(image_files), image_size, image_size),
                         dtype=np.float32)
#     创建一个长度为文件个数,宽度和高度为28的
    
  print(folder)
# 打印目录
  num_images = 0
# 初始化num_images
  for image in image_files:
#   对每一个文件处理
    image_file = os.path.join(folder, image)
#     获取完整文件路径
    try:
      image_data = (ndimage.imread(image_file).astype(float) - 
                    pixel_depth / 2) / pixel_depth
#     读入图像,并且归一化处理
      if image_data.shape != (image_size, image_size):
#         检查图像的宽高
        raise Exception('Unexpected image shape: %s' % str(image_data.shape))
      dataset[num_images, :, :] = image_data
#         读入到数据集合中
      num_images = num_images + 1
#     图片序号加一
    except IOError as e:
#         如果无法读取文件的话,则忽略该文件
      print('Could not read:', image_file, ':', e, '- it\'s ok, skipping.')
    
  dataset = dataset[0:num_images, :, :]
# 如果读进来的文件数量少于最小需要文件数量
  if num_images < min_num_images:
    raise Exception('Many fewer images than expected: %d < %d' %
                    (num_images, min_num_images))
#     显示缺少的文件数量
  print('Full dataset tensor:', dataset.shape)
#     显示文件数量,图片长宽
  print('Mean:', np.mean(dataset))
#     平均值
  print('Standard deviation:', np.std(dataset))
#     标准差
  return dataset
        
def maybe_pickle(data_folders, min_num_images_per_class, force=False):
  dataset_names = []
  for folder in data_folders:
#         对每一个字母文件夹处理
    set_filename = folder + '.pickle'
#     设置输出的文件
    dataset_names.append(set_filename)
#     设置处理过的文件夹
    if os.path.exists(set_filename) and not force:
      # You may override by setting force=True.
#     检查是否存在已处理过的文件
      print('%s already present - Skipping pickling.' % set_filename)
    else:
      print('Pickling %s.' % set_filename)
      dataset = load_letter(folder, min_num_images_per_class)
#         归一化处理这个文件夹下面的所有图片
      try:
        with open(set_filename, 'wb') as f:
          pickle.dump(dataset, f, pickle.HIGHEST_PROTOCOL)
#         持久化数据,将数据保存在硬盘上,而不是一直放在内存中
      except Exception as e:
        print('Unable to save data to', set_filename, ':', e)
  
  return dataset_names

train_datasets = maybe_pickle(train_folders, 45000)
test_datasets = maybe_pickle(test_folders, 1800)

问题2

显示处理过的图片

def plot_samples_2(data_folders, sample_size, title=None):
    fig = plt.figure()
#     建立空图像
    if title: fig.suptitle(title, fontsize=16, fontweight='bold')
#         加入标题
    for folder in data_folders:
#         遍历每个字母
        with open(folder, 'rb') as pk_f:
            data = pickle.load(pk_f)
            for index, image in enumerate(data):
                if index < sample_size :
#         从该字母中随机选取一定数量的图片
                    ax = fig.add_subplot(len(data_folders), sample_size, sample_size * data_folders.index(folder) +
                                 index + 1)
#     加载子图图片
                    ax.imshow(image)
#     显示子图图片
                    ax.set_axis_off() 
#     关闭子图坐标线

    fig.set_size_inches(18.5, 10.5)
#     设置图片显示的大小
    plt.show()
    

plot_samples_2(train_datasets, 20, 'Train')
plot_samples_2(test_datasets, 20, 'Test')
image.png image.png

问题3

检查每个字母下面的文件数目是否相似。

file_path = 'notMNIST_large/{0}.pickle'
for ele in 'ABCDEFJHIJ':
    with open(file_path.format(ele), 'rb') as pk_f:
#         遍历每一个目录
        dat = pickle.load(pk_f)
#     加载这个目录下面的持久化文件
    print('number of pictures in {}.pickle = '.format(ele), dat.shape[0])
#     打印相关信息

结果表明数目基本一致。


问题3效果

代码段——数据分割

数据不可能一次性就全部加载到内存中,这里对这些数据进行分割,接下来的这份代码对数据进行了分割

def make_arrays(nb_rows, img_size):
  if nb_rows:
    dataset = np.ndarray((nb_rows, img_size, img_size), dtype=np.float32)
#     创建一个空集合,数据类型是长rows宽img_size高img_size的矩阵,数据类型是浮点32位
    labels = np.ndarray(nb_rows, dtype=np.int32)
#     创建一个标签,数据类型是32位整型,长度是rows
  else:
    dataset, labels = None, None
  return dataset, labels
# 返回创建的数据类型

def merge_datasets(pickle_files, train_size, valid_size=0):
  num_classes = len(pickle_files)
#     需要处理的类别数量
  valid_dataset, valid_labels = make_arrays(valid_size, image_size)
#     建立有效数据集合,长度为有效长度
  train_dataset, train_labels = make_arrays(train_size, image_size)
#     建立训练数据集合,长度为训练长度
  vsize_per_class = valid_size // num_classes
  tsize_per_class = train_size // num_classes
# 计算给定训练长度和有效长度下每个类别的平均长度

  start_v, start_t = 0, 0
# 初始化下标,start_v是有效数据的开始,start_t是训练数据的开始
  end_v, end_t = vsize_per_class, tsize_per_class
# 初始化下标,end_v是有效数据的结束,end_t是训练数据的结束
  end_l = vsize_per_class + tsize_per_class
# 初始化下标,end_l是字母集合的结束,等于每个类别有效数据的长度+训练数据的长度
  for label, pickle_file in enumerate(pickle_files):  
#         遍历每一个pickle_file
    try:
      with open(pickle_file, 'rb') as f:
#         打开这个持久化文件
        letter_set = pickle.load(f)
#       加载数据集
        # let's shuffle the letters to have random validation and training set
        np.random.shuffle(letter_set)
#       打乱数据集的顺序
        if valid_dataset is not None:
#         如果不是测试集的话,更新测试集,否则 valid_dataset 不更新
          valid_letter = letter_set[:vsize_per_class, :, :]
#         numpy切片     http://brieflyx.me/2015/python-module/numpy-array-split/
#         从打乱的数据中选择 每类有效数据 数量的数据进行处理,放到 valid_letter 中
          valid_dataset[start_v:end_v, :, :] = valid_letter
#         把这份数据放到valid_dataset中
          valid_labels[start_v:end_v] = label
#         标记label 应该是 0~9中的一种
          start_v += vsize_per_class
          end_v += vsize_per_class
#         更新下标
#       循环结束时, valid_dataset 应该总长度为 valid_size 的一份数据, valid_labels是对应位置的标签

        train_letter = letter_set[vsize_per_class:end_l, :, :]
#       除去valid部分的随机其他元素,长度为 end_l - vsize_per_class = tsize_per_class
        train_dataset[start_t:end_t, :, :] = train_letter
#       循环结束时,train_dataset应该是总长为 train_size 的 一份数据
        
#       
        train_labels[start_t:end_t] = label
        start_t += tsize_per_class
        end_t += tsize_per_class
#       更新下标
    except Exception as e:
      print('Unable to process data from', pickle_file, ':', e)
      raise
    
  return valid_dataset, valid_labels, train_dataset, train_labels
            
            
train_size = 200000
valid_size = 10000
test_size = 10000

valid_dataset, valid_labels, train_dataset, train_labels = merge_datasets(
  train_datasets, train_size, valid_size)
_, _, test_dataset, test_labels = merge_datasets(test_datasets, test_size)

print('Training:', train_dataset.shape, train_labels.shape)
print('Validation:', valid_dataset.shape, valid_labels.shape)
print('Testing:', test_dataset.shape, test_labels.shape)

代码段——打散数据

permutation函数介绍:http://www.jianshu.com/p/f0eb10acaa2d

def randomize(dataset, labels):
#     labels.shape[0] 是 labels 的长度
  permutation = np.random.permutation(labels.shape[0])
#     随机取出这么多数字的打乱
  print(labels.shape[0])
  shuffled_dataset = dataset[permutation,:,:]
# 打乱数据
  shuffled_labels = labels[permutation]
# 打乱标签
  return shuffled_dataset, shuffled_labels
train_dataset, train_labels = randomize(train_dataset, train_labels)
test_dataset, test_labels = randomize(test_dataset, test_labels)
valid_dataset, valid_labels = randomize(valid_dataset, valid_labels)

问题4

检验打散后的数据是否正确

import random
def plot_sample_3(dataset, labels, title):
    fig = plt.figure()
    plt.suptitle(title, fontsize=16, fontweight='bold')
#     设置标题样式
    items = random.sample(range(len(labels)), 200)
#     打散 labels 长的顺序序列
    for i, item in enumerate(items):
#         随机取一个
        plt.subplot(10, 20, i + 1)
#     画子图
        plt.axis('off')
#     关闭坐标轴
        plt.title(chr(ord('A') + labels[item]))
#     加标题
        plt.imshow(dataset[item])
#     显示对应位置的子图
    fig.set_size_inches(18.5, 10.5)
    plt.show()
#     显示图片
 
plot_sample_3(train_dataset, train_labels, 'train dataset suffled')
plot_sample_3(valid_dataset, valid_labels, 'valid dataset suffled')
plot_sample_3(test_dataset, test_labels, 'test dataset suffled')
问题4

省略类似的两图

代码段——保存数据

pickle_file = os.path.join(data_root, 'notMNIST.pickle')
# 输出文件路径
try:
  f = open(pickle_file, 'wb')
# 打开这个文件
  save = {
    'train_dataset': train_dataset,
    'train_labels': train_labels,
    'valid_dataset': valid_dataset,
    'valid_labels': valid_labels,
    'test_dataset': test_dataset,
    'test_labels': test_labels,
    }
#     写入一个字典 string-ndarray
  pickle.dump(save, f, pickle.HIGHEST_PROTOCOL)
  f.close()
except Exception as e:
  print('Unable to save data to', pickle_file, ':', e)
  raise

代码段——显示保存数据的大小

statinfo = os.stat(pickle_file)
print('Compressed pickle size:', statinfo.st_size)

问题5

题目的Google翻译

通过构建,此数据集可能包含大量重叠样本,包括验证和测试集中也包含的训练数据! 训练和测试之间的重叠可能会使结果偏斜,如果您希望在没有重叠的环境中使用您的模型,但如果您希望在使用训练样本时再次看到训练样本,那么实际上是可以的。 测量培训,验证和测试样本之间的重叠程度。
可选问题:
数据集之间的重复数据怎么样? (几乎相同的图像)
创建一个消毒验证和测试集,并比较您在随后的作业中的准确性。

大概意思是训练数据不能和测试用的数据重合,否则导致准确度不准

参考代码:

import hashlib

pickle_file = os.path.join('.', 'notMNIST.pickle')
try:
    with open(pickle_file, 'rb') as f:
        data = pickle.load(f)
except Exception as e:
  print('Unable to open data from', pickle_file, ':', e)
  raise
# 自从保存数据后,如果kernel挂了,就可以从本地直接读取,不用重新运行之前的代码
# 如果报错的话,可以在网上搜索报错的异常

def calcOverlap(sourceSet, targetSet, description):
    sourceSetMd5 = np.array([hashlib.md5(img).hexdigest() for img in sourceSet])
#     建立一个md5表格
    targetSetMd5 = np.array([hashlib.md5(img).hexdigest() for img in targetSet])
#     建立一个md5表格
    overlap = np.intersect1d(sourceSetMd5, targetSetMd5, assume_unique=False)
#     去重
    print(description)
    print("overlap",overlap.shape[0], "from",sourceSetMd5.shape[0],"to", targetSetMd5.shape[0])
    print("rate",overlap.shape[0]*100.0/sourceSetMd5.shape[0],"% and", overlap.shape[0]*100.0/targetSetMd5.shape[0],"%")
#     打印重叠数量


calcOverlap(data['train_dataset'], data['valid_dataset'], "train_dataset & valid_dataset")
calcOverlap(data['train_dataset'], data['test_dataset'], "train_dataset & test_dataset")
calcOverlap(data['test_dataset'], data['valid_dataset'], "test_dataset & valid_dataset")```

![运行效果](https://img.haomeiwen.com/i4388248/2882159fe68dc672.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

- 去除重复图片资源
待更新

## 问题6
使用逻辑回归训练模型并且进行测试

- 参考代码

import random
def disp_sample_dataset(dataset, labels,trueLabels, title=None):

展示训练的结果

fig = plt.figure()
if title: fig.suptitle(title, fontsize=16, fontweight='bold')

设置标题样式

items = random.sample(range(len(labels)), 200)

随机选择一系列图片

for i, item in enumerate(items):
    plt.subplot(10, 20, i + 1)

设置一个子图

    plt.axis('off')

关闭坐标线

    lab = str(chr(ord('A') + labels[item]))
    trueLab = str(chr(ord('A') + trueLabels[item]))
    if lab == trueLab:
        plt.title( lab )
    else:
        plt.title(lab + " but " + trueLab)

加上标题

    plt.imshow(dataset[item])

显示这个图片

fig.set_size_inches(18.5, 10.5)
plt.show()

def train_and_predict(train_dataset, train_labels, test_dataset, test_labels ,sample_size):
regr = LogisticRegression()

生成训练器

X_train = train_dataset[:sample_size].reshape(sample_size, 784)

根据sample_size选择要训练的数据量

把二维向量压缩到一维向量

y_train = train_labels[:sample_size]

取出训练数据

regr.fit(X_train, y_train)

训练数据

X_test = test_dataset.reshape(test_dataset.shape[0], 28 * 28)

将测试数据压缩到一维向量

y_test = test_labels

测试数据所对应的真实标签

pred_labels = regr.predict(X_test)

生成预测数据

print('Accuracy:', regr.score(X_test, y_test), 'when sample_size=', sample_size)
disp_sample_dataset(test_dataset, pred_labels, test_labels, 'sample_size=' + str(sample_size))

train_and_predict(data['train_dataset'],data['train_labels'],data['test_dataset'],data['test_labels'], 1000)


![image.png](https://img.haomeiwen.com/i4388248/6b3fb8a1d1b1ce34.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)


## 模型性能

小节22~27提到了模型性能的相关知识。我们通常希望模型的性能能够达到100%,显然是不可能的。并且,为了使训练集的准确性提高,模型可能会发生过拟合。这时要遵循两点。
- 不要将训练数据一次性使用,而是分块使用,每次训练一部分
- 当模型参数使30个以上的用例由错误变成正确,则这个参数的改变是有效果的。


![模型性能](https://img.haomeiwen.com/i4388248/033910ba1d5c09e3.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

## 随机梯度下降
小节29~31讲解了什么是随机梯度下降
在训练过程中,为了让模型朝着最优的方向走,需要计算该点的导数。1.导数的计算量比较大,我们需要随机选择一部分样本来计算导数,来代替真实的导数。这就是随机梯度下降。2.为了减缓随机选择的随机性,我们使用动量的惯性来减少随机性。3.为了让后期模型能够稳定,我们减少学习的步长。

课程一结束


> 作业代码参考
> http://www.hankcs.com/ml/notmnist.html
上一篇下一篇

猜你喜欢

热点阅读