作者: lukewood
创建日期: 2023/03/28
最后修改日期: 2023/03/28
描述: 使用 KerasCV 训练强大的图像分类器。
分类是为给定输入图像预测分类标签的过程。虽然分类是一个相对简单的计算机视觉任务,但现代方法仍然由几个复杂的组件构成。幸运的是,KerasCV 提供了构建常用组件的 API。
本指南演示了 KerasCV 在三个复杂性级别上解决图像分类问题的模块化方法:
KerasCV 使用 Keras 3 来处理 TensorFlow、PyTorch 或 Jax。下面的指南将使用 jax
后端。该指南在 TensorFlow 或 PyTorch 后端下无需更改,只需更新下面的 KERAS_BACKEND
。
我们使用 Keras 教授,官方 Keras 吉祥物,作为材料复杂度的视觉参考:
!pip install -q --upgrade keras-cv
!pip install -q --upgrade keras # 升级到 Keras 3.
import os
os.environ["KERAS_BACKEND"] = "jax" # @param ["tensorflow", "jax", "torch"]
import json
import math
import numpy as np
import keras
from keras import losses
from keras import ops
from keras import optimizers
from keras.optimizers import schedules
from keras import metrics
import keras_cv
# 导入 tensorflow 以使用 [`tf.data`](https://www.tensorflow.org/api_docs/python/tf/data) 及其预处理函数
import tensorflow as tf
import tensorflow_datasets as tfds
让我们从最简单的 KerasCV API 开始:一个预训练分类器。在这个例子中,我们将构建一个在 ImageNet 数据集上预训练的分类器。我们将使用该模型解决古老的“猫还是狗”问题。
KerasCV 中最高级别的模块是一个 任务。一个 任务 是一个 keras.Model
,由一个(通常是预训练的)主干模型和特定于任务的层组成。以下是使用 keras_cv.models.ImageClassifier
和 EfficientNetV2B0 主干模型的示例。
EfficientNetV2B0 是构建图像分类管道的一个很好的起始模型。该架构成功实现了高准确率,同时使用的参数数量为 7M。如果 EfficientNetV2B0 对于您希望解决的任务来说不够强大,请务必查看 KerasCV 的其他可用主干!
classifier = keras_cv.models.ImageClassifier.from_preset(
"efficientnetv2_b0_imagenet_classifier"
)
您可能会注意到与旧的 keras.applications
API 有一点小偏差;在旧 API 中,您会使用 EfficientNetV2B0(weights="imagenet")
来构造该类。虽然旧 API 非常适合分类,但它并没有有效扩展到其他需要复杂架构的用例,如目标检测和语义分割。
现在我们的分类器已经构建完成,让我们将其应用于这张可爱的猫咪图片!
filepath = keras.utils.get_file(origin="https://i.imgur.com/9i63gLN.jpg")
image = keras.utils.load_img(filepath)
image = np.array(image)
keras_cv.visualization.plot_image_gallery(
np.array([image]), rows=1, cols=1, value_range=(0, 255), show=True, scale=4
)
接下来,让我们从我们的分类器中获取一些预测:
predictions = classifier.predict(np.expand_dims(image, axis=0))
1/1 ━━━━━━━━━━━━━━━━━━━━ 4s 4s/step
预测以软max 分类排名的形式出现。我们可以使用简单的 argsort 函数找到前类的索引:
top_classes = predictions[0].argsort(axis=-1)
为了解码类映射,我们可以构建一个从类别索引到 ImageNet 类名称的映射。为了方便起见,我已经将 ImageNet 类映射存储在一个 GitHub gist 中。现在让我们下载并加载它。
classes = keras.utils.get_file(
origin="https://gist.githubusercontent.com/LukeWood/62eebcd5c5c4a4d0e0b7845780f76d55/raw/fde63e5e4c09e2fa0a3436680f436bdcb8325aac/ImagenetClassnames.json"
)
with open(classes, "rb") as f:
classes = json.load(f) # 加载类名
从 https://gist.githubusercontent.com/LukeWood/62eebcd5c5c4a4d0e0b7845780f76d55/raw/fde63e5e4c09e2fa0a3436680f436bdcb8325aac/ImagenetClassnames.json 下载数据
33567/33567 ━━━━━━━━━━━━━━━━━━━━ 0s 0us/step
现在我们可以通过索引简单地查找类名:
top_two = [classes[str(i)] for i in top_classes[-2:]]
print("前两个类是:", top_two)
前两个类是: ['埃及猫', '天鹅绒']
太好了!这两个似乎都是正确的! 然而,其中一个类是“天鹅绒”。 我们正在尝试将猫和狗进行分类。 我们不关心天鹅绒毛毯!
理想情况下,我们希望有一个只执行计算以确定图像是猫还是狗的分类器,并将其所有资源专用于此任务。 这可以通过微调我们自己的分类器来解决。
当有标签的图像特定于我们的任务时,微调自定义分类器可以提高性能。 如果我们想训练一个猫与狗的分类器,使用明确标记的猫与狗数据应该比通用分类器表现得更好! 对于许多任务,可能没有相关的预训练模型可用(例如,对特定于您应用程序的图像进行分类)。
首先,让我们开始加载一些数据:
BATCH_SIZE = 32
IMAGE_SIZE = (224, 224)
AUTOTUNE = tf.data.AUTOTUNE
tfds.disable_progress_bar()
data, dataset_info = tfds.load("cats_vs_dogs", with_info=True, as_supervised=True)
train_steps_per_epoch = dataset_info.splits["train"].num_examples // BATCH_SIZE
train_dataset = data["train"]
num_classes = dataset_info.features["label"].num_classes
resizing = keras_cv.layers.Resizing(
IMAGE_SIZE[0], IMAGE_SIZE[1], crop_to_aspect_ratio=True
)
def preprocess_inputs(image, label):
image = tf.cast(image, tf.float32)
# 静态调整图像大小,因为我们只迭代一次数据集。
return resizing(image), tf.one_hot(label, num_classes)
# 打乱数据集以增加批次的多样性。
# 10*BATCH_SIZE 基于更大机器可以处理更大打乱缓冲区的假设。
train_dataset = train_dataset.shuffle(
10 * BATCH_SIZE, reshuffle_each_iteration=True
).map(preprocess_inputs, num_parallel_calls=AUTOTUNE)
train_dataset = train_dataset.batch(BATCH_SIZE)
images = next(iter(train_dataset.take(1)))[0]
keras_cv.visualization.plot_image_gallery(images, value_range=(0, 255))
喵!
接下来,让我们构建我们的模型。 预设名称中使用 imagenet 表示骨干网是在 ImageNet 数据集上预训练的。 预训练的骨干网通过利用从可能更大数据集中提取的模式,从我们的标签示例中提取更多信息。
接下来,让我们组合我们的分类器:
model = keras_cv.models.ImageClassifier.from_preset(
"efficientnetv2_b0_imagenet", num_classes=2
)
model.compile(
loss="categorical_crossentropy",
optimizer=keras.optimizers.SGD(learning_rate=0.01),
metrics=["accuracy"],
)
从 https://storage.googleapis.com/keras-cv/models/efficientnetv2b0/imagenet/classification-v0-notop.h5 下载数据
24029184/24029184 ━━━━━━━━━━━━━━━━━━━━ 1s 0us/step
这里我们的分类器只是一个简单的 keras.Sequential
。
剩下要做的就是调用 model.fit()
:
model.fit(train_dataset)
216/727 ━━━━━━━━━━━━━━━━━━━━ 15s 30ms/step - 准确率: 0.8433 - 损失: 0.5113
Corrupt JPEG data: 99 extraneous bytes before marker 0xd9
254/727 ━━━━━━━━━━━━━━━━━━━━ 14s 30ms/step - 准确率: 0.8535 - 损失: 0.4941
Warning: unknown JFIF revision number 0.00
266/727 ━━━━━━━━━━━━━━━━━━━━ 14s 30ms/step - 准确率: 0.8563 - 损失: 0.4891
Corrupt JPEG data: 396 extraneous bytes before marker 0xd9
310/727 ━━━━━━━━━━━━━━━━━━━━ 12s 30ms/step - 准确率: 0.8651 - 损失: 0.4719
Corrupt JPEG data: 162 extraneous bytes before marker 0xd9
358/727 ━━━━━━━━━━━━━━━━━━━━ 11s 30ms/step - 准确率: 0.8729 - 损失: 0.4550
Corrupt JPEG data: 252 extraneous bytes before marker 0xd9
Corrupt JPEG data: 65 extraneous bytes before marker 0xd9
374/727 ━━━━━━━━━━━━━━━━━━━━ 10s 30ms/step - 准确率: 0.8752 - 损失: 0.4497
Corrupt JPEG data: 1403 extraneous bytes before marker 0xd9
534/727 ━━━━━━━━━━━━━━━━━━━━ 5s 30ms/step - 准确率: 0.8921 - 损失: 0.4056
Corrupt JPEG data: 214 extraneous bytes before marker 0xd9
636/727 ━━━━━━━━━━━━━━━━━━━━ 2s 30ms/step - 准确率: 0.8993 - 损失: 0.3837
Corrupt JPEG data: 2226 extraneous bytes before marker 0xd9
654/727 ━━━━━━━━━━━━━━━━━━━━ 2s 30ms/step - 准确率: 0.9004 - 损失: 0.3802
Corrupt JPEG data: 128 extraneous bytes before marker 0xd9
668/727 ━━━━━━━━━━━━━━━━━━━━ 1s 30ms/step - 准确率: 0.9012 - 损失: 0.3775
Corrupt JPEG data: 239 extraneous bytes before marker 0xd9
704/727 ━━━━━━━━━━━━━━━━━━━━ 0s 30ms/step - 准确率: 0.9032 - 损失: 0.3709
Corrupt JPEG data: 1153 extraneous bytes before marker 0xd9
712/727 ━━━━━━━━━━━━━━━━━━━━ 0s 30ms/step - 准确率: 0.9036 - 损失: 0.3695
Corrupt JPEG data: 228 extraneous bytes before marker 0xd9
727/727 ━━━━━━━━━━━━━━━━━━━━ 69s 62ms/step - 准确率: 0.9045 - 损失: 0.3667
<keras.src.callbacks.history.History at 0x7fce380df100>
predictions = model.predict(np.expand_dims(image, axis=0))
classes = {0: "猫", 1: "狗"}
print("Top class is:", classes[predictions[0].argmax()])
1/1 ━━━━━━━━━━━━━━━━━━━━ 3s 3s/step
Top class is: 猫
太棒了 - 看起来模型正确地分类了图像。
现在我们已经动手进行了分类,让我们再做最后一项任务:从零开始训练一个分类模型! 图像分类的一个标准基准是ImageNet数据集,然而由于许可限制,我们在本教程中将使用CalTech 101图像分类数据集。 虽然我们在本指南中使用了更简单的CalTech 101数据集,但相同的训练模板也可以用于ImageNet,以达到接近最新的性能分数。
让我们先从数据加载开始:
NUM_CLASSES = 101
# 将epochs改为100~以完全训练。
EPOCHS = 1
def package_inputs(image, label):
return {"images": image, "labels": tf.one_hot(label, NUM_CLASSES)}
train_ds, eval_ds = tfds.load(
"caltech101", split=["train", "test"], as_supervised="true"
)
train_ds = train_ds.map(package_inputs, num_parallel_calls=tf.data.AUTOTUNE)
eval_ds = eval_ds.map(package_inputs, num_parallel_calls=tf.data.AUTOTUNE)
train_ds = train_ds.shuffle(BATCH_SIZE * 16)
正在下载和准备数据集125.64 MiB(下载:125.64 MiB,生成:132.86 MiB,总计:258.50 MiB)到/usr/local/google/home/rameshsampath/tensorflow_datasets/caltech101/3.0.1...
数据集caltech101已下载并准备到/usr/local/google/home/rameshsampath/tensorflow_datasets/caltech101/3.0.1。后续调用将重用此数据。
CalTech101数据集每幅图像的大小不同,因此我们使用ragged_batch()
API将它们批处理在一起,同时保持每个单独图像的形状信息。
train_ds = train_ds.ragged_batch(BATCH_SIZE)
eval_ds = eval_ds.ragged_batch(BATCH_SIZE)
batch = next(iter(train_ds.take(1)))
image_batch = batch["images"]
label_batch = batch["labels"]
keras_cv.visualization.plot_image_gallery(
image_batch.to_tensor(),
rows=3,
cols=3,
value_range=(0, 255),
show=True,
)
在我们先前的微调示例中,我们进行了静态调整大小操作,并没有利用任何图像增强。 这是因为对训练集的单次遍历就足以取得不错的结果。 在训练以解决更困难的任务时,您需要在数据管道中包含数据增强。
数据增强是一种使您的模型对输入数据的变化(如光照、裁剪和方向)具有鲁棒性的技术。
KerasCV在keras_cv.layers
API中包含了一些最有用的增强。
创建最佳的增强管道是一门艺术,但在本指南的这一部分,我们将提供一些分类的最佳实践建议。
关于图像数据增强需要注意的一点是,您必须小心不要将增强后的数据分布与原始数据分布偏移得太远。 目标是防止过拟合并提高泛化能力, 但完全偏离数据分布的样本只会给训练过程添加噪声。
我们将使用的第一个增强是RandomFlip
。
这种增强的行为或多或少符合您的预期:它要么翻转图像,要么不翻转。
虽然这种增强在CalTech101和ImageNet中是有用的,但应该注意的是,它不应在数据分布不是垂直镜像不变的任务上使用。
一个发生这种情况的数据集的例子是MNIST手写数字。
将一个6
沿垂直轴翻转会使数字看起来更像7
而不是6
,但标签仍然显示为6
。
random_flip = keras_cv.layers.RandomFlip()
augmenters = [random_flip]
image_batch = random_flip(image_batch)
keras_cv.visualization.plot_image_gallery(
image_batch.to_tensor(),
rows=3,
cols=3,
value_range=(0, 255),
show=True,
)
一半的图像已被翻转!
我们将使用的下一个增强是RandomCropAndResize
。
此操作选择图像的随机子集,然后将其调整为提供的目标大小。
通过使用这种增强,我们迫使我们的分类器变得空间不变。
此外,该层接受一个aspect_ratio_factor
,用于扭曲图像的纵横比。
虽然这可以提高模型性能,但应谨慎使用。
很容易导致长宽比失真的情况使样本远离原始训练集的数据分布。
记住——数据增强的目标是生成与训练集数据分布一致的更多训练样本!
RandomCropAndResize
也可以处理 tf.RaggedTensor
输入。在 CalTech101 图像数据集中,图像的尺寸各异。
因此,它们无法轻易地批量组合成一个密集的训练批次。
幸运的是,RandomCropAndResize
为您处理了 Ragged -> Dense 转换的过程!
让我们将 RandomCropAndResize
添加到我们的增强集合中:
crop_and_resize = keras_cv.layers.RandomCropAndResize(
target_size=IMAGE_SIZE,
crop_area_factor=(0.8, 1.0),
aspect_ratio_factor=(0.9, 1.1),
)
augmenters += [crop_and_resize]
image_batch = crop_and_resize(image_batch)
keras_cv.visualization.plot_image_gallery(
image_batch,
rows=3,
cols=3,
value_range=(0, 255),
show=True,
)
太好了!我们现在正在处理一批密集的图像。 接下来,让我们在训练集中加入一些空间和颜色抖动。 这将使我们生成一个对光线闪烁、 阴影等具有鲁棒性的分类器。
通过改变颜色和空间特征对图像进行增强的方法有无穷无尽,但也许最成熟的技术是
RandAugment
。
RandAugment
实际上是 10 种不同增强的集合:
AutoContrast
, Equalize
, Solarize
, RandomColorJitter
, RandomContrast
,
RandomBrightness
, ShearX
, ShearY
, TranslateX
和 TranslateY
。
在推理时,对于每张图像,num_augmentations
个增强器被随机采样,
每个增强器的幅度因子也会随机采样。
这些增强器随后会被按顺序应用。
KerasCV 使调节这些参数变得简单,使用 augmentations_per_image
和 magnitude
参数!
让我们尝试一下:
rand_augment = keras_cv.layers.RandAugment(
augmentations_per_image=3,
value_range=(0, 255),
magnitude=0.3,
magnitude_stddev=0.2,
rate=1.0,
)
augmenters += [rand_augment]
image_batch = rand_augment(image_batch)
keras_cv.visualization.plot_image_gallery(
image_batch,
rows=3,
cols=3,
value_range=(0, 255),
show=True,
)
看起来不错;但我们还没完呢! 如果某张图像缺少一个类的某个关键特征怎么办?例如,如果一片叶子挡住了猫耳朵的视线,但我们的分类器仅通过观察耳朵来学习识别猫?
解决这个问题的一种简单方法是使用 RandomCutout
,它随机去掉图像的一个子区域:
random_cutout = keras_cv.layers.RandomCutout(width_factor=0.4, height_factor=0.4)
keras_cv.visualization.plot_image_gallery(
random_cutout(image_batch),
rows=3,
cols=3,
value_range=(0, 255),
show=True,
)
虽然这相对合理地解决了问题,但它可能导致分类器对特征和由于切除产生的黑色像素区域之间的边界产生反应。
CutMix
通过使用更复杂(且更有效)的技术解决了同样的问题。
CutMix
不会用黑色像素替换切掉的区域,而是用从训练集中随机抽样的其他图像的区域替代这些区域!
在这个替换后,图像的分类标签被更新为原始和混合图像的类标签的混合。
在实践中这是什么样子呢?让我们看看:
cut_mix = keras_cv.layers.CutMix()
# CutMix 需要同时修改图像和标签
inputs = {"images": image_batch, "labels": label_batch}
keras_cv.visualization.plot_image_gallery(
cut_mix(inputs)["images"],
rows=3,
cols=3,
value_range=(0, 255),
show=True,
)
让我们暂时不将其添加到我们的增强器中——更多的内容稍后再谈!
接下来,让我们看看 MixUp()
。
不幸的是,尽管 MixUp()
已经被实验证明可以显著提高训练模型的鲁棒性和泛化能力,
但尚不清楚这种改进为何发生……不过一点点魔法总是没坏处的!
MixUp()
的工作原理是从一批图像中抽取两张,然后逐渐将它们的像素强度以及分类标签混合在一起。
让我们看看它的实际效果:
mix_up = keras_cv.layers.MixUp()
# MixUp 需要同时修改图像和标签
inputs = {"images": image_batch, "labels": label_batch}
keras_cv.visualization.plot_image_gallery(
mix_up(inputs)["images"],
rows=3,
cols=3,
value_range=(0, 255),
show=True,
)
仔细观察,你会发现这些图像已经被混合在一起。
我们不是对每个图像都应用CutMix()
和MixUp()
,而是选择其中之一应用于每个批次。
这可以使用keras_cv.layers.RandomChoice()
来实现。
cut_mix_or_mix_up = keras_cv.layers.RandomChoice([cut_mix, mix_up], batchwise=True)
augmenters += [cut_mix_or_mix_up]
现在让我们将最终的增强器应用于训练数据:
def create_augmenter_fn(augmenters):
def augmenter_fn(inputs):
for augmenter in augmenters:
inputs = augmenter(inputs)
return inputs
return augmenter_fn
augmenter_fn = create_augmenter_fn(augmenters)
train_ds = train_ds.map(augmenter_fn, num_parallel_calls=tf.data.AUTOTUNE)
image_batch = next(iter(train_ds.take(1)))["images"]
keras_cv.visualization.plot_image_gallery(
image_batch,
rows=3,
cols=3,
value_range=(0, 255),
show=True,
)
我们还需要调整评估集的大小,以获得模型期望的图像大小的密集批次。在这种情况下,我们使用确定性的keras_cv.layers.Resizing
来避免对评估指标添加噪声。
inference_resizing = keras_cv.layers.Resizing(
IMAGE_SIZE[0], IMAGE_SIZE[1], crop_to_aspect_ratio=True
)
eval_ds = eval_ds.map(inference_resizing, num_parallel_calls=tf.data.AUTOTUNE)
image_batch = next(iter(eval_ds.take(1)))["images"]
keras_cv.visualization.plot_image_gallery(
image_batch,
rows=3,
cols=3,
value_range=(0, 255),
show=True,
)
最后,让我们解包数据集并准备将其传递给model.fit()
,该方法接受一个(images, labels)
的元组。
def unpackage_dict(inputs):
return inputs["images"], inputs["labels"]
train_ds = train_ds.map(unpackage_dict, num_parallel_calls=tf.data.AUTOTUNE)
eval_ds = eval_ds.map(unpackage_dict, num_parallel_calls=tf.data.AUTOTUNE)
数据增强无疑是训练现代分类器中最困难的部分。 恭喜你走到这一步!
为了达到最佳性能,我们需要使用学习率调度,而不是单一的学习率。虽然我们不会详细讨论在这里使用的带热身的余弦衰减调度,你可以在这里阅读更多相关内容。
def lr_warmup_cosine_decay(
global_step,
warmup_steps,
hold=0,
total_steps=0,
start_lr=0.0,
target_lr=1e-2,
):
# 余弦衰减
learning_rate = (
0.5
* target_lr
* (
1
+ ops.cos(
math.pi
* ops.convert_to_tensor(
global_step - warmup_steps - hold, dtype="float32"
)
/ ops.convert_to_tensor(
total_steps - warmup_steps - hold, dtype="float32"
)
)
)
)
warmup_lr = target_lr * (global_step / warmup_steps)
if hold > 0:
learning_rate = ops.where(
global_step > warmup_steps + hold, learning_rate, target_lr
)
learning_rate = ops.where(global_step < warmup_steps, warmup_lr, learning_rate)
return learning_rate
class WarmUpCosineDecay(schedules.LearningRateSchedule):
def __init__(self, warmup_steps, total_steps, hold, start_lr=0.0, target_lr=1e-2):
super().__init__()
self.start_lr = start_lr
self.target_lr = target_lr
self.warmup_steps = warmup_steps
self.total_steps = total_steps
self.hold = hold
def __call__(self, step):
lr = lr_warmup_cosine_decay(
global_step=step,
total_steps=self.total_steps,
warmup_steps=self.warmup_steps,
start_lr=self.start_lr,
target_lr=self.target_lr,
hold=self.hold,
)
return ops.where(step > self.total_steps, 0.0, lr)
调度结果如我们所期待的那样。
接下来,让我们构建这个优化器:
total_images = 9000
total_steps = (total_images // BATCH_SIZE) * EPOCHS
warmup_steps = int(0.1 * total_steps)
hold_steps = int(0.45 * total_steps)
schedule = WarmUpCosineDecay(
start_lr=0.05,
target_lr=1e-2,
warmup_steps=warmup_steps,
total_steps=total_steps,
hold=hold_steps,
)
optimizer = optimizers.SGD(
weight_decay=5e-4,
learning_rate=schedule,
momentum=0.9,
)
终于,我们现在可以构建我们的模型并调用fit()
!
keras_cv.models.EfficientNetV2B0Backbone()
是一个方便的别名。
keras_cv.models.EfficientNetV2Backbone.from_preset('efficientnetv2_b0')
。
请注意,此预设不附带任何预训练权重。
backbone = keras_cv.models.EfficientNetV2B0Backbone()
model = keras.Sequential(
[
backbone,
keras.layers.GlobalMaxPooling2D(),
keras.layers.Dropout(rate=0.5),
keras.layers.Dense(101, activation="softmax"),
]
)
由于 MixUp() 和 CutMix() 产生的标签在某种程度上是人为的,我们采用标签平滑来防止模型过拟合于这种增强过程的伪影。
loss = losses.CategoricalCrossentropy(label_smoothing=0.1)
让我们编译模型:
model.compile(
loss=loss,
optimizer=optimizer,
metrics=[
metrics.CategoricalAccuracy(),
metrics.TopKCategoricalAccuracy(k=5),
],
)
最后调用 fit()。
model.fit(
train_ds,
epochs=EPOCHS,
validation_data=eval_ds,
)
96/96 ━━━━━━━━━━━━━━━━━━━━ 65s 462ms/step - categorical_accuracy: 0.0068 - loss: 6.6096 - top_k_categorical_accuracy: 0.0497 - val_categorical_accuracy: 0.0122 - val_loss: 4.7151 - val_top_k_categorical_accuracy: 0.1596
<keras.src.callbacks.history.History at 0x7fc7142c2e80>
恭喜!你现在知道如何从头开始使用 KerasCV 训练一个强大的图像分类器。 根据您应用中标记数据的可用性,从头开始训练可能比使用迁移学习和上面讨论的数据增强更强大。对于较小的数据集,预训练模型通常能产生更高的准确性和更快的收敛。
虽然图像分类可能是计算机视觉中最简单的问题,但现代领域有许多复杂的组件。
幸运的是,KerasCV 提供了强大、生产级的 API,使得用一行代码组装大多数这些组件成为可能。
通过使用 KerasCV 的 ImageClassifier
API、预训练权重和 KerasCV 数据增强,您可以在几百行代码中组装训练强大分类器所需的一切!
作为后续练习,尝试以下内容: