Python-深度学习第三版-六-

Python 深度学习第三版(六)

原文:deeplearningwithpython.io/chapters/

译者:飞龙

协议:CC BY-NC-SA 4.0

第十七章:图像生成

deeplearningwithpython.io/chapters/chapter17_image-generation

目前创意 AI 最受欢迎和最成功的应用是图像生成:学习潜在视觉空间并从中采样以创建全新的图片,这些图片是从真实图片中插值出来的——比如虚构人物、虚构地点、虚构的猫狗等等。

图像生成的深度学习

在本节和下一节中,我们将回顾一些与图像生成相关的高级概念,以及与该领域两大主要技术相关的实现细节:变分自编码器(VAEs)和扩散模型。请注意,我们在这里介绍的技术并不仅限于图像——你可以使用类似的模型来开发声音或音乐的潜在空间——但在实践中,迄今为止最有趣的结果都是通过图片获得的,这正是我们在这里关注的重点。

从图像的潜在空间中进行采样

图像生成的关键思想是开发一个低维的潜在空间,用于表示(这就像深度学习中的所有其他事物一样,是一个向量空间),其中任何一点都可以映射到一个“有效”的图像:一个看起来像真实事物的图像。能够实现这种映射的模块,以潜在点为输入,输出图像(像素网格),通常被称为生成器,有时也称为解码器。一旦学习到这样的潜在空间,你就可以从中采样点,并通过将它们映射回图像空间,生成以前从未见过的图像(见图 17.1)——训练图像之间的中间状态。

图 17.1:使用潜在向量空间采样新图像

此外,文本条件化使得将自然语言中的提示空间映射到潜在空间成为可能(见图 17.2),从而使得进行语言引导的图像生成成为可能——生成与文本描述相对应的图片。这类模型被称为文本到图像模型。

在潜在空间中在许多训练图像之间进行插值,使得这些模型能够生成无限多的视觉概念组合,包括许多以前没有人明确提出过的。比如在月球上骑自行车的马?你做到了。这使得图像生成成为创意人士进行创作的强大画笔。

图 17.2:语言引导的图像生成

当然,仍然存在需要克服的挑战。与所有深度学习模型一样,潜在空间并没有编码一个一致的物理世界模型,所以你可能会偶尔看到多指的手、不连贯的照明或混乱的物体。生成的图像的连贯性仍然是一个活跃的研究领域。在图 17.2 的情况下,尽管已经看到了成千上万张人们骑自行车的图片,但模型并没有以人类的方式理解骑自行车的含义——比如踩踏、转向或保持直立平衡等概念。这就是为什么你的骑自行车的马不太可能以可信的方式描绘出用后腿踩踏,就像人类艺术家会画的那样。

学习图像表示的潜在空间有多种不同的策略,每种策略都有其自身的特点。最常见的图像生成模型类型包括

  • 扩散模型

  • 变分自编码器 (VAEs)

  • 生成对抗网络 (GANs)

虽然此书的先前版本涵盖了生成对抗网络(GANs),但近年来它们已经逐渐过时,几乎被扩散模型所取代。在本版中,我们将涵盖 VAEs 和扩散模型,并跳过 GANs。在我们自己构建的模型中,我们将专注于无条件的图像生成——从潜在空间中采样图像而不需要文本条件。然而,你还将学习如何使用预训练的文本到图像模型以及如何探索其潜在空间。

变分自编码器

VAEs(变分自编码器),由 Kingma 和 Welling 于 2013 年 12 月^([1])和 Rezende、Mohamed 和 Wierstra 于 2014 年 1 月^([2])同时发现,是一种特别适合通过概念向量进行图像编辑的生成模型。它们是一种自编码器——一种旨在将输入编码到低维潜在空间并解码回原始输入的网络——它结合了深度学习和贝叶斯推理的思想。

VAEs 已经存在十多年了,但它们至今仍然相关,并继续在最近的研究中被使用。虽然 VAEs 永远不会是生成高保真图像的首选——在这方面扩散模型表现更佳——但它们仍然是深度学习工具箱中的一个重要工具,尤其是在可解释性、对潜在空间的控制和数据重建能力至关重要的场合。它也是你第一次接触自编码器的概念,了解这一点是有用的。VAEs 完美地展示了这类模型背后的核心思想。

经典的图像自编码器通过编码器模块将图像映射到潜在向量空间,然后通过解码器模块将其解码回与原始图像相同维度的输出(见图 17.3)。它通过使用与输入图像相同的图像作为目标数据进行训练,这意味着自编码器学习重建原始输入。通过对代码(编码器的输出)施加各种约束,可以使自编码器学习到数据更有趣或更少的潜在表示。最常见的是,将代码约束为低维和稀疏(主要是零),在这种情况下,编码器充当将输入数据压缩成更少信息位的方式。

图片

图 17.3:自动编码器:将输入x映射到压缩表示,然后解码回x'

在实践中,这种经典的自动编码器并没有导致特别有用或结构良好的潜在空间。它们在压缩方面也不太有用。因此,它们在很大程度上已经过时。然而,VAE 通过一点统计魔法增强了自动编码器,迫使它们学习连续、高度结构的潜在空间。它们已经证明是图像生成的一个强大工具。

相反,变分自编码器(VAE)不是将其输入图像压缩到潜在空间中的固定代码,而是将图像转换为统计分布的参数:均值和方差。本质上,这意味着我们假设输入图像是由统计过程生成的,并且在这个过程中应该考虑随机性。VAE 然后使用均值和方差参数随机采样分布中的一个元素,并将该元素解码回原始输入(见图 17.4)。这个过程的不确定性提高了鲁棒性,并迫使潜在空间在各个地方编码有意义的表示:潜在空间中采样的每个点都被解码为有效的输出。

图片

图 17.4:VAE 将图像映射到两个向量z_meanz_log_sigma,这些向量定义了潜在空间上的概率分布,用于采样一个潜在点进行解码。

在技术术语上,以下是 VAE 的工作原理:

  1. 编码器模块将输入样本input_img转换到表示的潜在空间中的两个参数,z_meanz_log_variance

  2. 您从假设生成输入图像的潜在正态分布中随机采样一个点z,通过z = z_mean + exp(z_log_variance) * epsilon,其中epsilon是一个包含小值的随机张量。

  3. 解码器模块将潜在空间中的这个点映射回原始输入图像。

因为epsilon是随机的,这个过程确保了每个接近你编码input_imgz-mean)的潜在位置的点都可以解码成类似于input_img的东西,从而迫使潜在空间具有连续的有意义性。潜在空间中任何两个接近的点都会解码成高度相似的画面。连续性,加上潜在空间低维度的特性,迫使潜在空间中的每个方向都编码了数据的有意义变化轴,使得潜在空间非常结构化,因此非常适合通过概念向量进行操作。

VAE 的参数通过两个损失函数进行训练:一个重建损失,它迫使解码样本与初始输入相匹配,以及一个正则化损失,它有助于学习均匀的潜在分布并减少对训练数据的过度拟合。从示意图上看,这个过程看起来是这样的:

# Encodes the input into a mean and variance parameter
z_mean, z_log_variance = encoder(input_img)
# Draws a latent point using a small random epsilon
z = z_mean + exp(z_log_variance) * epsilon
# Decodes z back to an image
reconstructed_img = decoder(z)
# Instantiates the autoencoder model, which maps an input image to its
# reconstruction
model = Model(input_img, reconstructed_img) 

然后,您可以使用重建损失和正则化损失来训练模型。对于正则化损失,我们通常使用一个表达式(Kullback–Leibler 散度),其目的是将编码器输出的分布推向一个以 0 为中心的均匀分布。这为编码器提供了对其建模的潜在空间结构的合理假设。

现在我们来看一下在实践中实现 VAE 是什么样的!

使用 Keras 实现 VAE

我们将实现一个可以生成 MNIST 数字的 VAE。它将包含三个部分:

  • 一个将真实图像转换为潜在空间中的均值和方差的编码网络

  • 一个采样层,它接受这样的均值和方差,并使用它们从潜在空间中采样一个随机点

  • 一个解码器网络,它将潜在空间中的点转换回图像

以下列表显示了您将使用的编码器网络,它将图像映射到潜在空间上概率分布的参数。它是一个简单的卷积神经网络,将输入图像x映射到两个向量,z_meanz_log_var。一个重要的细节是我们使用步长进行特征图的下采样,而不是最大池化。我们上次这样做是在第十一章的图像分割示例中。回想一下,通常来说,对于任何关心信息位置(即图像中的哪里有东西)的模型,步长比最大池化更可取,因为这个模型确实需要产生一个可以用来重建有效图像的图像编码。

import keras
from keras import layers

# Dimensionality of the latent space: a 2D plane
latent_dim = 2

image_inputs = keras.Input(shape=(28, 28, 1))
x = layers.Conv2D(32, 3, activation="relu", strides=2, padding="same")(
    image_inputs
)
x = layers.Conv2D(64, 3, activation="relu", strides=2, padding="same")(x)
x = layers.Flatten()(x)
x = layers.Dense(16, activation="relu")(x)
# The input image ends up being encoded into these two parameters.
z_mean = layers.Dense(latent_dim, name="z_mean")(x)
z_log_var = layers.Dense(latent_dim, name="z_log_var")(x)
encoder = keras.Model(image_inputs, [z_mean, z_log_var], name="encoder") 

列表 17.1:VAE 编码器网络

它的总结看起来是这样的:

>>> encoder.summary()
Model: "encoder"
┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓
┃ Layer (type)          ┃ Output Shape      ┃     Param # ┃ Connected to       ┃
┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩
│ input_layer           │ (None, 28, 28, 1) │           0 │ -                  │
│ (InputLayer)          │                   │             │                    │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ conv2d (Conv2D)       │ (None, 14, 14,    │         320 │ input_layer[0][0]  │
│                       │ 32)               │             │                    │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ conv2d_1 (Conv2D)     │ (None, 7, 7, 64)  │      18,496 │ conv2d[0][0]       │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ flatten (Flatten)     │ (None, 3136)      │           0 │ conv2d_1[0][0]     │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ dense (Dense)         │ (None, 16)        │      50,192 │ flatten[0][0]      │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ z_mean (Dense)        │ (None, 2)         │          34 │ dense[0][0]        │
├───────────────────────┼───────────────────┼─────────────┼────────────────────┤
│ z_log_var (Dense)     │ (None, 2)         │          34 │ dense[0][0]        │
└───────────────────────┴───────────────────┴─────────────┴────────────────────┘
 Total params: 69,076 (269.83 KB)
 Trainable params: 69,076 (269.83 KB)
 Non-trainable params: 0 (0.00 B)

接下来是使用z_meanz_log_var,即假设产生input_img的统计分布的参数,来生成一个潜在空间点z的代码。

from keras import ops

class Sampler(keras.Layer):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        # We need a seed generator to use functions from keras.random
        # in call().
        self.seed_generator = keras.random.SeedGenerator()
        self.built = True

    def call(self, z_mean, z_log_var):
        batch_size = ops.shape(z_mean)[0]
        z_size = ops.shape(z_mean)[1]
        epsilon = keras.random.normal(
            # Draws a batch of random normal vectors
            (batch_size, z_size), seed=self.seed_generator
        )
        # Applies the VAE sampling formula
        return z_mean + ops.exp(0.5 * z_log_var) * epsilon 

列表 17.2:潜在空间采样层

以下列表展示了解码器的实现。我们将向量 z 调整为图像的维度,然后使用几个卷积层来获得最终图像输出,其维度与原始 input_img 相同。

# Input where we'll feed z
latent_inputs = keras.Input(shape=(latent_dim,))
# Produces the same number of coefficients we had at the level of the
# Flatten layer in the encoder
x = layers.Dense(7 * 7 * 64, activation="relu")(latent_inputs)
# Reverts the Flatten layer of the encoder
x = layers.Reshape((7, 7, 64))(x)
# Reverts the Conv2D layers of the encoder
x = layers.Conv2DTranspose(64, 3, activation="relu", strides=2, padding="same")(
    x
)
x = layers.Conv2DTranspose(32, 3, activation="relu", strides=2, padding="same")(
    x
)
# The output ends up with shape (28, 28, 1).
decoder_outputs = layers.Conv2D(1, 3, activation="sigmoid", padding="same")(x)
decoder = keras.Model(latent_inputs, decoder_outputs, name="decoder") 

列表 17.3:VAE 解码器网络,将潜在空间点映射到图像

其摘要如下:

>>> decoder.summary()
Model: "decoder"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                      ┃ Output Shape             ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ input_layer_1 (InputLayer)        │ (None, 2)                │             0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ dense_1 (Dense)                   │ (None, 3136)             │         9,408 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ reshape (Reshape)                 │ (None, 7, 7, 64)         │             0 │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_transpose                  │ (None, 14, 14, 64)       │        36,928 │
│ (Conv2DTranspose)                 │                          │               │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_transpose_1                │ (None, 28, 28, 32)       │        18,464 │
│ (Conv2DTranspose)                 │                          │               │
├───────────────────────────────────┼──────────────────────────┼───────────────┤
│ conv2d_2 (Conv2D)                 │ (None, 28, 28, 1)        │           289 │
└───────────────────────────────────┴──────────────────────────┴───────────────┘
 Total params: 65,089 (254.25 KB)
 Trainable params: 65,089 (254.25 KB)
 Non-trainable params: 0 (0.00 B)

现在,让我们创建 VAE 模型本身。这是您第一个不是进行监督学习的模型示例(自编码器是 自监督 学习的一个例子,因为它使用其输入作为目标)。每当您偏离经典监督学习时,通常都会子类化 Model 类并实现自定义的 train_step() 来指定新的训练逻辑,这是您在第七章中学到的。我们在这里可以轻松做到这一点,但这种方法的一个缺点是 train_step() 的内容必须是后端特定的——您会使用 TensorFlow 的 GradientTape,您会使用 PyTorch 的 loss.backward(),等等。自定义训练逻辑的一个更简单的方法是仅实现 compute_loss() 方法,并保留默认的 train_step()compute_loss() 是内置 train_step() 调用的关键可微分逻辑。由于它不涉及直接操作梯度,因此很容易保持其与后端无关。

其签名如下:

compute_loss(x, y, y_pred, sample_weight=None, training=True)

其中 x 是模型的输入;y 是模型的输出(在我们的案例中,它是 None,因为我们使用的数据集只有输入,没有目标);而 y_predcall() 的输出——即模型的预测。在任何监督训练流程中,您都会基于 yy_pred 计算损失。在我们的案例中,由于 yNoney_pred 包含潜在参数,我们将使用 x(原始输入)和从 y_pred 导出的 reconstruction 来计算损失。

该方法必须返回一个标量,即要最小化的损失值。您还可以使用 compute_loss() 来更新您的指标状态,这是我们在这个案例中想要做的。

现在,让我们用自定义的 compute_loss() 方法来编写我们的 VAE。它适用于所有后端,无需更改代码!

class VAE(keras.Model):
    def __init__(self, encoder, decoder, **kwargs):
        super().__init__(**kwargs)
        self.encoder = encoder
        self.decoder = decoder
        self.sampler = Sampler()
        # We'll use these metrics to keep track of the loss averages
        # over each epoch.
        self.reconstruction_loss_tracker = keras.metrics.Mean(
            name="reconstruction_loss"
        )
        self.kl_loss_tracker = keras.metrics.Mean(name="kl_loss")

    def call(self, inputs):
        return self.encoder(inputs)

    def compute_loss(self, x, y, y_pred, sample_weight=None, training=True):
        # Argument x is the model's input.
        original = x
        # Argument y_pred is the output of call().
        z_mean, z_log_var = y_pred
        # This is our reconstructed image.
        reconstruction = self.decoder(self.sampler(z_mean, z_log_var))

        # We sum the reconstruction loss over the spatial dimensions
        # (axes 1 and 2) and take its mean over the batch dimension.
        reconstruction_loss = ops.mean(
            ops.sum(
                keras.losses.binary_crossentropy(x, reconstruction), axis=(1, 2)
            )
        )
        # Adds the regularization term (Kullback–Leibler divergence)
        kl_loss = -0.5 * (
            1 + z_log_var - ops.square(z_mean) - ops.exp(z_log_var)
        )
        total_loss = reconstruction_loss + ops.mean(kl_loss)

        # Updates the state of our loss-tracking metrics
        self.reconstruction_loss_tracker.update_state(reconstruction_loss)
        self.kl_loss_tracker.update_state(kl_loss)
        return total_loss 

列表 17.4:具有自定义 compute_loss() 方法的 VAE 模型

最后,您已经准备好实例化模型并在 MNIST 数字上对其进行训练。因为 compute_loss() 已经处理了损失,所以在编译时不需要指定外部损失(loss=None),这反过来意味着您在训练期间不会传递目标数据(如您所见,在 fit 中您只向模型传递 x_train)。

import numpy as np

(x_train, _), (x_test, _) = keras.datasets.mnist.load_data()
# We train on all MNIST digits, so we concatenate the training and test
# samples.
mnist_digits = np.concatenate([x_train, x_test], axis=0)
mnist_digits = np.expand_dims(mnist_digits, -1).astype("float32") / 255

vae = VAE(encoder, decoder)
# We don't pass a loss argument in compile(), since the loss is already
# part of the train_step().
vae.compile(optimizer=keras.optimizers.Adam())
# We don't pass targets in fit(), since train_step() doesn't expect
# any.
vae.fit(mnist_digits, epochs=30, batch_size=128) 

列表 17.5:训练 VAE

一旦模型训练完成,您就可以使用 decoder 网络将任意的潜在空间向量转换为图像。

import matplotlib.pyplot as plt

# We'll display a grid of 30 × 30 digits (900 digits total).
n = 30
digit_size = 28
figure = np.zeros((digit_size * n, digit_size * n))

# Samples points linearly on a 2D grid
grid_x = np.linspace(-1, 1, n)
grid_y = np.linspace(-1, 1, n)[::-1]

# Iterates over grid locations
for i, yi in enumerate(grid_y):
    for j, xi in enumerate(grid_x):
        # For each location, samples a digit and adds it to our figure
        z_sample = np.array([[xi, yi]])
        x_decoded = vae.decoder.predict(z_sample)
        digit = x_decoded[0].reshape(digit_size, digit_size)
        figure[
            i * digit_size : (i + 1) * digit_size,
            j * digit_size : (j + 1) * digit_size,
        ] = digit

plt.figure(figsize=(15, 15))
start_range = digit_size // 2
end_range = n * digit_size + start_range
pixel_range = np.arange(start_range, end_range, digit_size)
sample_range_x = np.round(grid_x, 1)
sample_range_y = np.round(grid_y, 1)
plt.xticks(pixel_range, sample_range_x)
plt.yticks(pixel_range, sample_range_y)
plt.xlabel("z[0]")
plt.ylabel("z[1]")
plt.axis("off")
plt.imshow(figure, cmap="Greys_r") 

列表 17.6:从 2D 潜在空间中采样点网格并将它们解码为图像

样本数字的网格(见图 17.5)显示了不同数字类别的完全连续分布,当你沿着潜在空间中的路径移动时,一个数字会逐渐变成另一个数字。这个空间中的特定方向具有意义:例如,有一个“四”的方向,“一”的方向,等等。

图片

图 17.5:从潜在空间解码的数字网格

在下一节中,我们将详细介绍生成图像的另一项主要工具:扩散模型,这是今天几乎所有商业图像生成服务背后的架构。

扩散模型

自动编码器的一个长期应用是去噪:将包含少量噪声的输入(例如,低质量的 JPEG 图像)输入到模型中,并得到相同输入的清理版本。这是自动编码器擅长的一项任务。在 2010 年代后期,这个想法催生了非常成功的图像超分辨率模型,能够接受低分辨率、可能包含噪声的图像,并输出高质量、高分辨率的版本(见图 17.6)。这些模型在过去几年中已成为每个主要智能手机相机应用的一部分。

图片

图 17.6:图像超分辨率

当然,这些模型并不是像《银翼杀手》(1982 年)中的“增强”场景那样神奇地恢复输入中隐藏的丢失细节。相反,它们是在对图像应该看起来像什么做出有根据的猜测——它们正在幻觉出一个清理过的、更高分辨率的版本。这可能会导致一些有趣的意外。例如,使用一些 AI 增强相机,你可以拍摄一个看起来有点像月亮的东西(例如严重模糊的月亮图像的打印件),你会在你的相机胶卷中得到一张清晰的月亮陨石坑的图片。许多在打印件中根本不存在的细节被相机直接幻觉出来,因为使用的超分辨率模型过度拟合了月亮摄影图像。所以,绝对不要像 Rick Deckard 那样使用这项技术进行法医鉴定!

图像去噪的早期成功使研究人员产生了令人震惊的想法:既然你可以使用自动编码器从图像中去除少量噪声,那么重复这个过程多次,循环去除大量噪声当然也是可能的。最终,你能去除由纯噪声组成的图像的噪声吗?

事实上,你可以做到这一点。通过这样做,你可以有效地从无中生有地创造出全新的图像,就像图 17.7 所示的那样。这是扩散模型背后的关键洞察,这些模型更准确地应该被称为逆向扩散模型,因为“扩散”指的是逐渐向图像添加噪声直到其消散成无的过程。

图片

图 17.7:反向扩散:通过重复去噪将纯噪声转换为图像

扩散模型本质上是一个循环中的去噪自动编码器,能够将纯噪声转换为清晰、逼真的图像。你可能知道米开朗基罗的这句诗意名言:“每一块石头里都有一尊雕像,雕塑家的任务就是发现它”——同样,每一块白色噪声中都有一个图像,扩散模型的任务就是发现它。

现在,让我们用 Keras 构建一个模型。

牛津花卉数据集

我们将要使用的数据集是牛津花卉数据集(www.robots.ox.ac.uk/~vgg/data/flowers/102/),这是一个包含 102 个不同物种的 8,189 张花卉图片的集合。

让我们获取数据集存档并提取它:

import os

fpath = keras.utils.get_file(
    origin="https://www.robots.ox.ac.uk/~vgg/data/flowers/102/102flowers.tgz",
    extract=True,
) 

fpath现在是提取目录的本地路径。图像包含在该目录下的jpg子目录中。让我们使用image_dataset_from_directory()将它们转换为可迭代的数据集。

我们需要将图像调整到固定大小,但不想扭曲它们的纵横比,因为这会负面影响我们生成图像的质量,所以我们使用crop_to_aspect_ratio选项来提取最大尺寸且未扭曲的裁剪图像,尺寸为(128 × 128):

batch_size = 32
image_size = 128
images_dir = os.path.join(fpath, "jpg")
dataset = keras.utils.image_dataset_from_directory(
    images_dir,
    # We won't need the labels, just the images.
    labels=None,
    image_size=(image_size, image_size),
    # Crops images when resizing them to preserve their aspect ratio
    crop_to_aspect_ratio=True,
)
dataset = dataset.rebatch(
    # We'd like all batches to have the same size, so we drop the last
    # (irregular) batch.
    batch_size,
    drop_remainder=True,
) 

这里是一个示例图像(图 17.8):

from matplotlib import pyplot as plt

for batch in dataset:
    img = batch.numpy()[0]
    break
plt.imshow(img.astype("uint8")) 

图片

图 17.8:牛津花卉数据集的一个示例图像

一个 U-Net 去噪自动编码器

相同的去噪模型在扩散去噪过程的每次迭代中都会被重复使用,每次消除一点噪声。为了使模型的工作更容易,我们告诉它对于给定的输入图像应该提取多少噪声——这就是noise_rates输入。我们让模型输出一个预测的噪声掩码,我们可以从输入中减去它来实现去噪。

对于我们的去噪模型,我们将使用 U-Net——一种最初为图像分割而开发的卷积神经网络。它看起来像图 17.9。

图片

图 17.9:我们的 U-Net 风格的去噪自动编码器架构

这个架构有三个阶段:

  1. 一个下采样阶段,由几个卷积层块组成,其中输入从原始的 128 × 128 大小下采样到一个更小的尺寸(在我们的例子中,是 16 × 16)。

  2. 一个中间阶段,其中特征图具有恒定的尺寸。

  3. 一个上采样阶段,其中特征图被上采样回 128 × 128。

下采样阶段和上采样阶段的块之间存在 1:1 的映射:每个上采样块是下采样块的逆。重要的是,该模型具有从每个下采样块到相应上采样块的连接性,这些连接性有助于避免在连续的下采样和上采样操作中丢失图像细节信息。

让我们使用 Functional API 来组装模型:

# Utility function to apply a block of layers with a residual
# connection
def residual_block(x, width):
    input_width = x.shape[3]
    if input_width == width:
        residual = x
    else:
        residual = layers.Conv2D(width, 1)(x)
    x = layers.BatchNormalization(center=False, scale=False)(x)
    x = layers.Conv2D(width, 3, padding="same", activation="swish")(x)
    x = layers.Conv2D(width, 3, padding="same")(x)
    x = x + residual
    return x

def get_model(image_size, widths, block_depth):
    noisy_images = keras.Input(shape=(image_size, image_size, 3))
    noise_rates = keras.Input(shape=(1, 1, 1))

    x = layers.Conv2D(widths[0], 1)(noisy_images)
    n = layers.UpSampling2D(image_size, interpolation="nearest")(noise_rates)
    x = layers.Concatenate()([x, n])

    skips = []
    # Dowsampling stage
    for width in widths[:-1]:
        for _ in range(block_depth):
            x = residual_block(x, width)
            skips.append(x)
        x = layers.AveragePooling2D(pool_size=2)(x)

    # Middle stage
    for _ in range(block_depth):
        x = residual_block(x, widths[-1])

    # Upsampling stage
    for width in reversed(widths[:-1]):
        x = layers.UpSampling2D(size=2, interpolation="bilinear")(x)
        for _ in range(block_depth):
            x = layers.Concatenate()([x, skips.pop()])
            x = residual_block(x, width)

    # We set the kernel initializer for the last layer to "zeros,"
    # making the model predict only zeros after initialization (that
    # is, our default assumption before training is "no noise").
    pred_noise_masks = layers.Conv2D(3, 1, kernel_initializer="zeros")(x)

    # Creates the functional model
    return keras.Model([noisy_images, noise_rates], pred_noise_masks) 

你可以用类似 get_model(image_size=128, widths=[32, 64, 96, 128], block_depth=2) 的方式实例化模型。widths 参数是一个列表,包含每个连续下采样或上采样阶段的 Conv2D 层大小。我们通常希望随着输入的下采样(从 32 到 128 个单位)而层的大小增加,然后在上采样时减小(从 128 回到 32 个单位)。

扩散时间和扩散计划的概念

扩散过程是一系列步骤,其中我们将我们的去噪自动编码器应用于从图像中消除少量噪声,从纯噪声图像开始,以纯信号图像结束。循环中当前步骤的索引称为 扩散时间(见图 17.7)。在我们的情况下,我们将使用介于 1 和 0 之间的连续值作为此索引的值——1 表示过程的开始,此时噪声量最大,信号量最小,而 0 表示过程的结束,此时图像几乎全部是信号而没有噪声。

当前扩散时间与图像中存在的噪声和信号量之间的关系称为 扩散计划。在我们的实验中,我们将使用余弦计划来平滑地从扩散过程开始的高信号率(低噪声)过渡到结束时的低信号率(高噪声)。

def diffusion_schedule(
    diffusion_times,
    min_signal_rate=0.02,
    max_signal_rate=0.95,
):
    start_angle = ops.cast(ops.arccos(max_signal_rate), "float32")
    end_angle = ops.cast(ops.arccos(min_signal_rate), "float32")
    diffusion_angles = start_angle + diffusion_times * (end_angle - start_angle)
    signal_rates = ops.cos(diffusion_angles)
    noise_rates = ops.sin(diffusion_angles)
    return noise_rates, signal_rates 

列表 17.7:扩散计划

这个 diffusion_schedule() 函数接受一个 diffusion_times 张量作为输入,它表示扩散过程的进展,并返回相应的 noise_ratessignal_rates 张量。这些比率将被用来指导去噪过程。使用余弦计划的逻辑是保持关系 noise_rates ** 2 + signal_rates ** 2 == 1(见图 17.10)。

图片

图 17.10:噪声率和信号率之间的余弦关系

让我们绘制这个函数如何将扩散时间(介于 0 和 1 之间)映射到特定的噪声率和信号率(见图 17.11):

diffusion_times = ops.arange(0.0, 1.0, 0.01)
noise_rates, signal_rates = diffusion_schedule(diffusion_times)

# These lines are only necessary if you're using PyTorch, in which case
# tensor conversion to NumPy is no longer trivial.
diffusion_times = ops.convert_to_numpy(diffusion_times)
noise_rates = ops.convert_to_numpy(noise_rates)
signal_rates = ops.convert_to_numpy(signal_rates)

plt.plot(diffusion_times, noise_rates, label="Noise rate")
plt.plot(diffusion_times, signal_rates, label="Signal rate")

plt.xlabel("Diffusion time")
plt.legend() 

图片

图 17.11:我们的余弦扩散计划

训练过程

让我们创建一个 DiffusionModel 类来实现训练过程。它将包含我们的去噪自动编码器作为其属性之一。我们还需要一些其他的东西:

  • 损失函数 — 我们将使用平均绝对误差作为我们的损失,也就是说 mean(abs(real_noise_mask - predicted_noise_mask))

  • 图像归一化层 — 我们将添加到图像中的噪声将具有单位方差和零均值,因此我们希望我们的图像也以这种方式归一化,以便噪声的值范围与图像的值范围相匹配。

让我们先编写模型构造函数:

class DiffusionModel(keras.Model):
    def __init__(self, image_size, widths, block_depth, **kwargs):
        super().__init__(**kwargs)
        self.image_size = image_size
        self.denoising_model = get_model(image_size, widths, block_depth)
        self.seed_generator = keras.random.SeedGenerator()
        # Our loss function
        self.loss = keras.losses.MeanAbsoluteError()
        # We'll use this to normalize input images.
        self.normalizer = keras.layers.Normalization() 

我们首先需要的方法是去噪方法。它简单地调用去噪模型以检索一个预测的噪声掩码,并使用它来重建一个去噪图像:

 def denoise(self, noisy_images, noise_rates, signal_rates):
        # Calls the denoising model
        pred_noise_masks = self.denoising_model([noisy_images, noise_rates])
        # Reconstructs the predicted clean image
        pred_images = (
            noisy_images - noise_rates * pred_noise_masks
        ) / signal_rates
        return pred_images, pred_noise_masks 

接下来是训练逻辑。这是最重要的部分!就像在 VAE 示例中一样,我们将实现一个自定义的compute_loss()方法来保持我们的模型后端无关。当然,如果你坚持使用一个特定的后端,你也可以编写一个带有相同逻辑的自定义train_step(),以及后端特定的梯度计算和权重更新逻辑。

由于compute_loss()接收call()的输出作为输入,我们将去噪前向传递放在call()中。我们的call()接受一批干净的输入图像,并执行以下步骤:

  1. 对图像进行归一化

  2. 样本随机的扩散时间(去噪模型需要在扩散时间的全谱上训练)

  3. 计算相应的噪声率和信号率(使用扩散计划)

  4. 在干净的图像上添加随机噪声(基于计算出的噪声率和信号率)

  5. 对图像进行去噪

它返回

  • 预测的去噪图像

  • 预测的噪声掩码

  • 实际应用的噪声掩码

这两个量随后在compute_loss()中使用,以计算模型在噪声掩码预测任务上的损失:

 def call(self, images):
        images = self.normalizer(images)
        # Samples random noise masks
        noise_masks = keras.random.normal(
            (batch_size, self.image_size, self.image_size, 3),
            seed=self.seed_generator,
        )
        # Samples random diffusion times
        diffusion_times = keras.random.uniform(
            (batch_size, 1, 1, 1),
            minval=0.0,
            maxval=1.0,
            seed=self.seed_generator,
        )
        noise_rates, signal_rates = diffusion_schedule(diffusion_times)
        # Adds noise to the images
        noisy_images = signal_rates * images + noise_rates * noise_masks
        # Denoises them
        pred_images, pred_noise_masks = self.denoise(
            noisy_images, noise_rates, signal_rates
        )
        return pred_images, pred_noise_masks, noise_masks

    def compute_loss(self, x, y, y_pred, sample_weight=None, training=True):
        _, pred_noise_masks, noise_masks = y_pred
        return self.loss(noise_masks, pred_noise_masks) 

生成过程

最后,让我们实现图像生成过程。我们从纯随机噪声开始,并反复应用denoise()方法,直到我们得到高信号、低噪声的图像。

 def generate(self, num_images, diffusion_steps):
        noisy_images = keras.random.normal(
            # Starts from pure noise
            (num_images, self.image_size, self.image_size, 3),
            seed=self.seed_generator,
        )
        step_size = 1.0 / diffusion_steps
        for step in range(diffusion_steps):
            # Computes appropriate noise rates and signal rates
            diffusion_times = ops.ones((num_images, 1, 1, 1)) - step * step_size
            noise_rates, signal_rates = diffusion_schedule(diffusion_times)
            # Calls denoising model
            pred_images, pred_noises = self.denoise(
                noisy_images, noise_rates, signal_rates
            )
            # Prepares noisy images for the next iteration
            next_diffusion_times = diffusion_times - step_size
            next_noise_rates, next_signal_rates = diffusion_schedule(
                next_diffusion_times
            )
            noisy_images = (
                next_signal_rates * pred_images + next_noise_rates * pred_noises
            )
        # Denormalizes images so their values fit between 0 and 255
        images = (
            self.normalizer.mean + pred_images * self.normalizer.variance**0.5
        )
        return ops.clip(images, 0.0, 255.0) 

使用自定义回调可视化结果

我们没有合适的度量标准来评判我们生成的图像质量,所以你将需要在训练过程中自己可视化生成的图像来判断你的模型是否有所进展。一个简单的方法是使用自定义回调。以下回调在每一个 epoch 结束时使用generate()方法来显示一个 3 × 6 的生成图像网格:

class VisualizationCallback(keras.callbacks.Callback):
    def __init__(self, diffusion_steps=20, num_rows=3, num_cols=6):
        self.diffusion_steps = diffusion_steps
        self.num_rows = num_rows
        self.num_cols = num_cols

    def on_epoch_end(self, epoch=None, logs=None):
        generated_images = self.model.generate(
            num_images=self.num_rows * self.num_cols,
            diffusion_steps=self.diffusion_steps,
        )

        plt.figure(figsize=(self.num_cols * 2.0, self.num_rows * 2.0))
        for row in range(self.num_rows):
            for col in range(self.num_cols):
                i = row * self.num_cols + col
                plt.subplot(self.num_rows, self.num_cols, i + 1)
                img = ops.convert_to_numpy(generated_images[i]).astype("uint8")
                plt.imshow(img)
                plt.axis("off")
        plt.tight_layout()
        plt.show()
        plt.close() 

是时候开始了!

终于到了在牛津花卉数据集上训练我们的扩散模型的时候了。让我们实例化模型:

model = DiffusionModel(image_size, widths=[32, 64, 96, 128], block_depth=2)
# Computes the mean and variance necessary to perform normalization —
# don't forget it!
model.normalizer.adapt(dataset) 

我们将使用AdamW作为我们的优化器,并启用一些实用的选项来帮助稳定训练并提高生成图像的质量:

  • 学习率衰减 — 我们在训练过程中通过InverseTimeDecay计划逐渐降低学习率。

  • 模型权重的指数移动平均——也称为 Polyak 平均。这种技术在整个训练过程中维护模型权重的运行平均值。每 100 个批次,我们用这个平均权重量覆模型的权重。这有助于在损失景观嘈杂的情况下稳定模型的表示。

代码如下

model.compile(
    optimizer=keras.optimizers.AdamW(
        # Configures the learning rate decay schedule
        learning_rate=keras.optimizers.schedules.InverseTimeDecay(
            initial_learning_rate=1e-3,
            decay_steps=1000,
            decay_rate=0.1,
        ),
        # Turns on Polyak averaging
        use_ema=True,
        # Configures how often to overwrite the model's weights with
        # their exponential moving average
        ema_overwrite_frequency=100,
    ),
) 

让我们拟合模型。我们将使用VisualizationCallback回调在每个 epoch 后绘制生成图像的示例,并使用ModelCheckpoint回调保存模型的权重:

model.fit(
    dataset,
    epochs=100,
    callbacks=[
        VisualizationCallback(),
        keras.callbacks.ModelCheckpoint(
            filepath="diffusion_model.weights.h5",
            save_weights_only=True,
            save_best_only=True,
        ),
    ],
) 

如果你正在 Colab 上运行,你可能会遇到错误:“缓冲数据在达到输出大小限制后截断。” 这是因为fit()的日志包括图像,它们占用了大量空间,而单个笔记本单元允许的输出是有限的。为了解决这个问题,你可以在五个连续的单元中简单地链式调用五个model.fit(..., epochs=20),这相当于一个单独的fit(..., epochs=100)调用。

经过 100 个 epoch(在 T4 上大约需要 90 分钟,免费的 Colab GPU),我们得到了像这样的相当生成性的花朵(见图 17.12)。

图 17.12:生成的花朵示例

你可以继续训练更长的时间,并得到越来越逼真的结果。

所以这就是扩散图像生成的工作原理!现在,下一步是解锁它们的潜力,添加文本条件,这将导致一个文本到图像模型,能够生成与给定文本标题匹配的图像。

文本到图像模型

我们可以使用相同的基本扩散过程来创建一个将文本输入映射到图像输出的模型。为此,我们需要一个预训练的文本编码器(例如第十五章中的 RoBERTa 这样的 transformer 编码器),它可以映射文本到连续嵌入空间中的向量。然后我们可以在(prompt, image)对上训练扩散模型,其中每个提示是输入图像的简短文本描述。

我们可以以前同样的方式处理图像输入,将噪声输入映射到去噪输出,逐渐接近我们的输入图像。关键的是,我们可以通过也将嵌入的文本提示传递给去噪模型来扩展这个设置。因此,我们的去噪模型不仅接受noisy_images输入,还将接受两个输入:noisy_imagestext_embeddings。这使我们训练的先前花朵去噪器有了优势。模型不是在没有额外信息的情况下学习从图像中去除噪声,而是可以使用最终图像的文本表示来帮助指导去噪过程。

训练完成后,事情会变得更有趣。因为我们已经训练了一个可以将纯噪声映射到某些文本的向量表示的图像的条件上的模型,现在我们可以输入纯噪声和从未见过的提示,并将其去噪成图像。

让我们试试这个。在这本书中,我们实际上不会从头开始训练这样的模型——你已经有了所有需要的成分,但训练一个效果良好的文本到图像扩散模型既昂贵又耗时。相反,我们将使用 KerasHub 中一个流行的预训练模型 Stable Diffusion(图 17.13)。Stable Diffusion 是由一家名为 Stability AI 的公司制作的,该公司专门制作图像和视频生成的开源模型。我们只需几行代码就可以在 KerasHub 中使用他们图像生成模型的第三个版本:

import keras_hub

height, width = 512, 512
task = keras_hub.models.TextToImage.from_preset(
    "stable_diffusion_3_medium",
    image_shape=(height, width, 3),
    # A trick to keep memory usage down. More details in chapter 18.
    dtype="float16",
)
prompt = "A NASA astraunaut riding an origami elephant in New York City"
task.generate(prompt) 

列表 17.8:创建一个稳定扩散文本到图像模型

图 17.13:我们的稳定扩散模型的一个示例输出

与我们在上一章中介绍的 CausalLM 任务类似,TextToImage 任务是一个高级类,用于根据文本输入进行图像生成。它将标记化和扩散过程封装成一个高级的生成调用。

稳定扩散模型实际上为其模型添加了一个第二个“负提示”,可以用来将扩散过程引导远离某些文本输入。这里没有什么魔法。要添加一个负提示,你可以简单地训练一个三元组模型:(image, positive_prompt, negative_prompt),其中正提示是对图像的描述,而负提示是一系列不描述图像的词语。通过将正负文本嵌入输入到去噪器中,去噪器将学习引导噪声向与正提示匹配的图像,并远离与负提示匹配的图像(图 17.14)。让我们尝试从我们的输入中移除颜色蓝色:

task.generate(
    {
        "prompts": prompt,
        "negative_prompts": "blue color",
    }
) 

图 17.14:使用负提示将模型引导远离蓝色

与我们在上一章中使用的文本模型的 generate() 方法类似,我们有一些额外的参数可以传递给模型以控制生成过程。让我们尝试向我们的模型传递一个可变的扩散步骤数,以查看去噪过程(图 17.15)的实际操作:

import numpy as np
from PIL import Image

def display(images):
    return Image.fromarray(np.concatenate(images, axis=1))

display([task.generate(prompt, num_steps=x) for x in [5, 10, 15, 20, 25]]) 

图 17.15:控制扩散步骤的数量

探索文本到图像模型的潜在空间

没有比文本扩散模型更好地看到深度神经网络插值性质的方法了。我们模型使用的文本编码器将学习一个平滑、低维流形来表示我们的输入提示。它是连续的,这意味着我们学习了一个空间,我们可以从一个提示的文本表示走到另一个提示的文本表示,每个中间点都将具有语义意义。我们可以将这一点与我们的扩散过程相结合,通过简单地用文本提示描述每个端状态来在两个图像之间进行形态变化。

在我们能够做到这一点之前,我们需要将我们的高级generate()函数分解成其组成部分。让我们试试看。

from keras import random

def get_text_embeddings(prompt):
    token_ids = task.preprocessor.generate_preprocess([prompt])
    # We don't care about negative prompts here, but the model expects
    # them.
    negative_token_ids = task.preprocessor.generate_preprocess([""])
    return task.backbone.encode_text_step(token_ids, negative_token_ids)

def denoise_with_text_embeddings(embeddings, num_steps=28, guidance_scale=7.0):
    # Creates pure noise to denoise into an image
    latents = random.normal((1, height // 8, width // 8, 16))
    for step in range(num_steps):
        latents = task.backbone.denoise_step(
            latents,
            embeddings,
            step,
            num_steps,
            guidance_scale,
        )
    return task.backbone.decode_step(latents)[0]

# Rescales our images back to [0, 255]
def scale_output(x):
    x = ops.convert_to_numpy(x)
    x = np.clip((x + 1.0) / 2.0, 0.0, 1.0)
    return np.round(x * 255.0).astype("uint8")

embeddings = get_text_embeddings(prompt)
image = denoise_with_text_embeddings(embeddings)
scale_output(image) 

列表 17.9:分解generate()函数

我们生成过程有三个不同的步骤:

  1. 首先,我们获取提示,对它们进行分词,并使用我们的文本编码器将它们嵌入。

  2. 第二步,我们获取文本嵌入和纯噪声,并逐步将噪声“去噪”成图像。这与我们刚刚构建的花朵模型相同。

  3. 最后,我们将模型输出从[-1, 1]映射回[0, 255],这样我们就可以渲染图像。

这里有一点需要注意,我们的文本嵌入实际上包含四个不同的张量:

>>> [x.shape for x in embeddings]
[(1, 154, 4096), (1, 154, 4096), (1, 2048), (1, 2048)]

与仅将最终的嵌入文本向量传递给去噪模型不同,Stable Diffusion 的作者选择传递最终的输出向量和文本编码器学习到的整个标记序列的最后表示。这实际上为我们去噪模型提供了更多的工作信息。作者对正向和负向提示都这样做,所以我们这里总共有四个张量:

  • 正向提示的编码序列

  • 负向提示的编码序列

  • 正向提示的编码向量

  • 负向提示的编码向量

将我们的generate()函数分解后,我们现在可以尝试在两个文本提示之间的潜在空间中行走。为了做到这一点,让我们构建一个在模型输出的文本嵌入之间进行插值的函数。

from keras import ops

def slerp(t, v1, v2):
    v1, v2 = ops.cast(v1, "float32"), ops.cast(v2, "float32")
    v1_norm = ops.linalg.norm(ops.ravel(v1))
    v2_norm = ops.linalg.norm(ops.ravel(v2))
    dot = ops.sum(v1 * v2 / (v1_norm * v2_norm))
    theta_0 = ops.arccos(dot)
    sin_theta_0 = ops.sin(theta_0)
    theta_t = theta_0 * t
    sin_theta_t = ops.sin(theta_t)
    s0 = ops.sin(theta_0 - theta_t) / sin_theta_0
    s1 = sin_theta_t / sin_theta_0
    return s0 * v1 + s1 * v2

def interpolate_text_embeddings(e1, e2, start=0, stop=1, num=10):
    embeddings = []
    for t in np.linspace(start, stop, num):
        embeddings.append(
            (
                # The second and fourth text embeddings are for the
                # negative prompt, which we do not use.
                slerp(t, e1[0], e2[0]),
                e1[1],
                slerp(t, e1[2], e2[2]),
                e1[3],
            )
        )
    return embeddings 

列表 17.10:一个用于插值文本嵌入的函数

你会注意到我们使用了一个特殊的插值函数,称为slerp,在文本嵌入之间行走。这代表球面线性插值——这是一个在计算机图形学中使用了数十年的函数,用于在球体上插值点。

不要过于担心数学问题;对于我们这个例子来说,它并不重要,但理解动机是很重要的。如果我们想象我们的文本流形是一个球体,我们的两个提示是这个球体上的随机点,那么直接在这两点之间进行线性插值将会让我们落在球体内部。我们不再处于其表面。我们希望保持在由我们的文本嵌入学习到的光滑流形的表面上——这就是嵌入点对我们去噪模型有意义的所在。见图 17.16。

图片

图 17.16:球面插值使我们接近流形的表面。

当然,我们文本嵌入模型学习到的流形实际上并不是球形的。但它是一个具有相同粗糙大小的数字的平滑表面——它是球形的,如果我们假设我们处于一个球体上,那么通过球面插值比如果我们假设我们处于一条线上进行插值要更好。

定义了我们的插值后,让我们尝试在两个提示之间的文本嵌入之间行走,并在每个插值输出处生成一个图像。我们将从 0.5 运行到 0.6(在 0 到 1 之间),以便在“形态”在视觉上变得明显时放大插值的中间部分(图 17.17):

prompt1 = "A friendly dog looking up in a field of flowers"
prompt2 = "A horrifying, tentacled creature hovering over a field of flowers"
e1 = get_text_embeddings(prompt1)
e2 = get_text_embeddings(prompt2)

images = []
# Zooms in to the middle of the overall interpolation from [0, 1]
for et in interpolate_text_embeddings(e1, e2, start=0.5, stop=0.6, num=9):
    image = denoise_with_text_embeddings(et)
    images.append(scale_output(image))
display(images) 

图 17.17:在两个提示之间进行插值并生成输出

第一次尝试时,这可能会感觉像魔法,但其中并没有什么魔法——插值是深度神经网络学习方式的基础。这将是我们在书中最后要讨论的实质性模型,它是一个很好的视觉隐喻来结束。深度神经网络是插值机器;它们将复杂、现实世界的概率分布映射到低维流形上。我们可以利用这一事实,即使对于像人类语言这样复杂的输入和像自然图像这样复杂的输出也是如此。

摘要

  • 使用深度学习进行图像生成是通过学习捕获图像数据集统计信息的潜在空间来完成的。通过从潜在空间中采样和解码点,你可以生成从未见过的图像。为此有三个主要工具:VAEs、扩散模型和 GANs。

  • VAEs 导致高度结构化的、连续的潜在表示。因此,它们非常适合在潜在空间中进行各种图像编辑:人脸交换、将皱眉的脸变成微笑的脸等等。它们也适合进行基于潜在空间的动画,例如在潜在空间的横截面上进行行走动画,显示起始图像以连续的方式缓慢地变成不同的图像。

  • 扩散模型产生非常逼真的输出,并且是目前图像生成的主要方法。它们通过从纯噪声开始反复去噪图像来工作。它们可以很容易地根据文本标题进行条件化,以创建文本到图像模型。

  • Stable Diffusion 3 是一个最先进的预训练文本到图像模型,你可以用它来创建你自己的高度逼真的图像。

  • 这种文本到图像扩散模型学习的视觉潜在空间本质上是插值的。你可以通过在用于扩散过程的文本嵌入之间进行插值,并实现输出图像之间的平滑插值来看到这一点。

脚注

  1. Diederik P. Kingma 和 Max Welling, “Auto-Encoding Variational Bayes,” arXiv (2013), arxiv.org/abs/1312.6114. [↩]

  2. Danilo Jimenez Rezende, Shakir Mohamed, 和 Daan Wierstra, “Stochastic Backpropagation and Approximate Inference in Deep Generative Models,” arXiv (2014), arxiv.org/abs/1401.4082. [↩]

第十八章:现实世界的最佳实践

deeplearningwithpython.io/chapters/chapter18_best-practices-for-the-real-world

自从这本书的开头以来,你已经取得了很大的进步。你现在可以训练图像分类模型、图像分割模型、对向量数据进行分类或回归的模型、时间序列预测模型、文本分类模型、序列到序列模型,甚至是文本和图像的生成模型。你已经涵盖了所有的基础。

然而,你迄今为止的所有模型都是在小规模上训练的——在小型数据集上,使用单个 GPU——并且它们通常没有达到我们所查看的每个数据集上所能达到的最佳性能。毕竟,这本书是一本入门书。如果你要进入现实世界并在全新的问题上取得最先进的结果,你仍然需要跨越一段不小的鸿沟。

本章旨在弥合这一差距,并为你从机器学习学生成长为一名合格的机器学习工程师提供最佳实践。我们将回顾系统性地提高模型性能的必要技术:超参数调整和模型集成。然后,我们将探讨如何通过多 GPU 和 TPU 训练、混合精度和量化来加速和扩展模型训练。

充分发挥你的模型潜力

如果你只需要一个过得去的东西,那么盲目尝试不同的架构配置已经足够好了。在本节中,我们将通过一套必须掌握的技术的快速指南,从“过得去”提升到“表现卓越并赢得机器学习竞赛”。

超参数优化

当构建一个深度学习模型时,你必须做出许多看似随意的决定:你应该堆叠多少层?每一层应该有多少个单元或过滤器?你应该使用relu作为激活函数,还是使用不同的函数?你应该在给定的层之后使用BatchNormalization吗?你应该使用多少 dropout?等等。这些架构级别的参数被称为超参数,以区分它们与通过反向传播训练的模型的参数

在实践中,经验丰富的机器学习工程师和研究人员随着时间的推移对什么有效,什么无效有了直觉——他们发展了超参数调整技能。但没有正式的规则。如果你想达到给定任务可以实现的极限,你不能满足于这样的任意选择。你的初始决策几乎总是次优的,即使你非常有直觉。你可以通过手动调整并重复训练模型来细化你的选择——这就是机器学习工程师和研究人员大部分时间在做的事情。但整天调整超参数不应是你的工作——这最好留给机器。

因此,你需要以原则性的方式自动和系统地探索可能的决策空间。你需要搜索架构空间,并经验性地找到表现最佳的模型。这正是自动超参数优化领域的主题:这是一个完整的研究领域,也是一个重要的领域。

优化超参数的过程通常如下所示:

  1. 选择一组超参数(自动)。

  2. 构建相应的模型。

  3. 将其拟合到你的训练数据,并在验证数据上衡量性能。

  4. 选择下一组要尝试的超参数(自动)。

  5. 重复。

  6. 最终,在你的测试数据上衡量性能。

这个过程的关键是分析验证性能与各种超参数值之间关系的算法,以选择下一组要评估的超参数。可能有许多不同的技术:贝叶斯优化、遗传算法、简单的随机搜索等等。

训练模型的权重相对容易:你在一个数据的小批量上计算损失函数,然后使用反向传播将权重移动到正确的方向。另一方面,更新超参数则提出了独特的挑战。考虑以下情况:

  • 超参数空间通常由离散决策组成,因此不是连续的或可微分的。因此,你通常不能在超参数空间中进行梯度下降。相反,你必须依赖无梯度优化技术,这些技术自然比梯度下降效率低得多。

  • 计算这个优化过程的反馈信号(这组超参数是否会导致在这个任务上表现良好的模型?)可能非常昂贵:它需要从头开始创建和训练一个新的模型。

  • 反馈信号可能存在噪声:如果一次训练运行提高了 0.2%,这是否是因为更好的模型配置,还是因为你初始权重值运气好?

幸运的是,有一个工具可以使超参数调整更简单:KerasTuner。让我们来看看它。

使用 KerasTuner

让我们从安装 KerasTuner 开始:

!pip install keras-tuner -q 

KerasTuner 建立在其核心思想之上,即让你用一系列可能的选择,如 Int(name="units", min_value=16, max_value=64, step=16),来替换硬编码的超参数值,如 units=32。在给定模型中,这样的选择集合被称为超参数调优过程的 搜索空间

要指定搜索空间,定义一个模型构建函数(参见下一列表)。它接受一个 hp 参数,你可以从中采样超参数范围,并返回一个编译好的 Keras 模型。

import keras
from keras import layers

def build_model(hp):
    # Sample hyperparameter values from the hp object. After sampling,
    # these values (such as the "units" variable here) are just regular
    # Python constants.
    units = hp.Int(name="units", min_value=16, max_value=64, step=16)
    model = keras.Sequential(
        [
            layers.Dense(units, activation="relu"),
            layers.Dense(10, activation="softmax"),
        ]
    )
    # Different kinds of hyperparameters are available: Int, Float,
    # Boolean, Choice.
    optimizer = hp.Choice(name="optimizer", values=["rmsprop", "adam"])
    model.compile(
        optimizer=optimizer,
        loss="sparse_categorical_crossentropy",
        metrics=["accuracy"],
    )
    # The function returns a compiled model.
    return model 

列表 18.1:一个 KerasTuner 模型构建函数

如果你想要采用更模块化和可配置的方法来构建模型,你也可以从 HyperModel 类中派生出一个子类,并定义一个 build 方法。

import keras_tuner as kt

class SimpleMLP(kt.HyperModel):
    # Thanks to the object-oriented approach, we can configure model
    # constants as constructor arguments (instead of hardcoding them in
    # the model-building function).
    def __init__(self, num_classes):
        self.num_classes = num_classes

    # The build method is identical to our prior build_model standalone
    # function.
    def build(self, hp):
        units = hp.Int(name="units", min_value=16, max_value=64, step=16)
        model = keras.Sequential(
            [
                layers.Dense(units, activation="relu"),
                layers.Dense(self.num_classes, activation="softmax"),
            ]
        )
        optimizer = hp.Choice(name="optimizer", values=["rmsprop", "adam"])
        model.compile(
            optimizer=optimizer,
            loss="sparse_categorical_crossentropy",
            metrics=["accuracy"],
        )
        return model

hypermodel = SimpleMLP(num_classes=10) 

列表 18.2:一个 KerasTuner HyperModel

下一步是定义一个“调谐器”。从概念上讲,你可以将调谐器视为一个 for 循环,它将反复

  • 选择一组超参数值

  • 使用这些值调用模型构建函数以创建一个模型

  • 训练模型并记录其指标

KerasTuner 有几个内置的调谐器可用——RandomSearchBayesianOptimizationHyperband。让我们尝试 BayesianOptimization,这是一个尝试根据先前选择的成果来智能预测哪些新的超参数值可能表现最佳的调谐器:

tuner = kt.BayesianOptimization(
    # Specifies the model-building function (or hypermodel instance)
    build_model,
    # Specifies the metric that the tuner will seek to optimize. Always
    # specify validation metrics, since the goal of the search process
    # is to find models that generalize!
    objective="val_accuracy",
    # Maximum number of different model configurations ("trials") to
    # try before ending the search
    max_trials=20,
    # To reduce metrics variance, you can train the same model multiple
    # times and average the results. executions_per_trial is how many
    # training rounds (executions) to run for each model configuration
    # (trial).
    executions_per_trial=2,
    # Where to store search logs
    directory="mnist_kt_test",
    # Whether to overwrite data in the directory to start a new search.
    # Set this to True if you've modified the model-building function
    # or to False to resume a previously started search with the same
    # model-building function.
    overwrite=True,
) 

你可以通过 search_space_summary() 显示搜索空间的概述:

>>> tuner.search_space_summary()
Search space summary
Default search space size: 2
units (Int)
{"default": None,
 "conditions": [],
 "min_value": 128,
 "max_value": 1024,
 "step": 128,
 "sampling": None}
optimizer (Choice)
{"default": "rmsprop",
 "conditions": [],
 "values": ["rmsprop", "adam"],
 "ordered": False}

最后,让我们启动搜索。别忘了传递验证数据,并确保不要使用测试集作为验证数据——否则,你很快就会开始对测试数据过拟合,而且你将无法再信任你的测试指标:

(x_train, y_train), (x_test, y_test) = keras.datasets.mnist.load_data()
x_train = x_train.reshape((-1, 28 * 28)).astype("float32") / 255
x_test = x_test.reshape((-1, 28 * 28)).astype("float32") / 255
# Reserves these for later
x_train_full = x_train[:]
y_train_full = y_train[:]
# Sets aside a validation set
num_val_samples = 10000
x_train, x_val = x_train[:-num_val_samples], x_train[-num_val_samples:]
y_train, y_val = y_train[:-num_val_samples], y_train[-num_val_samples:]
callbacks = [
    # Uses a large number of epochs (you don't know in advance how many
    # epochs each model will need) and uses an EarlyStopping callback
    # to stop training when you start overfitting
    keras.callbacks.EarlyStopping(monitor="val_loss", patience=5),
]
# This takes the same arguments as fit() (it simply passes them down to
# fit() for each new model).
tuner.search(
    x_train,
    y_train,
    batch_size=128,
    epochs=100,
    validation_data=(x_val, y_val),
    callbacks=callbacks,
    verbose=2,
) 

由于我们只查看几个可能的选择,并且在 MNIST 上进行训练,所以前面的例子只需几分钟就能运行。然而,在典型的搜索空间和数据集上,你通常会发现自己让超参数搜索运行一整夜,甚至几天。如果你的搜索过程崩溃,你总是可以重新启动它——只需在调谐器中指定 overwrite=False,这样它就可以从存储在磁盘上的试验日志中恢复。

一旦搜索完成,你可以查询最佳超参数配置,这些配置可以用来创建高性能模型,然后你可以重新训练这些模型。

top_n = 4
# Returns a list of HyperParameters objects, which you can pass to the
# model-building function
best_hps = tuner.get_best_hyperparameters(top_n) 

列表 18.3:查询最佳超参数配置

通常,当你重新训练这些模型时,你可能希望将验证数据作为训练数据的一部分,因为你不会进行任何进一步的超参数更改,因此你将不再在验证数据上评估性能。在我们的例子中,我们将使用原始 MNIST 训练数据的全部来训练这些最终模型,而不保留验证集。

在我们能够对全部训练数据进行训练之前,尽管如此,我们还需要解决最后一个参数:训练的最佳轮数。通常,你希望对新模型进行比搜索期间更长时间的训练:在EarlyStopping回调中使用激进的patience值可以在搜索过程中节省时间,但可能会导致模型欠拟合。只需使用验证集来找到最佳的轮数:

def get_best_epoch(hp):
    model = build_model(hp)
    callbacks = [
        keras.callbacks.EarlyStopping(
            # Note the very high patience value.
            monitor="val_loss", mode="min", patience=10
        )
    ]
    history = model.fit(
        x_train,
        y_train,
        validation_data=(x_val, y_val),
        epochs=100,
        batch_size=128,
        callbacks=callbacks,
    )
    val_loss_per_epoch = history.history["val_loss"]
    best_epoch = val_loss_per_epoch.index(min(val_loss_per_epoch)) + 1
    print(f"Best epoch: {best_epoch}")
    return best_epoch 

最后,由于你正在训练更多的数据——在这个例子中是 20%更多的数据——所以你需要在这个轮数的基础上再训练一段时间:

def get_best_trained_model(hp):
    best_epoch = get_best_epoch(hp)
    model = build_model(hp)
    model.fit(
        x_train_full, y_train_full, batch_size=128, epochs=int(best_epoch * 1.2)
    )
    return model

best_models = []
for hp in best_hps:
    model = get_best_trained_model(hp)
    model.evaluate(x_test, y_test)
    best_models.append(model) 

如果你不太担心稍微低一点的性能,你可以采取一个捷径:只需使用调整器重新加载在超参数搜索期间保存的最佳权重下的顶级性能模型,而无需从头开始重新训练新模型:

best_models = tuner.get_best_models(top_n) 

构建正确搜索空间的艺术

总体而言,超参数优化是一种强大的技术,对于在任何任务上达到最先进的模型或赢得机器学习竞赛来说是绝对必要的。想想看:曾经,人们手工制作了浅层机器学习模型中的特征。那是非常低效的。现在深度学习自动化了层次特征工程的任务——特征是通过反馈信号学习的,而不是手工调整的,这就是应该的方式。同样,你不应该手工制作你的模型架构;你应该以原则性的方式优化它们。

然而,进行超参数调整并不能取代熟悉模型架构的最佳实践:随着选择数量的增加,搜索空间呈组合式增长,因此将所有内容都变成超参数并让调整器处理它将非常昂贵。你需要聪明地设计合适的搜索空间。超参数调整是自动化,而不是魔法:你使用它来自动化你本应手动运行的实验,但你仍然需要手动选择有潜力产生良好指标的实验配置。

好消息是:通过使用超参数调整,你必须做出的配置决策从微观决策(我该为这一层选择多少个单元?)升级到更高层次的架构决策(我是否应该在整个模型中使用残差连接?)。虽然微观决策特定于某个模型和某个数据集,但更高层次的决策在不同任务和数据集上具有更好的泛化能力:例如,几乎每个图像分类问题都可以通过相同类型的搜索空间模板来解决。

按照这个逻辑,KerasTuner 试图提供与广泛问题类别相关的预先准备好的搜索空间——例如图像分类。只需添加数据,运行搜索,就能得到一个相当不错的模型。你可以尝试超模型kt.applications.HyperXceptionkt.applications.HyperResNet,它们实际上是 Keras Applications 模型的可调整版本。

模型集成

在一个任务上获得最佳可能结果的一种强大技术是模型集成。集成包括将一组不同模型的预测汇总起来以产生更好的预测。如果你查看机器学习竞赛——特别是在 Kaggle 上——你会发现赢家使用非常大的模型集成,这不可避免地会击败任何单个模型,无论其多么优秀。

集成依赖于这样的假设:独立训练的不同表现良好的模型可能因不同的原因而表现良好:每个模型查看数据的不同方面来做出预测,获得部分“真相”但不是全部。你可能熟悉古老的寓言《盲人摸象》:一群盲人第一次遇到大象,试图通过触摸它来理解大象是什么。每个盲人都触摸到大象身体的不同部分——只触摸一部分,比如鼻子或腿。然后盲人们向彼此描述大象是什么:“它像一条蛇”,“像一根柱子或一棵树”,等等。盲人们本质上是在尝试从自己的视角、使用自己的假设(由模型的独特架构和独特的随机权重初始化提供)来理解训练数据的机器学习模型。每个模型都获得了数据的一部分真相,但不是全部真相。通过汇总他们的视角,你可以得到对数据的更准确描述。大象是各个部分的组合:没有哪个盲人能完全正确地描述它,但当他们一起接受采访时,他们可以讲述一个相当准确的故事。

让我们以分类为例。将一组分类器的预测(即集成分类器)进行汇总的最简单方法是在推理时平均它们的预测:

# Uses four different models to compute initial predictions
preds_a = model_a.predict(x_val)
preds_b = model_b.predict(x_val)
preds_c = model_c.predict(x_val)
preds_d = model_d.predict(x_val)
# This new prediction array should be more accurate than any of the
# initial ones.
final_preds = 0.25 * (preds_a + preds_b + preds_c + preds_d) 

然而,这只有在分类器大致相同的情况下才会有效。如果其中之一明显比其他分类器差得多,最终的预测可能不会像该组中最好的分类器那样好。

一种更智能的集成分类器的方式是进行加权平均,其中权重是在验证数据上学习的——通常,表现更好的分类器会得到更高的权重,而表现较差的分类器会得到较低的权重。为了寻找一组好的集成权重,你可以使用随机搜索或简单的优化算法,例如 Nelder-Mead 算法:

preds_a = model_a.predict(x_val)
preds_b = model_b.predict(x_val)
preds_c = model_c.predict(x_val)
preds_d = model_d.predict(x_val)
# These weights (0.5, 0.25, 0.1, 0.15) are assumed to be learned
# empirically.
final_preds = 0.5 * preds_a + 0.25 * preds_b + 0.1 * preds_c + 0.15 * preds_d 

有许多可能的变体:例如,你可以对预测的指数进行平均。一般来说,在验证数据上优化的简单加权平均提供了一个非常强大的基线。

使集成有效果的关键是分类器集的多样性。多样性是力量。如果所有盲人只触摸大象的鼻子,他们会同意大象像蛇一样,他们将永远不知道大象的真实情况。多样性是使集成工作起来的因素。在机器学习的术语中,如果你的所有模型都以相同的方式有偏差,那么你的集成将保留这种相同的偏差。如果你的模型以不同的方式有偏差,偏差将相互抵消,集成将更加稳健和准确。

因此,你应该集成尽可能好且尽可能不同的模型。这通常意味着使用非常不同的架构,甚至使用不同的机器学习方法的品牌。一件在很大程度上不值得做的事情是集成多次独立训练的相同网络,从不同的随机初始化开始。如果你的模型之间唯一的区别是它们的随机初始化以及它们接触训练数据的顺序,那么你的集成将缺乏多样性,并且只会对任何单个模型提供微小的改进。

我在实践中发现了一件事效果很好——但这并不适用于每个问题领域——那就是使用基于树的多种方法的集成(例如随机森林或梯度提升树)和深度神经网络。2014 年,我和安德烈·科列夫在 Kaggle(www.kaggle.com/c/higgs-boson)的希格斯玻色子衰变检测挑战中获得了第四名,我们使用了各种基于树的模型和深度神经网络的集成。令人惊讶的是,集成中的一个模型来自与其他不同的方法(它是一个正则化贪婪森林),并且比其他模型得分显著更低。不出所料,它在集成中被分配了很小的权重。但令我们惊讶的是,它最终通过其与其他模型如此不同而大幅提高了整体集成的性能:它提供了其他模型无法获得的信息。这正是集成的目的。这不仅仅关乎你的最佳模型有多好;这关乎你候选模型集的多样性。

使用多设备扩展模型训练

回想一下我们在第七章中介绍的“进步循环”概念:你想法的质量是它们经过多少次精炼循环的函数(见图 18.1)。而你对一个想法进行迭代的速度取决于你设置实验的速度、运行实验的速度,以及最终分析结果数据的能力。

图 18.1:进步循环

随着你对 Keras API 的专长发展,你编写深度学习实验的速度将不再是这个进步周期的瓶颈。下一个瓶颈将变成你训练模型的速度。快速训练基础设施意味着你可以在 10 或 15 分钟内得到结果,因此你可以每天进行数十次迭代。更快的训练直接提高了你的深度学习解决方案的质量

在本节中,你将了解如何通过使用多个 GPU 或 TPU 来扩展你的训练运行。

多 GPU 训练

虽然 GPU 每年都在变得更强大,但深度学习模型也在变得越来越庞大,需要更多的计算资源。在单个 GPU 上训练对移动速度的速度设定了一个硬限制。解决方案?你可以简单地添加更多的 GPU 并开始进行多 GPU 分布式训练

在多个设备上分配计算有两种方式:数据并行模型并行

使用数据并行,单个模型在多个设备或多个机器上被复制。每个模型副本处理不同的数据批次,然后合并他们的结果。

使用模型并行,单个模型的不同部分在不同的设备上运行,同时处理单个数据批次。这对于具有自然并行架构的模型效果最好,例如具有多个分支的模型。在实践中,模型并行仅在模型太大而无法适应任何单个设备的情况下使用:它不是用作加速常规模型训练的方法,而是用作训练更大模型的方法。

然后,当然,你也可以混合使用数据并行和模型并行:单个模型可以跨多个设备(例如,4 个)分割,并且分割后的模型可以在多个设备组(例如,两次,总共使用 2 * 4 = 8 个设备)。

让我们详细看看它是如何工作的。

数据并行:在每个 GPU 上复制你的模型

数据并行是分布式训练中最常见的形式。它基于一个简单的原则:分而治之。每个 GPU 接收整个模型的副本,称为副本。传入的数据批次被分成N个子批次,每个子批次由一个模型副本并行处理。这就是为什么它被称为数据并行:不同的样本(数据点)是并行处理的。例如,如果有两个 GPU,大小为 128 的批次将被分成两个大小为 64 的子批次,由两个模型副本处理。然后

  • 在推理 — 我们将检索每个子批次的预测并将它们连接起来以获得整个批次的预测。

  • 在训练过程中——我们会检索每个子批次的梯度,计算平均值,并根据梯度平均值更新所有模型副本。然后,模型的状态将与你在 128 个样本的全批次上训练时相同。这被称为同步训练,因为所有副本都保持同步——它们的权重在所有时间点都有相同的值。存在非同步的替代方案,但它们效率较低,并且在实践中不再使用。

数据并行是一种简单且高度可扩展的方式来加速你的模型训练。如果你获得更多设备,只需增加你的批次大小,你的训练吞吐量就会相应增加。然而,它有一个限制:它要求你的模型能够适应你的某个设备。然而,现在训练具有数十亿参数的基础模型是很常见的,这些模型无法适应任何单个 GPU。

模型并行:将你的模型分割到多个 GPU 上

这就是模型并行的作用所在。虽然数据并行是通过将你的数据批次分割成子批次并在并行处理子批次来工作的,但模型并行是通过将你的模型分割成子模型,并在不同的设备上并行运行每个子模型来工作的。例如,考虑以下模型。

model = keras.Sequential(
    [
        keras.layers.Input(shape=(16000,)),
        keras.layers.Dense(64000, activation="relu"),
        keras.layers.Dense(8000, activation="sigmoid"),
    ]
) 

列表 18.4:一个大型密集连接模型

每个样本有 16,000 个特征,并通过两个Dense层被分类到 8,000 个可能重叠的类别中。这些层很大——第一个大约有 10 亿个参数,最后一个大约有 5.12 亿个参数。如果你使用的是两个小设备,你将无法使用数据并行,因为你无法将模型拟合到单个设备上。你可以做的是将单个模型实例分割到多个设备上。这通常被称为分片分区模型。在设备间分割模型主要有两种方式:水平分区和垂直分区。

在水平分区中,每个设备处理模型的不同层。例如,在之前的模型中,一个 GPU 会处理第一个Dense层,而另一个 GPU 会处理第二个Dense层。这种方法的缺点主要是可能会引入通信开销。例如,第一层的输出需要在第二层处理之前被复制到第二个设备上。这可能会成为瓶颈,尤其是如果第一层的输出很大——你可能会冒着让 GPU 闲置的风险。

在垂直分区中,每一层都分布在所有可用设备上。由于层通常是用 matmulconvolution 操作实现的,这些操作高度可并行化,因此在实践中实现这种策略很容易,并且几乎总是大型模型的最佳选择。例如,在先前的模型中,你可以将第一个 Dense 层的内核和偏置分成两半,这样每个设备只接收形状为 (16000, 32000) 的内核(沿其最后一个轴分割)和形状为 (32000,) 的偏置。你将使用这个半内核和半偏置对每个设备进行 matmul(inputs, kernel) + bias 计算,然后通过以下方式连接两个输出:

half_kernel_0 = kernel[:, :32000]
half_bias_0 = bias[:32000]

half_kernel_1 = kernel[:, 32000:]
half_bias_1 = bias[32000:]

with keras.device("gpu:0"):
    half_output_0 = keras.ops.matmul(inputs, half_kernel_0) + half_bias_0

with keras.device("gpu:1"):
    half_output_1 = keras.ops.matmul(inputs, half_kernel_1) + half_bias_1 

实际上,你将想要混合数据并行性和模型并行性。你将把你的模型分布在,比如说,四个设备上,然后你将在多个两组设备中复制那个分割的模型——比如说两组——每组并行处理一个子批次的数据。然后你将有两个副本,每个副本在四个设备上运行,总共使用八个设备(图 18.2)。

图 18.2:将模型分布在八个设备上:两个模型副本,每个由四组设备处理

实践中的分布式训练

现在我们来看看如何在实践中实现这些概念。我们只会涵盖 JAX 后端,因为它是各种 Keras 后端中最高效和最可扩展的,远远超过其他后端。如果你在进行任何类型的大规模分布式训练,而你没有使用 JAX,那么你正在犯一个错误——并且浪费你的美元,消耗了比你实际需要的计算资源多得多的计算。

获取两个或更多 GPU

首先,你需要获取几个 GPU 的访问权限。到目前为止,Google Colab 只允许你使用单个 GPU,所以你需要做以下两件事之一:

  • 获取两个到八个 GPU,将它们安装在一台机器上(这将需要一个强大的电源),并安装 CUDA 驱动程序、cuDNN 等。对大多数人来说,这不是最佳选择。

  • 在 Google Cloud、Azure 或 AWS 上租用多 GPU 虚拟机(VM)。你将能够使用预安装驱动程序和软件的 VM 镜像,并且设置开销非常小。这可能是对那些不是全天候训练模型的人来说的最佳选择。

我们不会详细介绍如何启动多 GPU 云虚拟机,因为这样的说明相对短暂,并且这些信息在网上很容易找到。

在 JAX 中使用数据并行性

在 Keras 和 JAX 中使用数据并行性非常简单:在构建你的模型之前,只需添加以下代码行:

keras.distribution.set_distribution(keras.distribution.DataParallel()) 

就这些。

如果你想要更细粒度的控制,你可以指定你想要使用的设备。你可以通过以下方式列出可用设备:

keras.distribution.list_devices() 

它将返回一个字符串列表——你的设备名称,例如 "gpu:0""gpu:1" 等等。然后你可以将这些传递给 DataParallel 构造函数:

keras.distribution.set_distribution(
    keras.distribution.DataParallel(["gpu:0", "gpu:1"])
) 

在理想的世界里,在N个 GPU 上训练将导致加速因子为N。然而,在实践中,分布引入了一些开销——特别是,合并来自不同设备的权重变化需要一些时间。你获得的有效加速是所用 GPU 数量的函数:

  • 使用两个 GPU 时,加速保持在 2×左右。

  • 使用四个时,加速大约为 3.8×。

  • 使用八个时,大约为 7.3×。

这假设你使用足够大的全局批次大小,以保持每个 GPU 的满负荷运行。如果你的批次大小太小,本地批次大小将不足以保持 GPU 忙碌。

使用 JAX 进行模型并行

Keras 还提供了强大的工具,可以完全自定义你想要如何进行分布式训练,包括模型并行训练以及你可以想象的数据并行和模型并行训练的任何混合。让我们深入了解。

DeviceMesh API

首先,你需要理解设备网格的概念。设备网格简单地说就是设备网格。考虑以下示例,有八个 GPU:

gpu:0   |   gpu:4
--------|---------
gpu:1   |   gpu:5
--------|---------
gpu:2   |   gpu:6
--------|---------
gpu:3   |   gpu:7 

主要思想是将设备分为组,沿着轴组织。通常,一个轴将负责数据并行,另一个轴将负责模型并行(如图 18.2 所示,你的设备形成一个网格,其中水平轴处理数据并行,垂直轴处理模型并行)。

设备网格不必是二维的——它可以是你想要的任何形状。然而,在实践中,你只会看到一维和二维网格。

让我们在 Keras 中创建一个 2×4 的设备网格:

device_mesh = keras.distribution.DeviceMesh(
    # We assume eight devices, organized as a 2 × 4 grid.
    shape=(2, 4),
    # It's convenient to give your axes meaningful names!
    axis_names=["data", "model"],
) 

请注意,你也可以明确指定你想要使用的设备:

devices = [f"gpu:{i}" for i in range(8)]
device_mesh = keras.distribution.DeviceMesh(
    shape=(2, 4),
    axis_names=["data", "model"],
    devices=devices,
) 

如您从axis_names参数中猜到的,我们打算使用轴 0 上的设备进行数据并行,轴 1 上的设备进行模型并行。由于轴 0 上有两个设备,轴 1 上有四个,我们将把模型计算分配到四个 GPU 上,并将我们的分割模型复制两次,并行地在不同子批次的数据上运行。

现在我们有了网格,我们需要告诉 Keras 如何将不同的计算部分分配到我们的设备上。为此,我们将使用LayoutMap API。

LayoutMap API

为了指定不同的计算部分应该在何处进行,我们使用变量作为我们的参考框架。我们将分割或复制变量到我们的设备上,并将所有与该变量部分相关的计算移动到相应的设备上。

考虑一个变量。它的形状是,比如说,(32, 64)。你可以对这个变量做两件事:

  • 你可以在网格的轴上复制(复制)它,这样沿着该轴的每个设备都能看到相同的值。

  • 你可以在网格的轴上分割(拆分)它——例如,你可以将其分割成四个形状为(32, 16)的块——这样沿着该轴的每个设备都能看到不同的块。

现在,请注意我们的变量有两个维度。重要的是,“分片”或“复制”是你可以独立为变量的每个维度做出的决定。

你将用来告诉 Keras 这些决定的 API 是LayoutMap类。LayoutMap类似于字典。它将模型变量(例如,你模型中第一个密集层的核变量)映射到关于该变量如何在设备网格上复制或分片的一些信息。具体来说,它将一个变量路径映射到一个元组,该元组有与变量维度一样多的条目,其中每个条目指定了对该变量维度的操作。它看起来像这样:

{
    # None means "replicate the variable along this dimension."
    "sequential/dense_1/kernel": (None, "model"),
    # "model" means "shard the variable along this dimension across the
    # devices of the model axis of the device mesh."
    "sequential/dense_1/bias": ("model",),
    ...
} 

这是你第一次遇到变量路径的概念——它只是一个看起来像"sequential/dense_1/kernel"的字符串标识符。这是一个在不保留实际变量实例的情况下引用变量的有用方式。

这是你可以打印模型中所有变量路径的方法:

for v in model.variables:
    print(v.path) 

在列表 18.4 的示例模型中,我们得到的结果如下:

sequential/dense/kernel
sequential/dense/bias
sequential/dense_1/kernel
sequential/dense_1/bias 

现在,让我们分片和复制这些变量。对于这样一个简单的模型,你的变量分片的基本规则应该是这样的:

  • 沿着"model"网格轴分片变量的最后一个维度。

  • 将所有其他维度保持为复制状态。

简单吧?就像这样:

layout_map = keras.distribution.LayoutMap(device_mesh)
layout_map["sequential/dense/kernel"] = (None, "model")
layout_map["sequential/dense/bias"] = ("model",)
layout_map["sequential/dense_1/kernel"] = (None, "model")
layout_map["sequential/dense_1/bias"] = ("model",) 

最后,我们通过设置分布配置来告诉 Keras 在实例化变量时参考这个分片布局,如下所示:

model_parallel = keras.distribution.ModelParallel(
    layout_map=layout_map,
    # This argument tells Keras to use the mesh axis named "data" for
    # data parallelism.
    batch_dim_name="data",
)
keras.distribution.set_distribution(model_parallel) 

一旦设置了分布配置,你就可以创建你的模型并使用fit()函数来拟合它。你的代码的其他部分不会改变——你的模型定义代码和训练代码都是相同的。这适用于你使用内置的 API 如fit()evaluate(),或者使用你自己的训练逻辑。假设你为你的变量有了正确的LayoutMap,你刚才看到的简短代码片段就足以分配任何大型语言模型训练运行的计算——它可以扩展到你拥有的任何数量的设备,以及任意大小的模型。

要检查你的变量是如何分片的,你可以检查variable.value.sharding属性,如下所示:

>>> model.layers[0].kernel.value.sharding
NamedSharding(
    mesh=Mesh("data": 2, "model": 4),
    spec=PartitionSpec(None, "model")
)

你甚至可以通过 JAX 实用工具jax.debug.visualize_sharding来可视化它:

import jax

value = model.layers[0].kernel.value
jax.debug.visualize_sharding(value.shape, value.sharding) 

TPU 训练

除了 GPU 之外,在深度学习领域,通常有一个趋势是将工作流程转移到为深度学习工作流程专门设计的越来越专业的硬件上;这样的专用芯片被称为 ASIC(应用特定集成电路)。大小各种公司都在开发新的芯片,但今天在这一领域最突出的努力是谷歌的 Tensor Processing Unit (TPU),它可在谷歌云和谷歌 Colab 上使用。

在 TPU 上训练确实需要跳过一些障碍。但这是值得的:TPU 真的非常快。在 Colab 上可用的 TPU v2(NVIDIA P100 GPU)上训练通常比训练快 15 倍。对于大多数模型,TPU 训练的平均成本效益比 GPU 训练高 3 倍。

你实际上可以在 Colab 中免费使用 TPU v2。在 Colab 菜单中,在“Runtime”选项卡下,在“Change Runtime Type”选项中,你会注意到除了 GPU 运行时之外,你还可以访问 TPU 运行时。对于更严肃的训练运行,Google Cloud 还提供了从 v3 到 v5 的 TPU,它们甚至更快。

当在启用 TPU 的笔记本上使用 JAX 后端运行 Keras 代码时,你不需要做任何更多的事情,只需调用keras.distribution.set_distribution(distribution)并传入一个DataParallelModelParallel分布实例即可开始使用你的 TPU 核心。确保在创建模型之前调用它!

使用步骤融合来提高 TPU 利用率

由于 TPU 拥有大量的计算能力,你需要使用非常大的批次来保持 TPU 核心忙碌。对于小型模型,所需的批次大小可能会非常大——每个批次超过 10,000 个样本。当处理巨大的批次时,你应该确保相应地增加你的优化器学习率:你将进行的权重更新会更少,但每次更新将更准确(因为梯度是使用更多的数据点计算的);因此,你应该在每次更新中通过更大的幅度移动权重。

然而,有一个简单的技巧可以在保持合理大小的批次的同时,维持 TPU 的完全利用率:步骤融合。其思路是在每个 TPU 执行步骤中运行多个训练步骤。基本上,在虚拟机内存与 TPU 之间往返两次之间做更多的工作。要做到这一点,只需在compile()函数中指定steps_per_execution参数——例如,steps_per_execution=8表示在每个 TPU 执行中运行八个训练步骤。对于未充分利用 TPU 的小型模型,这可以带来显著的加速:

model.compile(..., steps_per_execution=8) 

使用低精度计算加速训练和推理

如果我告诉你有一个简单的技巧,你可以用它来加速几乎任何模型的训练和推理,速度提高高达 2 倍,基本上是免费的?这听起来太好了,但确实存在这样的技巧。要了解它是如何工作的,首先,我们需要看看计算机科学中“精度”的概念。

理解浮点精度

精度对数字的重要性就像分辨率对图像的重要性。因为计算机只能处理 1 和 0,所以任何被计算机看到的数字都必须被编码为二进制字符串。例如,你可能熟悉uint8整数,这些整数是八位编码的整数:00000000uint8中表示0,而11111111表示 255。要表示超过 255 的整数,你需要添加更多的位——八位是不够的。大多数整数都是 32 位存储的,我们可以用它们来表示从-2147483648 到 2147483647 的带符号整数。

浮点数也是一样。在数学中,实数构成一个连续的轴:在任意两个数字之间有无限多个点。你总是可以放大实数轴。在计算机科学中,这并不成立:例如,在 3 和 4 之间只有有限数量的中间点。有多少个?这取决于你工作的精度:存储数字所使用的位数。你只能放大到一定的分辨率。

你通常会使用三个级别的精度:

  • 半精度,或float16,其中数字存储在 16 位上

  • 单精度,或float32,其中数字存储在 32 位上

  • 双精度,或float64,其中数字存储在 64 位上

你甚至可以提高到float8,稍后你将看到。

考虑浮点数的分辨率的方法是考虑两个任意数字之间你可以安全处理的最小距离。在单精度中,这大约是 1e-7。在双精度中,这大约是 1e-16。而在半精度中,只有 1e-3。

Float16 推理

在这本书中你迄今为止看到的每一个模型都使用了单精度数字:它将状态存储为float32权重变量,并在float32输入上运行其计算。这种精度足以运行模型的正向和反向传播而不会丢失任何信息——特别是在涉及小的梯度更新时(回想一下,通常的学习率是 1e-3,权重更新的量级通常为 1e-6)。

现代 GPU 和 TPU 具有专门的硬件,可以比等效的 32 位操作更快地运行 16 位操作,并且使用更少的内存。通过尽可能使用这些低精度操作,你可以显著加快这些设备上的训练速度。你可以在 Keras 中将默认的浮点精度设置为float16

import keras

keras.config.set_dtype_policy("float16") 

注意,这应该在定义你的模型之前完成。这样做将为模型推理(例如,通过model.predict())带来很好的加速效果。你应该期望在 GPU 和 TPU 上获得近 2 倍的速度提升。

对于某些设备,特别是 TPU,还有float16的替代方案:bfloat16bfloat16也是一种 16 位精度的浮点数类型,但它与float16在结构上有所不同:它使用 8 位指数位而不是 5 位,7 位尾数位而不是 10 位(见表 18.1)。这意味着它可以覆盖更广泛的值范围,但在这个范围内的“分辨率”较低。与float16相比,一些设备对bfloat16进行了更好的优化,所以尝试两者之前选择最快的选项可能是个好主意。

数据类型 float16 bfloat16
指数位 5 8
尾数位 10 7
符号位 1 1

表 18.1:float16bfloat16之间的差异

混合精度训练

将默认浮点精度设置为 16 位是加快推理速度的好方法。现在,当涉及到训练时,有一个显著的复杂性。梯度下降过程在float16bfloa16中不会顺利运行,因为我们无法表示大约 1e-5 或 1e-6 的小梯度更新,这在实践中相当常见。

然而,你可以使用一种混合方法:这就是混合精度训练的含义。其思路是在精度不是问题的地方使用 16 位计算,而在其他地方使用 32 位值以保持数值稳定性——特别是在处理梯度变量更新时。通过在完全精度下保持模型中精度敏感的部分,你可以在不显著影响模型质量的情况下,获得 16 位计算的大部分速度优势。

你可以这样开启混合精度:

import keras

keras.config.set_dtype_policy("mixed_float16") 

通常,模型的正向传播的大部分操作将在float16(除了像 softmax 这样的数值不稳定操作)中完成,而模型的权重将存储和更新在float32中。你的float16梯度在更新float32变量之前将被转换为float32

Keras 层有variable_dtypecompute_dtype属性。默认情况下,这两个都设置为float32。当你开启混合精度时,大多数层的compute_dtype将切换到float16。因此,这些层将把它们的输入转换为float16并在float16(使用半精度权重副本)中进行计算。然而,由于它们的variable_dtype仍然是float32,它们的权重将能够从优化器接收准确的float32更新,而不是半精度更新。

一些操作在float16中可能数值不稳定(特别是 softmax 和交叉熵)。如果你需要为特定层退出混合精度,只需将dtype="float32"参数传递给该层的构造函数即可。

使用混合精度进行损失缩放

在训练过程中,梯度可能会变得非常小。当使用混合精度时,你的梯度保持在float16(与前向传递相同)。因此,可表示数字的有限范围可能导致小梯度被舍入为零。这会阻止模型有效地学习。

梯度值与损失值成比例,因此为了鼓励梯度更大,一个简单的技巧是将损失乘以一个大的标量因子。你的梯度将不太可能被舍入为零。

Keras 使这变得简单。如果你想使用一个固定的损失缩放因子,你只需像这样将loss_scale_factor参数传递给你的优化器即可:

optimizer = keras.optimizers.Adam(learning_rate=1e-3, loss_scale_factor=10) 

如果你希望优化器自动确定正确的缩放因子,你也可以使用LossScaleOptimizer包装器:

optimizer = keras.optimizers.LossScaleOptimizer(
    keras.optimizers.Adam(learning_rate=1e-3)
) 

使用LossScaleOptimizer通常是你的最佳选择:正确的缩放值可以在训练过程中改变!

超越混合精度:float8训练

如果你以 16 位精度运行前向传递会产生如此整洁的性能优势,你可能想知道:我们能更低吗?8 位精度怎么样?四位,也许?两位?答案是,这很复杂。

使用float16进行前向传递的混合精度训练是“刚好工作”的最后一个精度级别——float16精度有足够的位来表示所有中间张量(除了梯度更新,这就是为什么我们为那些使用float32)。如果你降低到float8精度,这就不再成立了:你只是丢失了太多的信息。在某些计算中使用float8仍然是可能的,但这需要你对前向传递进行相当大的修改。你将不能简单地设置你的compute_dtypefloat8并运行。

Keras 框架为float8训练提供了内置实现。因为它专门针对 Transformer 用例,所以它只覆盖了一组受限的层:DenseEinsumDenseMultiHeadAttention层使用的Dense版本)和Embedding层。它的工作方式并不简单——它跟踪过去的激活值,以便在每一步重新缩放激活值,以便利用float8可表示值的全部范围。它还需要覆盖反向传播的一部分,以便对梯度值做同样的处理。

重要的是,这种额外的开销有计算成本。如果你的模型太小或如果你的 GPU 不够强大,这种成本将超过在float8中执行某些操作的好处,你将看到减速而不是加速。float8训练仅适用于非常大的模型(通常超过 50 亿参数)和大型、最新的 GPU,如 NVIDIA H100。在实践中,float8很少使用,除了在基础模型训练运行中。

使用量化的更快推理

float16——甚至float8——下运行推理将给你的模型带来不错的加速效果。但还有一个你可以使用的技巧:int8量化。主要思路是将一个已经训练好的、权重为float32的模型转换为更低精度的数据类型(通常是int8),同时尽可能保留前向传递的数值正确性。

如果你想要从头实现量化,数学原理很简单:一般思路是将所有matmul输入张量按某个因子缩放,使得它们的系数适合用int8表示的范围,即[-127, 127]——总共 256 个可能的值。在缩放输入后,将它们转换为int8,并以int8精度执行matmul操作,这应该比float16快得多。最后,将输出转换回float32,并除以输入缩放因子的乘积。由于matmul是线性操作,最终的取消缩放会抵消初始缩放,你应该得到与使用原始值相同的结果——任何精度损失仅来自将输入转换为int8时的值舍入。

让我们用一个例子来具体说明。假设你想执行matmul(x, kernel),以下是一些值:

from keras import ops

x = ops.array([[0.1, 0.9], [1.2, -0.8]])
kernel = ops.array([[-0.1, -2.2], [1.1, 0.7]]) 

如果你未经缩放就天真地将这些值转换为int8,那将会非常破坏性——例如,你的x会变成[[0, 0], [1, 0]]。所以让我们应用“绝对最大值”缩放方案,该方案将每个张量的值分散到[-127, 127]范围内:

def abs_max_quantize(value):
    # Max of absolute value of the tensor
    abs_max = ops.max(ops.abs(value), keepdims=True)
    # Scale is max of int range divided by max of tensor (1e-7 is to
    # avoid dividing by 0).
    scale = ops.divide(127, abs_max + 1e-7)
    # Scales the value
    scaled_value = value * scale
    # Rounding and clipping first is more accurate than directly
    # casting.
    scaled_value = ops.clip(ops.round(scaled_value), -127, 127)
    # Casts to int8
    scaled_value = ops.cast(scaled_value, dtype="int8")
    return scaled_value, scale

int_x, x_scale = abs_max_quantize(x)
int_kernel, kernel_scale = abs_max_quantize(kernel) 

现在我们可以执行更快的matmul操作并取消输出缩放:

int_y = ops.matmul(int_x, int_kernel)
y = ops.cast(int_y, dtype="float32") / (x_scale * kernel_scale) 

它有多准确?让我们将我们的yfloat32 matmul的输出进行比较:

>>> y
array([[ 0.9843736,  0.3933239],
       [-1.0151455, -3.1965137]])
>>> ops.matmul(x, kernel)
array([[ 0.98      ,  0.40999997],
       [-1\.        , -3.2       ]])

非常准确!对于大的matmul,这样做可以为你节省大量的计算,因为int8计算可以比float16计算快得多,而你只需向计算图中添加相对快速的逐元素操作——absmaxclipcastdividemultiply

现在,当然,我不期望你手动实现量化——那将非常不切实际。与float8类似,int8量化直接集成到特定的 Keras 层中:DenseEinsumDenseEmbedding。这为基于 Transformer 的任何模型解锁了int8推理支持。以下是使用任何包含此类层的 Keras 模型的方法:

# Instantiates a model (or any quantizable layer)
model = ...
# Boom!
model.quantize("int8")
# Now predict() and call() will run (partially) in int8!
predictions = model.predict(...) 

摘要

  • 你可以使用超参数调整和 KerasTuner 来自动化寻找最佳模型配置的繁琐工作。但要注意验证集过拟合!

  • 一组多样化的模型通常可以显著提高你预测的质量。

  • 为了进一步扩展你的工作流程,你可以使用数据并行在多个设备上训练模型,只要模型足够小,可以适应单个设备。

  • 对于更大的模型,您还可以使用模型并行来将您的模型变量和计算分散到多个设备上。

  • 您可以通过开启混合精度来加速在 GPU 或 TPU 上的模型训练——您通常可以在几乎不付出任何代价的情况下获得不错的速度提升。

  • 您也可以通过使用float16精度或甚至int8量化来加速推理。

第十九章:人工智能的未来

深度学习 Python 教程

要恰当地使用一个工具,你不仅应该了解它能做什么,还应该知道它不能做什么。我将概述一些深度学习的关键局限性。然后,我将提供一些关于人工智能未来演化和达到人类水平通用智能所需条件的推测性思考。如果你对基础研究感兴趣,这应该特别吸引你。

深度学习的局限性

你可以用深度学习做无数的事情。但深度学习并不能做一切。要很好地使用一个工具,你应该了解它的局限性,而不仅仅是它的优势。那么,深度学习在哪里不足?

深度学习模型难以适应新事物

深度学习模型的特性是大型、参数化的曲线,这些曲线拟合到大量数据集上。这是它们力量的来源——它们容易训练,并且在模型大小和数据集大小方面都能很好地扩展。但这也是一个显著弱点的来源。曲线拟合有固有的局限性。

首先,参数化曲线只能用于信息存储——它是一种数据库。回想一下我们在第十五章中关于 Transformer 作为“插值数据库”的讨论?其次,关键的是,这个数据库是静态的。模型的参数在特定的“训练时间”阶段确定。之后,这些参数被冻结,这个固定版本在“推理时间”用于对新数据进行预测。

使用静态数据库唯一能做的就是信息检索。这正是深度学习模型擅长的:识别或生成与训练过程中遇到的模式高度相似的图案。另一方面,它们在适应性方面天生不足。数据库是向后看的——它适合过去的数据,但无法处理不断变化的未来。在推理时间,你最好希望模型面对的情况是训练数据分布的一部分,否则模型将会崩溃。例如,在 ImageNet 上训练的模型会将豹纹沙发分类为真正的豹子——沙发不是其训练数据的一部分。

这也适用于最大的生成模型。近年来,大型语言模型(LLMs)的兴起及其在编程辅助和类似推理问题中的应用,为这一观点提供了广泛的实证证据。尽管经常有人声称 LLMs 可以通过上下文学习从几个例子中获取新技能,但大量证据表明,他们实际上正在执行的是在训练期间记忆的向量函数的检索和重新应用。通过学习在网页大小的文本数据集上进行下一标记预测,LLM 收集了数百万个可能有用的迷你文本处理程序,并且可以很容易地提示它们在新问题上的重复使用。但是,如果展示给它的是其训练数据中没有直接对应的内容,它就无能为力了。

看一下图 19.1 中的谜题。你找到解决方案了吗?很好。这并不难,对吧?但是今天,没有任何最先进的 LLM 或视觉语言模型能够做到这一点,因为这个问题并没有直接映射到他们在训练时间看到的任何东西——即使是在整个互联网上训练过之后。LLM 解决特定问题的能力与问题复杂性无关,而与熟悉度有关——他们会在任何足够新颖的问题上咬紧牙关,无论这个问题多么简单。

图 19.1:一个简单而新颖的谜题

这种失败模式甚至适用于 LLM 在训练数据中多次遇到的模式的微小变化。例如,在 ChatGPT 发布后的几个月里,如果你问它,“10 公斤的钢铁和 1 公斤的羽毛哪个更重?”,它会回答说它们重量相同。这是因为“10 公斤的钢铁和 1 公斤的羽毛哪个更重?”这个问题在互联网上多次出现——作为一个陷阱问题。当然,正确的答案是它们重量相同,所以 GPT 模型只是重复它记忆中的答案,而没有注意到查询中的实际数字,或者查询真正意味着什么。同样,LLM 在适应蒙提霍尔问题的变体(见图 19.2)方面也遇到困难,并且倾向于总是输出他们在训练期间多次看到的经典答案,而不管这在上下文中是否有意义。

图 19.2:蒙提霍尔问题的变体

值得注意的是,这些特定的提示后来通过特殊处理得到了修复。如今,有超过 25,000 人全职工作,通过审查失败案例和提出更好的答案来为 LLMs 提供训练数据。LLMs 的维护是一场持续的打地鼠游戏,一次修复一个失败的提示,而没有解决更普遍的根本问题。即使已经修复的提示,如果你对其做出小的修改,它们仍然会失败!

深度学习模型对措辞和其他干扰因素非常敏感

与之密切相关的问题是深度学习模型对输入呈现方式的极端敏感性。例如,图像模型会受到对抗样本的影响,这些样本被输入到深度学习网络中,旨在欺骗模型将其错误分类。你已经知道,在输入空间中可以进行梯度上升来生成最大化某些卷积神经网络(ConvNet)滤波器激活的输入——这是第十章中引入的滤波器可视化技术的基础。

同样,通过梯度上升,你可以稍微修改一个图像,以最大化给定类别的预测。通过拍摄一只熊猫的照片并添加长臂猿的梯度,我们可以让神经网络将熊猫分类为长臂猿(见图 19.3)。这既证明了这些模型的脆弱性,也证明了它们的输入到输出映射与我们的人类感知之间的深刻差异。

图 19.3:一个对抗样本:图像中的细微变化可能会颠覆模型对图像的分类。

同样,大型语言模型(LLMs)对提示中的细微细节具有极高的敏感性。无害的提示修改,如更改文本段落中的地点和人物名称或代码块中的变量名称,可能会显著降低 LLMs 的性能。考虑一下著名的爱丽丝梦游仙境谜题^([1]):

“爱丽丝有 N 个兄弟,她还有 M 个姐妹。爱丽丝的兄弟有多少个姐妹?”

答案当然是M + 1(爱丽丝的姐妹加上爱丽丝自己)。对于一个 LLM 来说,用在线实例中常见的值(如N = 3 和M = 2)提问通常会得到正确答案,但如果你调整MN的值,你很快就会得到错误的答案。

这种对措辞的过度敏感催生了提示工程的概念。提示工程是制定 LLM 提示的艺术,以最大限度地提高任务性能。例如,将“请逐步思考”这样的指令添加到涉及推理的提示中,可以显著提高性能。术语提示工程是对潜在问题的非常乐观的表述:“你的模型比你想象的要好!你只需要正确使用它们!”更消极的表述可能是指出,对于任何看似有效的查询,都有一系列微小的变化可能会严重影响性能。如果你可以通过简单的改写来破坏 LLMs 的理解,那么 LLMs 对某事的理解程度有多大?

这种现象背后的原因是,LLM 是一个大的参数曲线——一个存储知识和程序的中介,你可以在这两个对象之间进行插值,以产生无限多的中间对象。你的提示是一种指向数据库特定位置的方式:如果你问,“如何在 Python 中排序一个列表?像海盗一样回答”,那是一种数据库查找,你首先检索一段知识(如何在 Python 中排序列表),然后检索并执行一个风格转换程序(“像海盗一样回答”)。

由于 LLM 索引的知识和程序是插值的,你可以在潜在空间中移动来探索附近的区域。一个略有不同的提示,比如“解释 Python 列表排序,但像海盗一样回答”,仍然会指向数据库中非常相似的位置,从而得到一个相当接近但并不完全相同的答案。你可以使用数千种不同的变体,每种变体都会得到一个相似但略有不同的答案。这就是为什么需要提示工程的原因。你的第一个、天真的提示没有先验理由是针对你的任务的优化。LLM 不会理解你的意思,然后以最佳方式执行它——它只是会在许多可能的着陆点中检索你的提示所指向的程序。

提示工程是通过试错法在潜在空间中搜索,以找到在目标任务上似乎表现最好的查找查询的过程。这与在谷歌搜索时尝试不同的关键词没有区别。如果大型语言模型(LLMs)实际上理解了你所问的内容,那么这个搜索过程就没有必要了,因为关于你的目标任务所传达的信息量不会因为你的提示使用“重写”而不是“改写”或者是否在提示前加上“逐步思考”而改变。永远不要假设 LLM 第一次就能“理解”你的意思——记住,你的提示只是无限程序海洋中的一个地址,所有这些程序都是作为学习完成大量标记序列的副产品而被记忆的。

深度学习模型在学习可泛化程序方面存在困难

深度学习模型的问题不仅仅是它们局限于盲目地重新应用在训练时记住的模式,或者它们对输入的呈现方式高度敏感。即使你只需要查询和应用一个已知的程序,并且你知道如何在潜在空间中精确地定位这个程序,你仍然面临一个主要问题:深度学习模型记住的程序通常泛化能力不好。它们对某些输入值有效,但对其他输入值无效。这对于编码任何类型的离散逻辑的程序尤其如此。

考虑一下加法的问题,即以字符序列表示的两个数字的相加——例如“4 3 5 7 + 8 9 3 6”。尝试训练一个 Transformer 在成千上万的此类数字对上:你将达到非常高的准确率。非常高,但不是 100%——你将经常看到错误的答案,因为 Transformer 无法成功编码精确的加法算法(你知道的,你在小学学过的那个)。相反,它通过在训练时看到的各个数据点之间进行插值来猜测输出。

这也适用于最先进的 LLMs——至少对于那些没有明确硬编码在 Python 中执行“4357 + 8936”等片段以提供正确答案的 LLMs。它们已经看到了足够多的数字加法示例,可以加法,但它们的准确率只有大约 70%——相当令人失望。此外,它们的准确率强烈依赖于哪些数字正在被加,更常见的数字会导致更高的准确率。

即使看到数百万个示例,深度学习模型也无法最终学习到精确的加法算法的原因是,它只是一个静态的简单、连续的几何变换链,将一个向量空间映射到另一个向量空间。这对于感知模式识别来说是一个很好的匹配,但对于编码任何类型的逐步离散逻辑(例如,如数位或进位这样的概念)来说,匹配得非常差。它所能做的就是将一个数据流形 X 映射到另一个流形 Y,假设存在一个可学习的连续变换从 X 到 Y。深度学习模型可以被解释为一种程序,但反过来,大多数程序都不能表示为深度学习模型。对于大多数任务来说,要么不存在合理大小的相应神经网络来解决该任务,要么即使存在,也可能不可学习:相应的几何变换可能过于复杂,或者可能没有适当的数据来学习它。

将机器学习模型拟人化的风险

我们对图像、声音和语言的理解根植于我们作为人类的感觉运动经验。机器学习模型无法访问这样的经验,因此无法以人类相关的方式理解它们的输入。通过向我们的模型输入大量训练示例,我们让它们学习一种几何变换,将数据映射到特定示例集上的人类概念,但这种映射只是我们心中原始模型——即从我们作为具身代理的经验中发展出来的模型——的简单草图。这就像镜子中的模糊图像(见图 19.4)。你创建的模型将采取任何可用的捷径来适应它们的训练数据。

图 19.4:当前机器学习模型:就像镜子中的模糊图像

当代人工智能的一个真实风险是误解深度学习模型所做的事情,并高估它们的能力。人类的一个基本特征是我们的心智理论:我们倾向于将意图、信念和知识投射到周围的事物上。在石头上画一个笑脸突然让它在我们心中变得“快乐”。应用于深度学习,这意味着当我们训练能够使用语言的模型时,我们会认为模型“理解”它们生成的单词序列的内容,就像我们一样。然后,当我们发现任何与训练数据中存在的模式略有偏离时,模型就会产生完全荒谬的答案,我们会感到惊讶。

作为机器学习从业者,始终要意识到这一点,并永远不要陷入相信神经网络理解它们所执行的任务的陷阱——它们不理解,至少不是以对我们有意义的方式。它们被训练在一个与我们想要教给它们的任务截然不同、范围更窄的任务上:将训练输入映射到训练目标,一点一点地。向它们展示任何与它们的训练数据不符的东西,它们就会以荒谬的方式崩溃。

规模并不是一切

我们是否可以仅仅通过扩大模型规模来克服深度学习的局限性?规模是我们所需要的全部吗?这长期以来一直是该领域的普遍观点,特别是在 2023 年初,在 LLM 炒作的高峰期尤为突出。当时,GPT-4 刚刚发布,本质上只是 GPT-3 的扩大版:更多的参数,更多的训练数据。其显著提高的性能似乎表明,你可以继续前进——可能会有一个 GPT-5,它只是更多相同的东西,并且从它那里会自然地出现通用人工智能(AGI)。

这种观点的支持者会指出“缩放定律”作为证据。缩放定律是观察到的经验关系,即深度学习模型的大小(以及其训练数据集的大小)与其在特定任务上的性能之间的关系。它们表明,增加模型的大小可以可靠地以可预测的方式提高性能。但缩放定律的爱好者们忽略的关键问题是,他们用来衡量“性能”的基准实际上是记忆测试,这是我们喜欢给大学生出的测试。LLMs 通过记忆答案在这些测试中表现良好,而且自然地,将更多问题和答案塞入模型相应地提高了它们的性能。

事实上,扩大我们的模型并没有解决我在这些页面中列出的任何问题——无法适应新颖性、对措辞过度敏感以及无法推理出推理问题的通用程序——因为这些问题是曲线拟合,深度学习范式的固有属性。我从 2017 年开始指出这些问题,我们至今仍在努力解决这些问题——现在模型的大小已经增加了四到五个数量级,并且知识更加丰富。我们没有在这些问题上取得任何进展,因为我们使用的模型仍然是相同的。它们已经保持了七年多——它们仍然是通过对数据集进行梯度下降拟合的参数曲线,并且它们仍在使用 Transformer 架构。

通过堆叠更多层和使用更多训练数据来扩展当前的深度学习技术并不能解决深度学习的根本问题:

  • 深度学习模型局限于使用它们在训练时记忆的内插程序。它们无法在推理时独立地合成全新的程序以适应大量新颖的情况。

  • 即使在已知的情况下,这些内插程序也存在着泛化问题,这导致了对措辞的过度敏感和混淆特征。

  • 深度学习模型在它们能表示的内容上有所局限,你可能会希望学习的多数程序不能被表示为数据流形连续几何变形。这在算法推理任务中尤其如此。

让我们更仔细地看看是什么将生物智能与深度学习方法区分开来。

自动机与智能体

深度学习模型从输入到输出的直接几何变形与人类思考和学习的差异是根本性的。这不仅仅是人类通过具身经验自学,而不是被提供明确的训练示例。与可微分的参数函数相比,人脑是完全不同的生物。

让我们稍微放大视角,问一问,智能的目的是什么?它最初为什么会出现?我们只能进行推测,但我们可以做出相当有根据的推测。我们可以从大脑开始看起——产生智能的器官。大脑是一种进化适应——一种在数亿年间通过自然选择引导的随机尝试和错误逐步发展起来的机制,它极大地扩展了生物适应其环境的能力。大脑最初在超过五十亿年前出现,作为一种存储和执行行为程序的方式。行为程序只是一系列指令,使生物对其环境产生反应:“如果发生这种情况,那么就做那件事。”它们将生物的感觉输入与其运动控制联系起来。最初,大脑的作用可能是硬编码行为程序(作为神经网络连接模式),这样生物就能对其感觉输入做出适当的反应。这就是昆虫大脑仍然工作的方式——苍蝇、蚂蚁、C. elegans(见图 19.5)等。因为这些程序的原初“源代码”是 DNA,它会被解码为神经网络连接模式,进化突然能够以在很大程度上不受限制的方式在行为空间中进行搜索——这是一个主要的进化转变。

图片

图 19.5:秀丽线虫(C. elegans)的大脑网络:一个由自然进化“编程”的行为自动机。图由 Emma Towlson 创作(来自“网络控制原理预测秀丽隐杆线虫连接组中的神经元功能”,Yan 等人,《自然》,2017 年 10 月)。

进化是程序员,大脑是执行进化所提供代码的计算机。由于神经网络连接是一个非常通用的计算基础,所有大脑启用物种的感官运动空间突然开始经历巨大的扩张。眼睛、耳朵、颚、4 条腿、24 条腿——只要你有大脑,进化就会为你找到利用这些特性的行为程序。大脑可以处理你扔给它的任何模态,或模态的组合。

现在,请注意,这些早期的头脑本身并不一定具有智能。它们非常像自动机:它们只会执行生物 DNA 中硬编码的行为程序。它们只能被描述为具有与恒温器“智能”相同的智能。或者是一个列表排序程序。或者是一个训练有素的深度神经网络(人工的那种)。这是一个重要的区别,让我们仔细看看:自动机和真正的智能代理之间有什么区别?

局部泛化与极端泛化

人工智能领域长期以来一直受到将智能自动化概念混淆的困扰。一个自动化系统(或自动机)是静态的,被设计在特定环境中完成特定任务——“如果这个,那么那个”——而智能代理可以即时适应新颖、意外的情况。当一个自动机遇到不符合其“编程”去做的事情时(无论是关于人类编写的程序、进化生成的程序,还是将模型拟合到训练数据集上的隐式编程过程),它将失败。

同时,像我们人类这样的智能代理将利用他们的流动智能找到前进的道路。你如何区分一个只是记住了过去三年考试问题但没有理解学科的学生和一个真正理解材料的学生?你给他们一个全新的问题。

人类的能力远不止像深度网络或昆虫那样将即时刺激映射到即时反应。我们能够即时构建关于我们当前情况、我们自己以及他人的复杂、抽象模型,并可以使用这些模型来预测不同的可能未来并执行长期规划。我们能够快速适应意外情况,并在少量练习后掌握新技能。

这种使用抽象推理来处理我们没有准备好的经验的能力是人类认知的标志性特征。我称之为极端泛化:一种使用少量数据甚至没有新数据就能适应新颖、从未经历过的情境的能力。这种能力是人类和高级动物所展现的智能的关键。

这与类似自动机系统的行为形成了鲜明的对比。一个非常僵化的自动机根本不具备任何泛化能力;它无法处理任何它事先没有精确告知的事情。Python 字典,或者作为硬编码的 if-then-else 语句实现的简单问答程序就会属于这一类。深度网络做得稍微好一些:它们可以成功处理与它们熟悉的情况略有偏差的输入,这正是它们有用的地方。我们第八章中的狗与猫模型可以分类它之前没有见过的猫或狗图片,只要它们足够接近训练时的样本。然而,深度网络局限于我所说的局部泛化(见图 19.6):当输入开始偏离网络在训练时看到的输入时,深度网络从输入到输出的映射很快就会变得没有意义。深度网络只能泛化到已知未知,到在模型开发期间预测到的变化因素,这些因素在训练数据中广泛出现,例如宠物图片的不同角度或光照条件。这是因为深度网络通过在流形上的插值进行泛化(记得第五章):它们输入空间中的任何变化因素都需要被它们学习的流形所捕捉。这就是为什么基本的数据增强在提高深度网络泛化能力方面非常有帮助。与人类不同,这些模型在面对数据很少或没有数据的情况时,没有任何即兴发挥的能力。

图 19.6:局部泛化与极端泛化

例如,考虑这样一个问题:学习适当的发射参数,使火箭能够成功着陆在月球上。如果你为这个任务使用深度网络,并使用监督学习或强化学习来训练它,你将不得不给它提供成千上万甚至数百万次的发射试验:你需要让它接触到输入空间的密集采样,以便它能够学习从输入空间到输出空间的可靠映射。相比之下,作为人类,我们可以利用我们的抽象能力来提出物理模型——火箭科学——并推导出一个精确的解决方案,这个方案将使火箭在一次性或几次试验中成功着陆在月球上。同样,如果你开发了一个控制人类身体的深度网络,并且你希望它学会在不会撞到汽车的情况下安全地导航城市,网络将不得不在各种情况下死亡成千上万次,直到它能够推断出汽车是危险的,并发展出适当的避免行为。被投入到一个新的城市中,网络将不得不重新学习它所知道的大部分内容。另一方面,人类能够在不死亡的情况下学会安全的行为——这再次得益于我们抽象建模新情况的能力。

智能的目的

这种高度适应性的智能代理和僵化的自动机之间的区别让我们回到了大脑进化的主题。为什么大脑——最初只是自然进化发展行为自动机的媒介——最终变得智能?像每一个重要的进化里程碑一样,这是由于自然选择的限制鼓励了这一变化的发生。

大脑负责行为生成。如果一个生物体必须面对的情况大多是静态的且提前已知,那么行为生成将是一个简单的问题:进化只需通过随机尝试和错误找出正确的行为,并将它们硬编码到生物体的 DNA 中。大脑进化的这个第一阶段——作为自动机的头脑——就已经是最佳的。然而,关键的是,随着生物体复杂性和与之相伴的环境复杂性的持续增加,动物必须应对的情况变得更加动态和不可预测。如果你仔细观察,你生活中的每一天都不像你以前经历过的任何一天,也不像你的进化祖先们经历过的任何一天。你需要能够不断面对未知和意外的情况。进化无法找到并将你从几个小时前醒来以来成功导航你一天的行为序列硬编码到 DNA 中。它必须每天即时生成。

大脑,作为一个良好的行为生成引擎,只是适应了这一需求。它优化了适应性和通用性本身,而不仅仅是优化对一组固定情况的适应性。这种转变很可能在进化历史的多个时期发生,导致了在非常遥远的进化分支上的高度智能动物——猿类、章鱼、乌鸦等等。智能是对复杂、动态生态系统提出的挑战的回答。

这就是智能的本质:它是在不确定、不断变化的未来中,有效地利用你拥有的信息来产生成功行为的能力。笛卡尔所说的“理解”是这种非凡能力的关键:挖掘你过去经验的能力,以开发模块化、可重用的抽象,这些抽象可以快速重新用于处理新情况并实现极端的泛化。

爬升泛化谱系

用一种粗略的讽刺画来说,你可以将生物智能的进化历史总结为一种缓慢的沿着泛化光谱的攀登。它始于只能执行局部泛化的类似自动机的头脑。随着时间的推移,进化开始产生能够进行越来越广泛泛化的生物体,这些生物体能够在越来越复杂和多变的环境中生存。最终,在过去的几百万年——在进化术语中是一瞬间——某些人属物种开始趋向于实现能够进行极端泛化的生物智能的实施,从而引发了人类世的开端,永远改变了地球上生命的历史。

过去 70 年间人工智能的进步与这一演变过程有着惊人的相似之处。早期的 AI 系统是纯粹的自动机,例如 20 世纪 60 年代的 ELIZA 聊天程序,或者 SHRDLU:^([2]),这是一种 1970 年能够通过自然语言命令操纵简单物体的 AI。在 1990 年代和 2000 年代,我们见证了能够处理一定程度的不确定性和新颖性的局部泛化机器学习系统的兴起。在 2010 年代,深度学习通过使工程师能够使用更大的数据集和更具有表现力的模型,进一步扩展了这些系统的局部泛化能力。

今天,我们可能正处于下一个进化步骤的边缘。我们正在走向能够实现广泛泛化的系统,我将这定义为在单个广泛的任务领域内处理未知未知的能力(包括系统未接受过训练的情况以及其创造者无法预见的情况)。例如,一辆能够安全应对任何情况的自动驾驶汽车或能够通过“沃兹智力测试”的家庭机器人——进入一个随机的厨房并煮一杯咖啡:^([3])。通过结合深度学习和精心手工制作的抽象模型,我们已经在朝着这些目标取得明显的进展。

然而,深度学习范式仍然局限于认知自动化:在“人工智能”中的“智能”标签是一个分类错误。更准确地说,我们应该称我们的领域为“人工认知”,其中“认知自动化”和“人工智能”是其中的两个几乎独立的子领域。在这个细分中,AI 将是一个绿洲,其中几乎一切都还有待发现。

现在我不打算贬低深度学习的成就。认知自动化非常有用,深度学习模型仅通过数据暴露就能自动化任务的能力,代表了一种特别强大的认知自动化形式,比显式编程更实用、更灵活。做好这一点对于几乎所有行业来说都是一场变革。但它离人类(或动物)的智能还差得很远。到目前为止,我们的模型只能进行局部泛化:它们通过从 X 到 Y 的数据点的密集采样中学习到的平滑几何变换,将空间 X 映射到空间 Y,而 X 或 Y 中的任何干扰都会使这种映射无效。它们只能泛化到与过去数据相似的新情况,而人类的认知能够进行极端的泛化,快速适应极端新颖的情况,并为长期未来的情况做出规划。

如何构建智能

到目前为止,你已经了解到,智能远不止深度学习所进行的这种潜在流形插值。那么,我们究竟需要从哪里开始构建真正的智能?目前我们还在逃避的核心要素是什么?

万花筒假说

智能是利用你的过去经验(以及先天的先验知识)来面对新颖、意外的未来情况的能力。现在,如果你必须面对的未来是真正新颖的——与之前所见的一切都没有共同点——无论你多么聪明,你都无法对它做出反应。

智能之所以有效,是因为没有任何事物是完全没有先例的。当我们遇到新事物时,我们能够通过将它们与过去的经验进行类比,并使用我们收集多年的抽象概念来阐述它们,从而理解它们。一个 17 世纪的人第一次看到喷气式飞机时,可能会描述它为一只大而响亮的金属鸟,它不会拍打翅膀。汽车?那是一种无马的马车。如果你试图向小学生讲解物理,你可以解释电就像水管中的水,或者时空就像被重物扭曲的橡皮膜。

除了这样明确、显式的类比之外,我们还在不断地做出更小、更隐晦的类比——每秒钟,每思考一次。类比是我们导航生活的方式。在一家新的超市购物?你会通过将其与你去过的类似商店联系起来找到自己的路。与新人交谈?他们会让你想起你之前遇到的一些人。甚至看似随机的模式,如云的形状,也会立刻在我们脑海中唤起生动的图像——一头大象,一艘船,一条鱼。

这些类比不仅存在于我们的脑海中:物理现实本身充满了同构。电磁学与重力相似。由于共同的起源,动物在结构上彼此相似。二氧化硅晶体与冰晶体相似。等等。

我把这个称为万花筒假说:我们对世界的体验似乎具有难以置信的复杂性和永无止境的新颖性,但这个复杂性海洋中的每一件事都与其他事物相似。你需要描述你所处的宇宙的独特意义原子的数量相对较小,而你周围的一切都是这些原子的重组:一些种子,无尽的变异,就像万花筒内部发生的事情一样,其中一些玻璃珠被一组镜子反射,产生丰富、看似无尽的图案(见图 19.7)。

图片

图 19.7:万花筒仅从几颗彩色玻璃珠中产生丰富(但重复)的图案。

智力的本质:抽象获取和重组

智力是挖掘你的经验以识别这些可以看似在不同情况下重复使用的意义原子——万花筒的核心珠子。一旦提取出来,它们就被称为抽象。无论何时你遇到一个新情况,你都会通过即时重新组合你收藏中的抽象来理解它,以编织一个全新的“模型”,适应该情况。

这个过程包括两个关键部分:

  • 抽象获取——有效地从一系列经验或数据中提取紧凑、可重用的抽象。这涉及到识别潜在的结构、原则或不变量。

  • 即时重组——以新颖的方式高效地选择和重组这些抽象来模拟新的问题和情况,甚至那些与以往经验截然不同的情况。

效率的强调至关重要。你的智能取决于你从有限的经验中获取良好抽象的效率,以及你如何有效地重组它们以导航不确定性和新颖性。如果你需要数万小时的练习来掌握一项技能,那么你并不聪明。如果你需要列出棋盘上所有可能的走法来找到最佳走法,那么你也不聪明。

这就是经典深度学习范式中的两个主要问题的来源:

  • 这些模型完全缺乏即时重组。它们在训练时间通过梯度下降在获取抽象方面做得相当不错,但按照设计,它们在测试时间没有能力重组它们所知道的内容。它们的行为就像一个静态的抽象数据库,仅限于检索。它们遗漏了整个画面的一半——最重要的那一半。

  • 他们效率极低。梯度下降需要大量的数据来提炼整洁的抽象——比人类多出许多数量级的更多数据。

那么,我们如何超越这些限制?

设置正确目标的重要性

生物智能是自然界提出的问题的答案。同样,如果我们想开发真正的 AI,首先,我们需要提出正确的问题。最终,AI 系统的能力反映了它们被设计和优化的目标。

在系统设计中,你经常会看到一种效应,即捷径规则:如果你专注于优化一个成功指标,你将实现你的目标,但要以牺牲系统内所有未涵盖在你成功指标中的东西为代价。你最终会采取通往目标的每一个可用的捷径。你的创造物是由你给予自己的激励所塑造的。

你在机器学习竞赛中经常看到这种情况。2009 年,Netflix 举办了一场挑战赛,承诺向实现电影推荐任务最高分数的团队提供 100 万美元的奖金。结果,他们从未使用获胜团队创建的系统,因为它过于复杂且计算密集。获胜者只优化了预测准确性——他们被激励去实现的目标——而牺牲了系统其他所有可取的特性:推理成本、可维护性、可解释性。在大多数 Kaggle 竞赛中,捷径规则也是成立的——Kaggle 获胜者产生的模型很少,如果有的话,可以在生产中使用。

在过去几十年中,捷径规则在 AI 领域无处不在。在 20 世纪 70 年代,心理学家和计算机科学先驱艾伦·纽厄尔担心他的领域没有在向正确的认知理论取得任何有意义的进展,因此为 AI 提出了一个新的宏伟目标:下棋。其理由是,在人类中,下棋似乎涉及到——也许甚至需要——诸如感知、推理和分析、记忆和从书籍中学习等能力。当然,如果我们能构建一个下棋的机器,它也必须具备这些属性。对吧?

二十多年后,梦想成真:1997 年,IBM 的 Deep Blue 击败了世界最佳棋手加里·卡斯帕罗夫。那时,研究人员不得不面对这样一个事实:创建一个棋类冠军 AI 并没有让他们对人类智能有太多了解。Deep Blue 的核心算法 A*并不是人类大脑的模型,也不能推广到除类似棋类游戏之外的任务。结果证明,构建一个只能下棋的 AI 比构建一个人工智能大脑要容易得多——这就是研究人员采取的捷径。

到目前为止,AI 领域的驱动成功指标一直是解决特定任务,从棋类到围棋,从 MNIST 分类到 ImageNet,从高中数学考试到律师资格考试。因此,该领域的历史是由一系列“成功”定义的,在这些“成功”中,我们找到了解决这些任务的方法,而没有涉及任何智能

如果这听起来像是一个令人惊讶的陈述,请记住,类似人类的智能并不是由任何特定任务的技能所定义——相反,它是指适应新事物以高效获取新技能和掌握从未见过的任务的能力。通过固定任务,你使得提供对需要完成的事情的任意精确描述成为可能——无论是通过硬编码人类提供的知识,还是通过提供巨大的数据量。你使得工程师只需添加数据或添加硬编码的知识,就能“购买”更多技能给他们的 AI,而不需要增加 AI 的泛化能力(见图 19.8)。如果你有近乎无限的训练数据,即使是像最近邻搜索这样非常粗糙的算法也能以超人的技能玩电子游戏。同样,如果你有近乎无限的人类编写的 if-then-else 语句——也就是说,直到你对游戏规则进行微小改变,这种改变是人类可以立即适应的——这将要求无智能的系统重新训练或从头开始重建。

图片

图 19.8:一个低泛化能力的系统在给定无限的任务特定信息的情况下,可以在固定任务上实现任意技能。

简而言之,通过固定任务,你消除了处理不确定性和新事物的需求,而智能的本质就是处理不确定性和新事物,因此你实际上消除了对智能的需求。而且,由于找到特定任务的低智能解决方案总是比解决智能的普遍问题更容易,所以这就是你 100%会采取的捷径。人类可以用他们的通用智能来学习任何新任务的技能,但反过来,从一系列特定任务的技能到通用智能没有路径。

新的目标:即时适应

要使 AI 真正智能并赋予它处理现实世界难以置信的多样性和不断变化性质的能力,首先,我们需要摆脱寻求实现特定任务技能的愿望,转而开始针对泛化能力本身。我们需要新的进展指标,这将帮助我们开发越来越智能的系统:指标将指引正确的方向,并给我们一个可操作的反馈信号。只要我们设定的目标是“创建一个解决任务 X 的模型”,捷径规则就会适用,我们最终会得到一个只做 X 的模型。

在我看来,智能可以精确地量化为一个效率比率:你关于世界的相关信息量(这可能是过去的经验或先天的先验知识)与你的未来操作区域之间的转换比率,即你将能够产生适当行为的新的情境集合(你可以将其视为你的技能集)。一个更智能的代理将能够使用更少的过去经验处理更广泛的未来任务和情境。为了测量这个比率,你只需要固定你系统可用的信息——它的经验和它的先验知识——并测量它在一系列已知与系统所接触到的内容足够不同的参考情境或任务上的表现。试图最大化这个比率应该会引导你走向智能。关键的是,为了避免作弊,你需要确保只在系统没有被编程或训练来处理的任务上对其进行测试——实际上,你需要的是系统创造者无法预料的任务。

在 2018 年和 2019 年,我开发了一个名为抽象与推理语料库用于通用人工智能(ARC-AGI)的基准数据集([1](#footnote-4)),旨在捕捉这种智能的定义。ARC-AGI 旨在使机器和人类都能接近,它看起来非常类似于人类智商测试,例如拉文渐进矩阵测试。在测试时,你会看到一系列“任务”。每个任务都通过三到四个“示例”进行解释,这些示例以输入网格和相应的输出网格的形式出现(见图 19.9)。然后你会得到一个全新的输入网格,你将有三次机会产生正确的输出网格,之后才能进行下一个任务。

图片

图 19.9:一个 ARC-AGI 任务:任务性质通过几个输入-输出对示例进行展示。给定一个新的输入,你必须构建相应的输出。

与智商测试相比,ARC-AGI 有两个独特之处。首先,ARC 通过只测试你从未见过的任务来寻求衡量泛化能力。这意味着 ARC-AGI 是一个你无法练习的游戏,至少在理论上是这样:你将要接受测试的任务将具有其独特的逻辑,你必须即时理解。你不能只是记住过去任务中的特定策略。

此外,ARC-AGI 试图控制你在测试中带来的先验知识。你永远不会完全从头开始接近一个新问题——你带着先前的技能和信息接近它。ARC-AGI 假设所有测试者都应该从一组称为核心知识先验的知识集合开始,这代表了人类天生具有的知识系统。与智商测试不同,ARC-AGI 任务永远不会涉及获得的知识,例如英语句子等。

ARC 奖

在 2024 年,为了加速向能够进行类似 ARC-AGI 所测量的那种流畅抽象和推理的人工智能系统迈进,我与 Mike Knoop 合作成立了非营利性的 ARC Prize 基金会。该基金会每年举办一次竞赛,奖金池丰厚(2024 年的版本超过 100 万美元)以激励研究人员开发能够解决 ARC-AGI 并因此展现真正流畅智能的人工智能。

ARC-AGI 基准对主流的深度学习扩展范式表现出惊人的抵抗力。在 LLM 时代,大多数其他基准很快就已经饱和。那是因为它们可以通过记忆来破解,而 ARC-AGI 被设计成对此具有抵抗力。从 2019 年 ARC-AGI 首次发布到 2025 年,基础 LLM 经历了大约 50,000 倍的扩展——从 GPT-2(2019)到 GPT-4.5(2025),但它们在 2019 版 ARC-AGI 上的表现仅从 0%上升到大约 10%。考虑到读者您很容易就能得到 95%以上的分数,这并不好。

如果你将你的系统扩展了 50,000 倍,但你仍然没有取得有意义的进展,那就像一个巨大的警告信号告诉你需要尝试新的想法。仅仅使模型更大或用更多数据训练它们并没有解锁 ARC-AGI 所需的流畅智能。ARC-AGI 明显表明,即时重组能力是解决推理所必需的。

测试时自适应时代

在 2024 年,一切发生了改变。那一年见证了主要叙事的转折——部分是由 ARC Prize 催化的。2023 年的主流“规模就是一切”的故事,曾是那个时代的基石教条,开始让位于“实际上,我们需要即时重组”。2024 年 12 月宣布的竞赛结果令人启迪:领先的解决方案并非仅仅通过扩展现有的深度学习架构产生。它们都使用了某种形式的测试时自适应(TTA)——要么是测试时搜索,要么是测试时训练。

TTA 指的是 AI 系统在测试过程中进行主动推理或学习的方法,使用特定问题信息——这是经典深度学习范式所缺失的关键组件。

有几种方法可以实现测试时自适应:

  • 测试时训练——模型根据测试任务中给出的示例调整其部分参数,使用梯度下降。

  • 搜索方法——系统在测试时搜索许多可能的推理步骤或潜在解决方案,以找到最佳方案。这可以是自然语言(思维链合成)或在一个符号、可验证的程序空间中(程序综合)。

这些 TTA 方法使 AI 系统更加灵活,并能比静态模型更好地处理新颖性。ARC Prize 2024 的每个顶级参赛作品都使用了它们。

在比赛结束不久,2024 年 12 月底,OpenAI 预览了其 o3 测试时推理模型,并使用 ARC-AGI 展示了其前所未有的能力。利用大量的测试时计算资源,该模型以每项任务约 200 美元的成本实现了 76%的得分,以每项任务超过 20,000 美元的成本实现了 88%的得分,超过了名义上的人类基准。我们第一次看到了一个显示出真正流畅智能迹象的人工智能模型。这一突破打开了类似技术的新一波兴趣和投资的闸门——测试时适应时代开始了。重要的是,ARC-AGI 是当时唯一提供明确信号的基准之一,表明正在进行一场重大的范式转变。

ARC-AGI 2

这是否意味着 AGI 已经被解决了?o3 的智能是否与人类相当?

还不尽然。首先,虽然 o3 的表现是一个里程碑式的成就,但它付出了巨大的代价——每个 ARC-AGI 谜题的计算成本高达数万美元。智能不仅仅是关于能力;它本质上关乎效率。在巨大的计算能力下强行搜索解决方案空间是一种捷径,它使得各种任务成为可能,而不需要智能。原则上,你甚至可以通过简单地遍历每个可能的解决方案程序树并测试每一个,直到找到在演示对中能工作的一个来解决问题 ARC-AGI。尽管 o3 的结果令人印象深刻,但它们更像是用超级计算机破解代码,而不是展示灵活、类似人类的流畅推理。智能的整个目的就是以尽可能少的资源实现结果。

其次,我们发现 o3 仍然被许多人类认为非常简单(如图 19.10 所示)的任务难住了。这强烈表明 o3 还没有达到人类水平。这里的关键是,2019 版本的 ARC-AGI 旨在简单。它本质上是对流体智能的二进制测试:要么你没有流体智能,就像所有基础 LLM 一样,在这种情况下你的得分接近零,要么你确实表现出一些真正的流体智能,在这种情况下你将立即获得极高的分数,就像任何人类——或者 o3 一样。中间没有太多空间。很明显,基准需要随着它旨在衡量的 AI 能力的发展而发展。需要一个新的 ARC-AGI 版本,它不那么容易强行解决,并且能够更好地区分具有不同水平流体推理能力的系统,直至达到人类水平的流体智能。好消息是:我们自 2022 年以来一直在研究一个。

图片

图 19.10:o3 在最高计算设置下(每项任务超过 20,000 美元)无法解决的问题示例

因此,在 2025 年 3 月,ARC Prize Foundation 推出了 ARC-AGI-2。它保留了与第一版完全相同的格式,但显著提高了任务内容。新的迭代旨在提高标准,包括需要更复杂推理链和本质上更难以穷举搜索方法的任务。目标是创建一个基准,其中计算效率成为成功的关键因素,推动系统走向更真实、更有效的策略,而不仅仅是探索数十亿种可能性。虽然大多数 ARC-AGI-1 任务几乎可以立即由人类解决,而不需要太多认知努力,但 ARC-AGI-2 中的所有任务都需要一定程度的深思熟虑(见图 19.11)——例如,在我们实验中人类测试者完成任务的平均时间是 5 分钟。

图片

图 19.11:典型的 ARC-AGI-1 任务(左)与典型的 ARC-AGI-2 任务(右)

在 ARC-AGI 2 上的初始 AI 测试结果令人沮丧:即使是 o3 在与这一新挑战的斗争中也遇到了重大困难,其分数在合理计算预算限制下暴跌至个位数以下。至于基础 LLM 呢?它们在 ARC-AGI-2 上的表现实际上回到了 0%——这是合适的,因为基础 LLM 没有流体智力。构建具有真正高效、类似人类流体智力的 AI 的挑战仍然远未解决。我们需要超越当前 TTA 技术的某种东西。

缺少的要素:搜索和符号

要完全解决 ARC-AGI,特别是版本 2,需要什么?希望这个挑战能让你思考。这正是 ARC-AGI 的全部目的:给你一个不同类型的目标,这将推动你走向新的方向——希望是一个富有成效的方向。现在,让我们快速看一下如果你想要回应这个召唤,你需要的关键要素。

我说过,智力由两个组成部分组成:抽象获取抽象重组。它们紧密相连——你操作的是哪种类型的抽象决定了你如何以及如何有效地重组它们。深度学习模型只操作通过参数曲线存储的抽象,通过梯度下降拟合。是否可能有更好的方法?

抽象的两个极端

抽象获取始于相互比较。关键的是,有两种不同的比较方式,由此产生了两种不同的抽象类型和两种不同的思维方式,每种方式更适合解决不同类型的问题。这两个抽象的极端共同构成了我们所有思想的基础。

将事物相互关联的第一种方式是相似性比较,这产生了以价值为中心的类比。第二种方式是精确的结构匹配,这产生了以程序为中心的类比(或以结构为中心的类比)。在这两种情况下,你都是从某个事物的一个实例开始,并将相关的实例合并在一起,以产生一个抽象,这个抽象能够捕捉到潜在实例的共同元素。变化的是你如何判断两个实例之间的关系,以及你如何将实例合并到抽象中。让我们逐一仔细看看每种类型。

价值中心类比

假设你在后院看到许多不同的甲虫,属于多个物种。你会注意到它们之间的相似性。有些会彼此更相似,有些则不那么相似:相似性的概念隐含着一个平滑的、连续的距离函数,它定义了一个潜在流形,你的实例就生活在这个流形上。一旦你看过足够的甲虫,你就可以开始将更相似的实例聚在一起,并将它们合并成一个原型集合,这个集合能够捕捉到每个集群共享的视觉特征(图 19.12)。这些原型是抽象的:它们看起来不像你见过的任何特定实例,尽管它们编码了它们共有的属性。当你遇到一个新的甲虫时,你不需要将它与你之前看到的每一个甲虫进行比较,就知道如何处理它。你只需将它与你手中的几个原型进行比较,找到最接近的原型——甲虫的类别——然后用它做出有用的预测:甲虫可能会咬你吗?它会吃你的苹果吗?

图片

图 19.12:价值中心类比通过连续的相似性概念将实例联系起来,以获得抽象原型。

这听起来熟悉吗?这几乎是对无监督机器学习(如 K-means 聚类算法)的描述。一般来说,所有现代机器学习,无论是监督还是无监督,都是通过学习潜在流形来工作的,这些流形描述了一个实例空间,并通过原型进行编码。(还记得第十章中可视化的 ConvNet 特征吗?它们是视觉原型。)以价值为中心的类比是那种能够使深度学习模型进行局部泛化的类比制作方式。

这也是许多你自己的认知能力所依赖的。作为一个人类,你一直在进行以价值为中心的类比。这是模式识别感知直觉背后的抽象类型。如果你能不思考就能完成任务,你很大程度上是在依赖以价值为中心的类比。如果你在看电影,并开始无意识地将不同的角色分类为“类型”,那是以价值为中心的抽象。

程序中心类比

关键的是,认知不仅仅是价值中心类比所允许的那种直接、近似、直觉的分类。还有一种抽象生成机制,较慢、精确、深思熟虑:程序中心(或结构中心)类比。

在软件工程中,你经常编写看起来有很多共同点的不同函数或类。当你注意到这些冗余时,你开始思考,是否有一个更抽象的函数可以执行相同的工作,并且可以被重复使用两次?是否有一个抽象的基类,你的两个类都可以从中继承?你在这里使用的抽象定义对应于程序中心类比。你并不是试图通过它们看起来多么相似来比较你的类和函数,就像你通过隐含的距离函数比较两个人的脸一样。相反,你感兴趣的是它们是否有部分具有完全相同的结构。你正在寻找所谓的子图同构(见图 19.13):程序可以用操作符的图来表示,你正在尝试找到在不同程序中完全共享的子图(程序子集)。

图片

图 19.13:程序中心类比识别并隔离不同实例间的同构子结构。

这种在不同离散结构内部通过精确结构匹配进行类比制作的过程,并不局限于像计算机科学或数学这样的专业领域——你一直在不知不觉中使用它。它构成了推理规划以及严谨(相对于直觉)的一般概念。每当你思考通过离散关系网络相互关联的对象(而不是连续相似性函数)时,你就是在使用以程序为中心的类比。

认知作为两种抽象的结合

表 19.1 并排比较了这两个抽象的极端。

价值中心抽象 程序中心抽象
通过距离关联事物 通过精确的结构匹配关联事物
连续的,基于几何的。 离散的,基于拓扑的
通过“平均”实例到“原型”产生抽象 通过隔离实例间的同构子结构产生抽象
基于感知和直觉 基于推理和规划
立即的,模糊的,近似的 慢速的,精确的,严谨的
产生可靠结果需要大量经验 经验高效:可以操作最少的两个实例

表 19.1:抽象的两个极端

我们所做的一切,我们思考的一切,都是这两种抽象类型的组合。你很难找到只涉及其中一种的任务。即使是看似“纯粹感知”的任务,比如在场景中识别物体,也涉及到大量关于你所观察到的物体之间关系的隐含推理。而即使是看似“纯粹推理”的任务,比如寻找数学定理的证明,也涉及到大量的直觉。当数学家把笔放在纸上时,他们已经对将要走的方向有一个模糊的愿景。他们为了到达目的地所采取的离散推理步骤是由高级直觉引导的。

这两个极端是互补的,正是它们的交织使得极端泛化成为可能。没有哪一个心智可以没有它们而完整。

为什么深度学习不是抽象生成完整答案

深度学习在编码以价值为中心的抽象方面非常出色,但它基本上没有能力生成以程序为中心的抽象。类似人类的智能是这两种类型的紧密交织,所以我们实际上缺少了我们需要的另一半——可以说是最重要的另一半。

现在,这里有一个警告。到目前为止,我把每种类型的抽象都描述为完全独立于另一种——甚至是相反的。然而,在实践中,它们更像是一个光谱:在一定程度上,你可以通过在连续流形中嵌入离散程序来进行推理——就像你可能通过任何一组离散点拟合多项式函数一样,只要你有多达足够的系数。反过来,你可以使用离散程序来模拟连续的距离函数——毕竟,当你用计算机进行线性代数时,你是在连续空间中工作,完全是通过在 1 和 0 上操作的离散程序来实现的。

然而,显然有一些问题更适合其中一种类型。比如,尝试训练一个深度学习模型来排序五个数字的列表。有了正确的架构,这并非不可能,但这是一个令人沮丧的练习。你需要大量的训练数据才能让它发生——即使如此,当面对新的数字时,模型仍然会偶尔犯错。而如果你想要开始排序十个数字的列表,你需要完全重新训练模型——在更多的数据上。与此同时,用 Python 编写一个排序算法只需要几行代码,一旦在几个额外的例子上验证通过,它就可以在任何大小的列表上工作。这相当强的泛化能力:从几个演示示例和测试示例到可以成功处理任何数字列表的程序。

反过来,感知问题与离散推理过程非常不匹配。尝试编写一个纯 Python 程序来分类 MNIST 数字,不使用任何机器学习技术:你将面临一场挑战。你会发现自己在费力地编写可以检测数字中闭合环数的函数、数字质心的坐标等等。在编写数千行代码之后,你可能会达到 90%的测试准确率。在这种情况下,拟合参数模型要简单得多;它可以更好地利用大量可用数据,并实现更稳健的结果。如果你有很多数据,并且面临一个适用于流形假设的问题,那么选择深度学习。

因此,我们不太可能看到一种将推理问题简化为流形插值或把感知问题简化为离散推理的方法的出现。在人工智能领域的前进方向是开发一个统一的框架,该框架包含两种抽象生成类型。

人工智能的另一种方法:程序综合

直到 2024 年,能够进行真正离散推理的人工智能系统都是由人类程序员硬编码的——例如,依赖于搜索算法、图操作和形式逻辑的软件。在测试时自适应(TTA)时代,这种情况终于开始改变。特别有前途的 TTA 分支之一是程序综合——一个今天仍然非常小众的领域,但我预计在接下来的几十年里它将迎来大发展。

程序综合是通过使用搜索算法(可能是遗传搜索,如遗传编程)来探索大量可能的程序空间(见图 19.14)来自动生成简单的程序。当找到一个符合所需规格的程序时,搜索停止,这些规格通常以一组输入输出对的形式提供。这非常类似于机器学习:给定作为输入输出对提供的训练数据,我们找到一个匹配输入到输出的程序,并且可以推广到新的输入。区别在于,我们不是在硬编码的程序(神经网络)中学习参数值,而是通过离散搜索过程生成源代码(见表 19.2)。

图 19.14:程序综合的示意图:给定一个程序规范和一组构建块,搜索过程将构建块组装成候选程序,然后对这些候选程序进行测试,以符合规范。搜索会继续进行,直到找到一个有效的程序。

机器学习 程序综合
模型:可微分的参数函数 模型:编程语言操作符的图
引擎:梯度下降 引擎:离散搜索(如遗传搜索)
需要大量数据来产生可靠的结果 数据高效:可以用几个训练示例工作

表 19.2:机器学习与程序综合的比较

程序综合是我们将向我们的 AI 系统添加以程序为中心的抽象能力的方法。它是拼图缺失的一块。

混合深度学习和程序综合

当然,深度学习不会消失。程序综合不是它的替代品;它是它的补充。这是我们人工大脑迄今为止缺失的半球。我们将两者结合使用。这种结合将主要有两种方式:

  • 开发集成深度学习模块和离散算法模块的系统

  • 使用深度学习使程序搜索过程本身更高效

让我们回顾一下这些可能的途径。

将深度学习模块和算法模块集成到混合系统中

今天,许多最强大的 AI 系统都是混合型的:它们既使用深度学习模型,也使用手工编写的符号操作程序。例如,在 DeepMind 的 AlphaGo 中,展示的大部分智能都是由人类程序员(如蒙特卡洛树搜索)设计和硬编码的。数据学习仅发生在专门的子模块(价值网络和政策网络)中。或者考虑 Waymo 自动驾驶汽车:它能够处理大量不同的场景,因为它维护着周围世界的模型——一个字面上的 3D 模型——其中充满了由人类工程师硬编码的假设。这个模型通过深度学习感知模块(由 Keras 提供动力)不断更新,这些模块将其与汽车周围的环境接口。

对于这两个系统——AlphaGo 和自动驾驶汽车——人类创建的离散程序和学习的连续模型相结合,才能解锁一种单独使用任何一种方法都无法达到的性能水平,例如端到端的深度网络或不含机器学习元素的软件。到目前为止,这种混合系统的离散算法元素都是人类工程师费力地硬编码的。但在未来,这样的系统可能完全是通过学习实现的,没有任何人类参与。

这会是什么样子呢?考虑一种著名的网络类型:循环神经网络(RNNs)。需要注意的是,RNNs 比前馈网络有稍微少的限制。这是因为 RNNs 不仅仅是几何变换:它们是在for循环中反复应用的几何变换。时间for循环本身是由人类开发者硬编码的:这是网络的内置假设。自然地,RNNs 在它们能表示的内容上仍然极为有限,主要是因为它们每一步执行的都是可微的几何变换,并且它们通过连续几何空间中的点(状态向量)从一步传递到下一步携带信息。现在想象一个以类似方式增强的神经网络,它使用编程原语,但不是单个硬编码的for循环和硬编码的连续空间内存,而是包含了一个大型的编程原语集合,模型可以自由地操作以扩展其处理功能,例如if分支,while语句,变量创建,用于长期记忆的磁盘存储,排序运算符,高级数据结构(如列表、图和哈希表)等等。这样一个网络可以表示的程序空间将比当前深度学习模型所能表示的更广泛,其中一些程序可以实现更优越的泛化能力。重要的是,这样的程序将不是端到端可微的,尽管特定的模块仍然可微,因此需要通过离散程序搜索和梯度下降的组合来生成。

我们将不再仅仅拥有硬编码的算法智能(手工软件)和学习的几何智能(深度学习)。相反,我们将拥有一个结合了提供推理和抽象能力的正式算法模块以及提供非正式直觉和模式识别能力的几何模块(图 19.15)。整个系统将几乎不需要人工参与进行学习。这应该会极大地扩展可以用机器学习解决的问题的范围——给定适当的训练数据,我们可以自动生成的程序空间。像 AlphaGo 这样的系统——甚至 RNNs——可以被视为这种混合算法-几何模型的史前祖先。

图片

图 19.15:一个既依赖几何原语(模式识别,直觉)又依赖算法原语(推理,搜索,记忆)的学习程序

使用深度学习引导程序搜索

今天,程序综合面临一个主要障碍:它效率极低。为了夸张,典型的程序综合技术是通过在搜索空间中尝试每一个可能程序,直到找到一个与提供的规范相匹配的程序。随着程序规范复杂性的增加,或者用于编写程序的原始词汇的扩展,程序搜索过程会遇到所谓的组合爆炸:要考虑的可能程序集增长非常快,实际上,比仅仅指数级增长还要快。因此,今天,程序综合只能用来生成非常短小的程序。你不会很快为你的电脑生成一个新的操作系统。

为了前进,我们需要通过使其更接近人类编写软件的方式,使程序综合变得高效。当你打开你的编辑器来编写脚本时,你不会考虑每一个可能编写程序。你只会在心中考虑几种可能的方法:你可以利用你对问题的理解和以往的经验,大大减少要考虑的可能选项的空间。

深度学习可以帮助程序综合做到同样的事情:尽管我们希望生成的每个特定程序可能是一个基本上离散的对象,执行非插值数据操作,但到目前为止的证据表明,所有有用程序的集合可能看起来非常像一个连续流形。这意味着一个在数百万个成功的程序生成场景上训练过的深度学习模型可能会开始发展出关于搜索过程应该采取的程序空间路径直觉——就像软件工程师可能立即对即将编写的脚本的总体架构以及他们应该用作通往目标途径的垫脚石的中间函数和类有直觉一样。

记住,人类的推理很大程度上是由以价值为中心的抽象所指导的——也就是说,通过模式识别和直觉。程序综合也应该如此。我预计,在接下来的 10 到 20 年里,通过学习启发式方法引导程序搜索的一般方法将越来越受到研究兴趣的关注。

模块化组件重组和终身学习

如果模型变得更加复杂,并且建立在更丰富的算法原语之上,那么这种增加的复杂性将需要任务之间更高的重用率,而不是每次遇到新任务或新数据集时从头开始训练新模型。许多数据集不包含足够的信息,使我们能够从头开始开发新的复杂模型,因此有必要使用先前遇到的数据集中的信息(就像你每次打开新书时不会从头开始学习英语一样——那是不可能的)。由于当前任务与先前遇到的任务之间有大量重叠,因此在每个新任务上从头开始训练模型也是低效的。

随着现代基础模型的发展,我们正逐渐接近一个世界,其中 AI 系统拥有大量的知识和技能,并将它们应用于任何他们遇到的情况。但是,LLMs 缺少一个关键成分:重组。LLMs 非常擅长检索和重新应用记忆中的函数,但它们还无法将这些函数即时重新组合成适应当前情况的新程序。实际上,正如 Dziri 等人最近的研究论文所调查的,它们完全无法执行函数组合。更重要的是,它们学习的函数类型既不够抽象也不够模块化,这使得它们一开始就不适合重组。记得我们指出 LLMs 在添加大整数时准确性较低吗?你可能不会想在如此脆弱的函数之上构建你的下一个代码库。

要解决组合泛化问题,我们需要重用像人类编程语言中找到的函数和类这样的强大程序组件。这些组件将专门为在新的环境中模块化重用而进化——与 LLMs 记忆的模式不同。我们的 AI 将在运行时重新组合它们,以合成适应当前任务的新的程序。关键的是,这样的可重用组件库将通过我们 AI 所有实例的累积经验来构建,并将永久对所有用户开放。我们的 AI 遇到的任何单个问题只需解决一次——使它们不断自我改进。

想象一下今天的软件开发过程:一旦工程师解决了特定问题(例如 Python 中的 HTTP 查询),他们就会将其打包成一个抽象的、可重用的库,任何人都可以在地球上访问。未来面临类似问题的工程师将能够搜索现有库,下载一个,并在自己的项目中使用它。以类似的方式,在未来,元学习系统将通过筛选全球高级可重用块库来组装新的程序。当系统发现自己为几个不同的任务开发类似的程序子例程时,它可以提出一个抽象的、可重用的子例程版本,并将其存储在全局库中(见图 19.16)。这些子例程可以是几何的(具有预训练表示的深度学习模块)或算法的(更接近当代软件工程师操作的库)。

图 19.16:一个能够快速开发特定任务模型的元学习器,使用可重用的原语(既包括算法也包括几何),从而实现极端的泛化

长期愿景

简而言之,这是我对于人工智能的长期愿景:

  • 模型将更像程序,并将具有超越我们目前工作的输入数据的连续几何变换的能力。这些程序将无庸置疑地更接近人类对其周围环境和自身的抽象心理模型,并且由于它们丰富的算法性质,它们将能够实现更强的泛化。

  • 尤其是模型将融合提供形式推理、搜索和抽象能力的算法模块,以及提供非正式直觉和模式识别能力的几何模块。这将实现以价值为中心和以程序为中心的抽象的融合。AlphaGo 或自动驾驶汽车(需要大量手动软件工程和人工设计决策的系统)是这种符号和几何人工智能融合的早期例子。

  • 这样的模型将通过自动生长而不是由人类工程师硬编码,使用存储在全局可重用子例程库中的模块化部分——这个库是通过在数千个先前任务和数据集上学习高性能模型而演化的。随着元学习系统识别出频繁的问题解决模式,它们将被转化为可重用的子例程——就像软件工程中的函数和类一样——并添加到全局库中。

  • 搜索可能的子例程组合以增长新模型的流程将是一个离散搜索过程(程序综合),但它将受到由深度学习提供的程序空间直觉的强烈指导。

  • 这个全球子程序库及其相关的模型增长系统将能够实现某种形式的人类似极端泛化:给定一个新的任务或情况,系统将能够使用非常少的数据组装一个新的适用于该任务的模型,这得益于丰富的程序化原语,这些原语具有很好的泛化能力,以及丰富的类似任务的经验。同样,如果人们有大量先前游戏的经验,他们可以快速学会玩复杂的全新电子游戏,因为从这种先前经验中得出的模型是抽象的、程序化的,而不是刺激和行动之间基本映射。

这个持续学习且模型不断增长的系统可以被解释为一种通用人工智能(AGI)。但不要期待随之而来的任何单一化机器人末日:那只是纯粹的幻想,源于对智能和技术的长期深刻误解。然而,这样的批评并不属于这本书的内容。

脚注

  1. 参见玛丽安娜·涅祖里娜、卢西亚·奇波利娜-库恩、梅赫迪·查尔蒂和叶尼娅·吉特谢夫,《爱丽丝梦游仙境:简单任务显示最先进大型语言模型中完全推理崩溃》,arXiv,arxiv.org/abs/2406.02061[↩]

  2. 特里·温格罗德,《作为计算机程序中理解自然语言数据表示的过程》,1971 年。[↩]

  3. 迈克尔·希克,《沃兹尼亚克:电脑能煮一杯咖啡吗?》快公司,2010 年 3 月。[↩]

  4. 弗朗索瓦·肖莱,《关于智能的度量》,2019 年。[↩]

  5. Nouha Dziri 等人,《信仰与命运:Transformer 在组合性上的局限性》,第 37 届国际神经网络信息处理系统会议论文集(2023 年),arxiv.org/abs/2305.18654[↩]

第二十章:结论

原文:deeplearningwithpython.io/chapters/chapter20_conclusion

我们将从这本书应该吸取的总体观点开始。这应该会刷新你对所学的一些概念的记忆。然后,我们将为你提供一份关于进一步学习机器学习和跟上新技术进展的资源与策略的简要列表。

成为有效的 AI 实践者是一个旅程,完成这本书只是你在这个旅程上的第一步。我想确保你意识到这一点,并准备好独立迈出下一步。

复习中的关键概念

本节简要总结了本书的关键要点。如果你需要快速复习以帮助回忆所学内容,可以阅读这几页。

人工智能的多种方法

首先,深度学习并不等同于人工智能(AI),甚至也不等同于机器学习:

  • 人工智能(AI)是一个古老而广泛的领域,通常可以理解为“所有尝试自动化人类认知过程”的尝试。这可以从非常基础的,如 Excel 电子表格,到非常高级的,如能够行走和说话的人形机器人。

  • 机器学习 是人工智能的一个特定子领域,旨在通过仅从训练数据中接触来自动开发程序(称为 模型)。将数据转化为程序的过程称为 学习。尽管机器学习已经存在很长时间了,但它直到 20 世纪 90 年代才开始起飞,并在 21 世纪初成为人工智能的主导形式。

  • 深度学习 是机器学习众多分支之一,其中的模型是几何变换的长链,依次应用。这些操作被组织成称为 的模块:深度学习模型通常是层的堆叠——或者更普遍地说,是层的图。这些层由 权重 参数化,这些权重是在训练期间学习的参数。模型的 知识 存储在其权重中,学习过程包括找到这些权重的“良好值”——这些值最小化 损失函数。因为考虑的几何变换链是可微分的,所以通过 梯度下降 高效地更新权重以最小化损失函数。

  • 生成式 AI 是深度学习的一个特定子集,其中的模型能够生成文本、图像、视频或声音。这些模型通常非常大——拥有数十亿个参数。它们以自监督的方式进行训练;也就是说,它们被训练来重建输入中人工缺失或损坏的部分——例如,去噪图像、预测句子中的下一个单词等。这个过程使得模型能够学习其输入空间的复杂“映射”(嵌入流形),这些映射可以用于采样新的输入。这些模型随着 ChatGPT 或 Midjourney 等产品的兴起,将 AI 带入了“消费者”时代。

尽管深度学习只是机器学习众多方法中的一种,但它并不与其他方法处于同等地位。深度学习是一次突破性的成功。原因如下。

深度学习在机器学习领域中的特殊性

在短短几年内,深度学习在一系列历史上被认为对计算机来说极其困难的任务上取得了巨大的突破,尤其是在机器感知领域:从图像、视频、声音中提取有用信息等。在提供足够的训练数据(特别是由人类适当标注的训练数据)的情况下,深度学习使得从感知数据中提取几乎任何人类能够提取的内容成为可能。因此,有时人们会说深度学习“解决了感知”问题——尽管这仅适用于对感知的狭义定义。

由于其前所未有的技术成功,深度学习独自引发了第三次,也是迄今为止最大的AI 夏天:一个对 AI 领域充满兴趣、投资和炒作的时期。当这本书正在撰写时,我们正处于这个时期。这个时期是否会在近期结束,以及结束之后会发生什么,是人们争论的话题。有一点是肯定的:与之前的 AI 夏天形成鲜明对比的是,深度学习为大大小小的科技公司带来了巨大的商业价值,并成为巨大的消费者成功,实现了人类水平的语音识别、聊天机器人助手、逼真的图像生成、人类水平的机器翻译等。炒作可能会(并且很可能)消退,但深度学习持续的经济和技术影响将保持。从这个意义上说,深度学习可以与互联网相提并论:它可能在几年内过度炒作,但从长远来看,它仍将是一场重大的革命,将改变我们的经济和我们的生活。

我对深度学习特别乐观的一个原因是,即使在未来十年内我们不再取得任何技术进步,将现有的算法应用于每一个适用的问题也将对大多数行业产生颠覆性的影响。深度学习无异于一场革命,由于资源投入和人力投入的指数级增长,进展正在以惊人的速度发生。从目前的情况来看,未来看起来光明,尽管短期预期有些过于乐观;将深度学习发挥到其潜能的极致可能需要数十年的时间。

如何思考深度学习

深度学习最令人惊讶的事情是它的简单性。十五年前,没有人预料到我们会通过使用简单的参数模型,并通过梯度下降进行训练,在机器感知和自然语言处理问题上取得如此惊人的成果。现在看来,你所需要的只是足够大的参数模型,在足够多的例子上用梯度下降进行训练。正如费曼曾经关于宇宙所说,“它并不复杂,只是有很多。”^([1])

在深度学习中,一切都是向量;也就是说,一切都是几何空间中的一个。模型输入(文本、图像等)和目标首先被向量化——转换成初始输入向量空间和目标向量空间。深度学习模型中的每一层都对通过它的数据进行一次简单的几何变换。模型中层的链式操作形成了一个复杂的几何变换,分解成一系列简单的变换。这个复杂变换试图将输入空间映射到目标空间,一次映射一个点。这个变换由层的权重参数化,这些权重根据模型当前的表现进行迭代更新。这个几何变换的一个关键特性是它必须是可微分的,这对于我们通过梯度下降学习其参数是必要的。直观地说,这意味着从输入到输出的几何变形必须是平滑和连续的——这是一个重要的约束。

将这种复杂的几何变换应用于输入数据的过程可以通过想象一个人试图展开一个纸团来在 3D 中进行可视化:皱巴巴的纸团是模型开始时的输入数据流形。人对纸团进行的每一次操作都类似于一层简单几何变换的操作。完整的展开动作序列是整个模型的复杂变换。深度学习模型是用于展开高维数据复杂流形的数学机器。

这就是深度学习的魔力——将意义转化为向量,转化为几何空间,然后逐步学习复杂的几何变换,将一个空间映射到另一个空间。你所需要的只是足够高维度的空间来捕捉原始数据中找到的所有关系范围。

整个事情的关键在于两个核心思想:即意义来源于事物之间的成对关系(在语言中的单词之间、在图像中的像素之间等等)以及这些关系可以通过距离函数来捕捉。但请注意,大脑是否通过几何空间来实现意义是一个完全独立的问题。从计算的角度来看,向量空间是高效的,但可以很容易地设想出用于智能的不同数据结构——特别是图。神经网络最初是从使用图作为编码意义的方式这一想法中产生的,这就是为什么它们被称为神经网络;周围的研究领域曾经被称为联结主义。如今,“神经网络”这个名字纯粹是出于历史原因——这是一个极其误导性的名字,因为它们既不是神经的,也不是网络的。特别是,神经网络几乎与大脑没有什么关系。一个更合适的名字可能是层次表示学习分层表示学习,或者甚至可以是深度可微模型链式几何变换,以强调连续几何空间操作是其核心。

关键使能技术

当前正在展开的技术革命并非始于任何单一的重大突破发明。相反,就像任何其他革命一样,它是大量使能因素积累的结果——最初缓慢,然后突然。在深度学习的情况下,我们可以指出以下关键因素:

  • 算法创新的逐步发展,最初持续了二十年(从反向传播开始),然后在 2012 年之后,随着更多研究努力投入到深度学习中,发生得越来越快。2017 年,Transformer 架构是一个重大的突破。

  • 大量图像、视频和文本数据的可用性,这是实现足够大的模型在足够大的数据上训练所必需的。这反过来又是消费互联网的兴起和摩尔定律应用于存储媒体的产物。如今,最先进的语言模型是在整个互联网的大部分数据上训练的。

  • 快速、高度并行计算硬件的可用性,尤其是 NVIDIA 生产的 GPU——首先是游戏 GPU,然后是专为深度学习从头设计的芯片。早期,NVIDIA 首席执行官黄仁勋注意到了深度学习的兴起,并决定将公司的未来押宝在其上,这带来了巨大的回报。

  • 一系列复杂的软件层,使得这种计算能力对人类可用:CUDA 语言,以及像 TensorFlow、JAX 和 PyTorch 这样的框架,它们执行自动微分,还有 Keras,它使得深度学习对大多数人变得容易。

在未来,深度学习将不仅被研究人员、研究生和具有学术背景的工程师等专业人士使用;它将成为每个开发者的工具箱中的工具,就像今天的网络技术一样。每个人都需要构建智能应用:就像今天每个企业都需要一个网站一样,每个产品都需要智能地理解用户生成数据。实现这一未来将需要我们构建使深度学习极其容易使用且对任何具有基本编码能力的人可用的工具。Keras 已经是在这个方向上的第一步。

通用机器学习工作流程

能够访问一个极其强大的工具,用于创建将任何输入空间映射到任何目标空间的模型,这很好,但机器学习工作流程中的难点通常是在设计和训练这些模型之前(以及对于生产模型,之后)的所有事情。理解问题域,以便确定要尝试预测什么,给定什么数据,以及如何衡量成功,是任何成功应用机器学习的先决条件,这不是像 Keras 和 TensorFlow 这样的高级工具能帮你的。作为提醒,以下是第六章中描述的典型机器学习工作流程的简要总结:

  • 定义问题。 可用哪些数据,你试图预测什么?你需要收集更多数据或雇佣人员手动标记数据集吗?

  • 确定一种可靠地衡量你目标成功的方法。 对于简单任务,这可能只是预测精度,但在许多情况下,它将需要复杂、特定领域的指标。

  • 准备你将用于评估你的模型的验证过程。 特别是,你应该定义一个训练集、一个验证集和一个测试集。验证集和测试集的标签不应泄露到训练数据中:例如,在时间预测中,验证数据和测试数据应在训练数据之后。

  • 通过将其转换为向量和进行预处理(如归一化等)使数据向量化,以便更容易地由神经网络接近。

  • 开发一个能够击败平凡常识基线的第一个模型,从而证明机器学习可以在你的问题上工作。 这可能并不总是如此!

  • *通过调整超参数和添加正则化来逐步细化你的模型架构。仅基于验证数据上的性能进行更改,而不是测试数据或训练数据。记住,你应该让你的模型过度拟合(从而确定一个比所需更大的模型容量级别),然后才开始添加正则化或缩小模型。在调整超参数时要警惕验证集过度拟合——你的超参数可能最终会过度专门化到验证集。避免这一点正是保留单独测试集的目的!

  • 在生产环境中部署你的最终模型——作为一个 Web API,作为 JavaScript 或 C++应用程序的一部分,在嵌入式设备上等。持续监控其在真实世界数据上的性能,并使用你的发现来改进模型的下一版本!

关键网络架构

在阅读完这本书后,你应该熟悉以下网络架构家族:密集连接网络卷积网络循环网络扩散模型变换器。每种模型都针对特定的数据模态:网络架构编码了关于数据结构的假设——一个假设空间,在其中将进行寻找良好模型的搜索。一个给定的架构是否适用于一个给定的问题完全取决于数据结构与网络架构假设之间的匹配。

这些不同的网络类型可以很容易地组合起来以实现更大的多模态模型,就像你组合乐高积木一样。从某种意义上说,深度学习层是信息处理的乐高积木。表 20.1 展示了输入和输出模态之间的映射以及适当的网络架构的快速概述。

输入 输出 模型
向量数据 类别概率,回归值 密集连接网络
时间序列数据 类别概率,回归值 RNN,变换器
图像 类别概率,回归值 卷积神经网络
文本 类别概率,回归值 变换器
文本,图像 文本 变换器
文本,图像 图像 VAE,扩散模型

表 20.1:不同数据类型的模型架构

现在我们快速回顾一下每种网络架构的具体特点。

密集连接网络

密集连接网络是一系列Dense层,旨在处理向量数据(其中每个样本是一个数值或分类属性的向量)。这类网络假设输入特征没有特定的结构:它们被称为密集连接,因为Dense层的单元与其他所有单元相连。该层试图映射任何两个输入特征之间的关系;这与例如 2D 卷积层不同,后者只关注局部关系。

密集连接网络最常用于分类数据(例如,输入特征是属性列表的情况),例如第四章中使用的波士顿房价数据集。它们也用作大多数网络的最终分类或回归阶段。例如,第八章中介绍的卷积神经网络通常以一个或两个Dense层结束,第十三章中的循环神经网络也是如此。

记住,要执行二分类,请在您的层堆栈中添加一个具有单个单元和sigmoid激活的Dense层,并使用binary_crossentropy作为损失。您的目标应该是 0 或 1:

import keras
from keras import layers

inputs = keras.Input(shape=(num_input_features,))
x = layers.Dense(32, activation="relu")(inputs)
x = layers.Dense(32, activation="relu")(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop", loss="binary_crossentropy") 

要执行单标签分类分类(其中每个样本恰好有一个类别,没有更多),请在您的层堆栈中添加一个具有与类别数量相等的单元数的Dense层,并使用softmax激活。如果您的目标是 one-hot 编码,请使用categorical_crossentropy作为损失;如果它们是整数,请使用sparse_categorical_crossentropy

inputs = keras.Input(shape=(num_input_features,))
x = layers.Dense(32, activation="relu")(inputs)
x = layers.Dense(32, activation="relu")(x)
outputs = layers.Dense(num_classes, activation="softmax")(x)
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop", loss="categorical_crossentropy") 

要执行多标签分类分类(其中每个样本可以有多个类别),请在您的层堆栈中添加一个具有与类别数量相等的单元数和sigmoid激活的Dense层,并使用binary_crossentropy作为损失。您的目标应该是 k-hot 编码:

inputs = keras.Input(shape=(num_input_features,))
x = layers.Dense(32, activation="relu")(inputs)
x = layers.Dense(32, activation="relu")(x)
outputs = layers.Dense(num_classes, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop", loss="binary_crossentropy") 

要执行对连续值向量的回归,请在您的层堆栈中添加一个具有与您试图预测的值数量相等的单元数的Dense层,并且没有激活。可以用于回归的损失函数有很多种——最常见的是mean_squared_error(均方误差):

inputs = keras.Input(shape=(num_input_features,))
x = layers.Dense(32, activation="relu")(inputs)
x = layers.Dense(32, activation="relu")(x)
outputs = layers.Dense(num_values)(x)
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop", loss="mse") 

卷积神经网络

卷积层通过将相同的几何变换应用于输入张量中的不同空间位置()来观察空间局部模式。这导致的结果是平移不变性,使得卷积层在数据效率和模块化方面非常高效。这个想法适用于任何维度的空间:1D(连续序列)、2D(图像)、3D(体积)等等。您可以使用Conv1D层处理序列,Conv2D层处理图像,Conv3D层处理体积。作为一种更轻量、更高效的卷积层替代方案,您还可以使用深度可分离卷积层,例如SeparableConv2D

卷积神经网络,或称卷积网络,由一系列卷积和最大池化层组成。池化层允许您在空间上对数据进行下采样,这对于在特征数量增加时保持特征图的大小合理,并允许后续卷积层“看到”更大的输入空间范围是必要的。卷积神经网络通常以Flatten操作或全局池化层结束,将空间特征图转换为向量,然后通过Dense层实现分类或回归。

这是一个典型的图像分类网络(在这种情况下是分类分类)使用SeparableConv2D层:

inputs = keras.Input(shape=(height, width, channels))
x = layers.SeparableConv2D(32, 3, activation="relu")(inputs)
x = layers.SeparableConv2D(64, 3, activation="relu")(x)
x = layers.MaxPooling2D(2)(x)
x = layers.SeparableConv2D(64, 3, activation="relu")(x)
x = layers.SeparableConv2D(128, 3, activation="relu")(x)
x = layers.MaxPooling2D(2)(x)
x = layers.SeparableConv2D(64, 3, activation="relu")(x)
x = layers.SeparableConv2D(128, 3, activation="relu")(x)
x = layers.GlobalAveragePooling2D()(x)
x = layers.Dense(32, activation="relu")(x)
outputs = layers.Dense(num_classes, activation="softmax")(x)
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop", loss="categorical_crossentropy") 

当构建一个非常深的卷积神经网络时,通常还会添加批归一化层以及残差连接——两种有助于梯度信息在网络中平滑流动的架构模式。

Transformers

Transformer 查看一组向量(如词向量)并使用神经注意力将每个向量转换为一个表示,该表示了解集合中其他向量提供的上下文。当所涉及的集合是一个有序序列时,你也可以使用位置编码来创建能够同时考虑全局上下文和词序的 Transformer,这些 Transformer 可以比 RNN 或 1D 卷积神经网络更有效地处理长文本段落。

Transformer 可以用于任何集合处理或序列处理任务,包括文本分类,但它们在序列到序列学习方面特别出色,例如将源语言的段落翻译成目标语言。

序列到序列 Transformer 由两部分组成:

  • 一个TransformerEncoder,它将输入向量序列转换为具有上下文感知和顺序感知的输出向量序列

  • 一个TransformerDecoder,它接受TransformerEncoder的输出以及一个目标序列,并预测目标序列中的下一个应该是什么。

如果你只处理单个向量(或集合)序列,你将只使用TransformerEncoder

以下是一个将源序列映射到目标序列的序列到序列 Transformer(这种设置可以用于机器翻译或问答,例如):

from keras_hub.layers import TokenAndPositionEmbedding
from keras_hub.layers import TransformerDecoder, TransformerEncoder

# Source sequence
encoder_inputs = keras.Input(shape=(src_seq_length,), dtype="int64")
x = TokenAndPositionEmbedding(vocab_size, src_seq_length, embed_dim)(
    encoder_inputs
)
encoder_outputs = TransformerEncoder(intermediate_dim=256, num_heads=8)(x)
# Target sequence so far
decoder_inputs = keras.Input(shape=(dst_seq_length,), dtype="int64")
x = TokenAndPositionEmbedding(vocab_size, dst_seq_length, embed_dim)(
    decoder_inputs
)
x = TransformerDecoder(intermediate_dim=256, num_heads=8)(x, encoder_outputs)
# Predictions for target sequence one step in the future
decoder_outputs = layers.Dense(vocab_size, activation="softmax")(x)
transformer = keras.Model([encoder_inputs, decoder_inputs], decoder_outputs)
transformer.compile(optimizer="adamw", loss="categorical_crossentropy") 

这是一个用于整数序列二分类的独立TransformerEncoder

inputs = keras.Input(shape=(seq_length,), dtype="int64")
x = TokenAndPositionEmbedding(vocab_size, seq_length, embed_dim)(inputs)
x = TransformerEncoder(intermediate_dim=256, num_heads=8)(x)
x = layers.GlobalMaxPooling1D()(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(optimizer="adamw", loss="binary_crossentropy") 

循环神经网络

循环神经网络(RNNs)通过逐个时间步处理输入序列并在整个过程中保持状态(状态通常是向量或向量集)来工作。在感兴趣的模式不是通过时间平移不变的序列(例如,近期过去比遥远过去更重要的时间序列数据)的情况下,应优先使用 1D 卷积神经网络。

Keras 中有三个 RNN 层:SimpleRNNGRULSTM。对于大多数实际用途,你应该使用GRULSTMLSTM是两者中更强大的,但成本也更高;你可以将GRU视为它的一个更简单、更便宜的替代品。

要堆叠多个 RNN 层,每个层在堆栈中的最后一层之前都应该返回其输出的完整序列(每个输入时间步将对应一个输出时间步);如果你没有堆叠任何更多的 RNN 层,那么通常只返回最后一个输出,它包含有关整个序列的信息。

以下是一个用于向量序列二分类的单个 RNN 层:

inputs = keras.Input(shape=(num_timesteps, num_features))
x = layers.LSTM(32)(inputs)
outputs = layers.Dense(num_classes, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop", loss="binary_crossentropy") 

这是一种用于二进制分类向量序列的堆叠 RNN 层:

inputs = keras.Input(shape=(num_timesteps, num_features))
x = layers.LSTM(32, return_sequences=True)(inputs)
x = layers.LSTM(32, return_sequences=True)(x)
x = layers.LSTM(32)(x)
outputs = layers.Dense(num_classes, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop", loss="binary_crossentropy") 

深度学习的局限性

构建深度学习模型就像玩乐高积木:只要你有合适的训练数据,并且映射可以通过合理的连续几何变换实现,层就可以组合起来将本质上任何事物映射到任何事物。

然而,这里有一个问题——这种映射往往无法以通用的方式学习。深度学习模型像庞大的插值模式数据库一样运行。它们的模式匹配能力也是它们的根本弱点:

  • 它们在适应新颖事物方面存在根本性的困难。 由于它们的参数在训练后是固定的,它们只能检索或复制与训练数据相似的图案。面对显著超出这种熟悉分布的输入——无论底层任务多么简单——它们的性能会急剧下降,因为它们缺乏超越记忆经验的流畅泛化机制。这解释了为什么即使是大型模型在新型任务或熟悉问题的简单变化(如 ARC-AGI 任务)上也失败。

  • 它们对措辞和其他干扰因素非常敏感。 深度学习模型对输入呈现的表面变化表现出高度敏感性,例如微小的措辞变化(考虑 LLM 中的提示敏感性)或难以察觉的扰动(考虑视觉中的对抗样本),这表明缺乏稳健、类似人类的理解。

  • 它们通常无法学习可泛化的算法。 深度学习模型的连续、几何性质使它们在本质上不适合学习精确、离散、逐步的算法,例如那些是经典计算机科学的核心。模型通过插值而不是实现稳健、可泛化的程序来近似这些过程。

你应该始终抵制将深度学习模型拟人化的诱惑。它们的性能建立在点 wise 统计模式上,而不是类似人类的经验基础上,这使得它们在遇到训练数据之外的偏差时变得脆弱。

简单地扩大模型规模和训练数据就能导致通用智能的叙述已被证明是不充分的。虽然扩大规模增强了在相当于记忆测试的基准测试上的性能,但它未能解决深度学习的根本局限性,这些局限性源于将静态、插值曲线拟合到数据的核心范式。五年来的指数级扩大基础 LLM 并没有克服这些限制,因为底层方法保持不变。

到 2024 年,这一认识推动了向测试时适应(TTA)的转变,其中模型在推理阶段进行搜索或微调以适应新问题。虽然 TTA 方法已经取得了重大突破,例如 OpenAI 的 o3 在 2024 年底在 ARC-AGI-1 上超越了人类基线,但这种性能是以极端的计算成本为代价的。高效、类似人类的适应仍然是一个完全未解决的问题,而稍微困难的 ARC-AGI-2 基准至今仍未解决。我们仍然需要进一步的概念进步,而不仅仅是扩展或蛮力搜索。

前方可能是什么

解决类似人类流畅智能(和 ARC-AGI-2)需要超越当前方法固有的局限性。虽然深度学习在价值中心抽象方面表现出色,这有助于模式识别和直觉,但它本质上缺乏程序中心抽象的能力,这是离散推理、规划和因果理解的基础。人类智能无缝地整合了这两者——未来的 AI 必须做到同样。

未来可能的关键发展可能包括

  • 混合模型 — 未来的模型可能会将学习到的算法模块(提供推理和符号操作)与深度学习模块(提供感知和直觉)集成。这些系统可能会学习动态地使用编程原语,如控制流、变量、递归和复杂的数据结构。

  • 深度学习引导的程序搜索 — 程序综合——自动发现满足规格的可执行代码——为程序中心抽象提供了一条途径。然而,它对低效的离散搜索的依赖是一个主要瓶颈。一个关键进步将是使用深度学习来引导这一搜索,利用关于程序结构的直觉来有效地导航程序的大量组合空间,就像人类开发者使用经验和直觉来缩小他们的选择范围一样。

  • 模块化重组和终身学习 — 我们将远离从头开始训练的单一、端到端模型。相反,未来的 AI 系统将使用大量可重用、模块化的组件库,这些组件可以在许多问题之间重新部署,并从经验中获取。这些库将包括“几何”(基于深度学习)和“算法”模块。面对新问题时,这样的 AI 系统将检索相关模块,并将它们动态地重新组合成适应当前情况的新模型。每当系统在问题解决循环中作为副产品开发出可重用组件时,新组件就会被添加到库中,可供系统未来可能遇到的每个任务使用。

最终,开发出类似人类流畅智能的 AI 需要将连续模式识别与离散、符号程序相结合,并完全接受即时适应的范式。

在快速发展的领域中保持最新

在此书的最后一页翻过之后,我想给您一些建议,关于如何继续学习和更新您的知识和技能。正如我们所知,现代深度学习领域,尽管有着几十年的漫长而缓慢的史前时期,但至今只有几年历史。自 2013 年以来,随着资金和研究人员的指数级增长,整个领域现在正以极快的速度发展。您在这本书中学到的知识不会永远相关,而且这也不是您整个职业生涯所需的所有知识。

幸运的是,有很多免费的在线资源供您使用,以保持最新状态并拓宽您的视野。以下是一些资源。

使用 Kaggle 进行实战练习

获取实战经验的有效方法之一是尝试在 Kaggle(kaggle.com)上参加机器学习竞赛。唯一真正学习的方法是通过实践和实际编码——这正是本书的哲学,而 Kaggle 竞赛是这一哲学的自然延续。在 Kaggle 上,您会发现一系列不断更新的数据科学竞赛,其中许多涉及深度学习,由对获得他们最复杂机器学习问题新解决方案感兴趣的公司准备。为顶尖参赛者提供了相当大的现金奖励。

通过参加几场比赛,也许作为团队的一部分,您将更加熟悉书中描述的一些高级最佳实践的实际应用,特别是超参数调整、避免验证集过拟合和模型集成。

在 arXiv 上了解最新的发展

与其他一些科学领域相比,深度学习研究完全在公开中进行。论文在完成之后立即公开和免费提供,许多相关软件也是开源的。arXiv(arxiv.org)——发音为“archive”(X 代表希腊字母χ)——是一个开放获取的预印本服务器,用于物理、数学和计算机科学研究论文。它已成为了解机器学习和深度学习前沿的既定方式。大多数深度学习研究人员在完成论文后不久就会将其上传到 arXiv。这允许他们在等待会议接受(可能需要几个月)之前就树立一个旗帜并宣布一个特定的发现,这在研究速度快和领域竞争激烈的背景下是必要的。这也使得该领域能够以极快的速度发展:所有新的发现都立即对所有人和所有人可见,并在此基础上进行构建。

一个重要的缺点是,每天在 arXiv 上发布的论文数量巨大,以至于甚至无法浏览它们,而且它们未经同行评审,这使得很难识别出那些既重要又高质量的论文。在噪声中找到信号具有挑战性,而且这种挑战性正在日益增加。但一些工具可以帮助:特别是,你可以使用 Google Scholar (scholar.google.com)来跟踪你最喜欢的作者发表的论文。

探索 Keras 生态系统

截至 2025 年初,Keras 已有超过 250 万用户,并且仍在增长,Keras 拥有庞大的教程、指南和相关开源项目生态系统:

最后的话

这就是《Python 深度学习》的结束!我希望你已经学到了一些关于机器学习、深度学习、Keras,甚至是一般认知的知识。学习是一个终身的旅程,尤其是在人工智能领域,我们手头的未知远多于确定性。所以请继续学习、质疑和研究。永远不要停止。因为即使到目前为止已经取得的进步,人工智能中的许多基本问题仍然没有答案。许多问题甚至还没有得到适当的提出。

脚注

  1. 理查德·费曼,访谈,从另一个角度看世界,约克郡电视台,1972 年 [↩]

  1. [4] ↩︎

posted @ 2025-12-08 20:08  绝不原创的飞龙  阅读(17)  评论(0)    收藏  举报