Run this notebook online:Binder or Colab: Colab

3.2. 线性回归的从零开始实现

在了解线性回归的关键思想之后,我们可以开始通过代码来动手实现线性回归了。 在这一节中,我们将从零开始实现整个方法,包括数据流水线、模型、损失函数和小批量随机梯度下降优化器。虽然现代的深度学习框架几乎可以自动化地进行所有这些工作,但从零开始实现可以确保你真正知道自己在做什么。同时,了解更细致的工作原理将方便我们自定义模型、自定义层或自定义损失函数。在这一节中,我们将只使用NDArrayGradientCollector。在之后的章节中,我们会充分利用 DJL 的优势,介绍更简洁的实现方式。首先,我们导入几个需要的包。

%load ../utils/djl-imports
%load ../utils/plot-utils

3.2.1. 生成数据集

为了简单起见,我们将根据带有噪声的线性模型构造一个人造数据集。 我们的任务是使用这个有限样本的数据集来恢复这个模型的参数。 我们将使用低维数据,这样可以很容易地将其可视化。 在下面的代码中,我们生成一个包含1000个样本的数据集,每个样本包含从标准正态分布中采样的2个特征。我们的合成数据集是一个矩阵 \(\mathbf{X}\in \mathbb{R}^{1000 \times 2}\)

我们使用线性模型参数\(\mathbf{w} = [2, -3.4]^\top\)\(b = 4.2\)和噪声项\(\epsilon\)生成数据集及其标签:

(3.2.1)\[\mathbf{y}= \mathbf{X} \mathbf{w} + b + \mathbf\epsilon.\]

你可以将 \(\epsilon\) 视为捕获特征和标签时的潜在观测误差。在这里我们认为标准假设成立,即\(\epsilon\)服从均值为0的正态分布。 为了简化问题,我们将标准差设为0.01。下面的代码生成合成数据集。

class DataPoints {
    private NDArray X, y;
    public DataPoints(NDArray X, NDArray y) {
        this.X = X;
        this.y = y;
    }

    public NDArray getX() {
        return X;
    }

    public NDArray getY() {
        return y;
    }
}

// Generate y = X w + b + noise
public DataPoints syntheticData(NDManager manager, NDArray w, float b, int numExamples) {
    NDArray X = manager.randomNormal(new Shape(numExamples, w.size()));
    NDArray y = X.dot(w).add(b);
    // Add noise
    y = y.add(manager.randomNormal(0, 0.01f, y.getShape(), DataType.FLOAT32));
    return new DataPoints(X, y);
}

NDManager manager = NDManager.newBaseManager();

NDArray trueW = manager.create(new float[]{2, -3.4f});
float trueB = 4.2f;

DataPoints dp = syntheticData(manager, trueW, trueB, 1000);
NDArray features = dp.getX();
NDArray labels = dp.getY();

注意,features 中的每一行都包含一个二维数据样本,labels 中的每一行都包含一维标签值(一个标量)。

System.out.printf("features: [%f, %f]\n", features.get(0).getFloat(0), features.get(0).getFloat(1));
System.out.println("label: " + labels.getFloat(0));
features: [0.292537, -0.718359]
label: 7.234216

通过生成第二个特征 features[:, 1]labels 的散点图,可以直观地观察到两者之间的线性关系。

float[] X = features.get(new NDIndex(":, 1")).toFloatArray();
float[] y = labels.toFloatArray();

Table data = Table.create("Data")
    .addColumns(
        FloatColumn.create("X", X),
        FloatColumn.create("y", y)
    );

ScatterPlot.create("Synthetic Data", data, "X", "y");

3.2.2. 读取数据集

回想一下,训练模型时要对数据集进行遍历,每次抽取一小批量样本,并使用它们来更新我们的模型。我们可以用ArrayDataset来随机抽取数据集中的样本并以小批量方式获取数据。

在下面的代码中,我们用 featureslabels 来创建一个数据集。通过 setSampling() 函数指定小批量样本的大小为:batchSize. 使用dataset.getData() 函数,我们可以获取一小批量样本,每个小批量包含一组特征和标签。

import ai.djl.training.dataset.ArrayDataset;
import ai.djl.training.dataset.Batch;

int batchSize = 10;

ArrayDataset dataset = new ArrayDataset.Builder()
          .setData(features) // Set the Features
          .optLabels(labels) // Set the Labels
          .setSampling(batchSize, false) // set the batch size and random sampling to false
          .build();

通常,我们使用合理大小的小批量来利用GPU硬件的优势,因为GPU在并行处理方面表现出色。每个样本都可以并行地进行模型计算,且每个样本损失函数的梯度也可以被并行地计算,GPU可以在处理几百个样本时,所花费的时间不比处理一个样本时多太多。

让我们直观感受一下。读取第一个小批量数据样本并打印。每个批量的特征维度说明了批量大小和输入特征数。 同样的,批量的标签形状与 batchSize 相等。

for (Batch batch : dataset.getData(manager)) {
    // Call head() to get the first NDArray
    NDArray X = batch.getData().head();
    NDArray y = batch.getLabels().head();
    System.out.println(X);
    System.out.println(y);
    // Don't forget to close the batch!
    batch.close();
    break;
}
ND: (10, 2) gpu(0) float32
[[ 0.2925, -0.7184],
 [ 0.1   , -0.3932],
 [ 2.547 , -0.0034],
 [ 0.0083, -0.251 ],
 [ 0.129 ,  0.3728],
 [ 1.0822, -0.665 ],
 [ 0.5434, -0.7168],
 [-1.4913,  1.4805],
 [ 0.1374, -1.2208],
 [ 0.3072,  1.1135],
]

ND: (10) gpu(0) float32
[ 7.2342,  5.7411,  9.3138,  5.0536,  3.1772,  8.6284,  7.7434, -3.808 ,  8.6185,  1.0259]

当我们运行迭代时,我们会连续地获得不同的小批量,直至遍历完整个数据集。 上面实现的迭代对于教学来说很好,但它的执行效率很低,可能会在实际问题上陷入麻烦。 例如,它要求我们将所有数据加载到内存中,并执行大量的随机内存访问。在DJL中实现的内置迭代器效率要高得多,它可以处理存储在文件中的数据和通过数据流提供的数据。

3.2.3. 初始化模型参数

在我们开始用小批量随机梯度下降优化我们的模型参数之前,我们需要先有一些参数。 在下面的代码中,我们通过从均值为0、标准差为0.01的正态分布中采样随机数来初始化权重,并将偏置初始化为0。

NDArray w = manager.randomNormal(0, 0.01f, new Shape(2, 1), DataType.FLOAT32);
NDArray b = manager.zeros(new Shape(1));
NDList params = new NDList(w, b);

在初始化参数之后,我们的任务是更新这些参数,直到这些参数足够拟合我们的数据。 每次更新都需要计算损失函数关于模型参数的梯度。有了这个梯度,我们就可以向减小损失的方向更新每个参数。 因为手动计算梯度很枯燥而且容易出错,所以没有人会手动计算梯度。我们使用 Section 2.5 中引入的自动微分来计算梯度。

3.2.4. 定义模型

接下来,我们必须定义模型,将模型的输入和参数同模型的输出关联起来。 回想一下,要计算线性模型的输出,我们只需计算输入特征 \(\mathbf{X}\) 和模型权重\(\mathbf{w}\)的矩阵-向量乘法后加上偏置\(b\)。注意,下面的X.dot(w) 是一个向量,而\(b\)是一个标量。回想一下 subsec_broadcasting 中描述的广播机制。当我们用一个向量加一个标量时,标量会被加到向量的每个分量上。

// Saved in Training.java for later use
public NDArray linreg(NDArray X, NDArray w, NDArray b) {
    return X.dot(w).add(b);
}

3.2.5. 定义损失函数

因为要更新模型。需要计算损失函数的梯度,所以我们应该先定义损失函数。 这里我们使用 Section 3.1 中描述的平方损失函数。 在实现中,我们需要将真实值y的形状转换为和预测值y_hat的形状相同。

// Saved in Training.java for later use
public NDArray squaredLoss(NDArray yHat, NDArray y) {
    return (yHat.sub(y.reshape(yHat.getShape()))).mul
        ((yHat.sub(y.reshape(yHat.getShape())))).div(2);
}

3.2.6. 定义优化算法

正如我们在 Section 3.1 中讨论的,线性回归有解析解。然而,这是一本关于深度学习的书,而不是一本关于线性回归的书。 由于这本书介绍的其他模型都没有解析解,下面我们将在这里介绍小批量随机梯度下降的工作示例。

在每一步中,使用从数据集中随机抽取的一个小批量,然后根据参数计算损失的梯度。接下来,朝着减少损失的方向更新我们的参数。 下面的函数实现小批量随机梯度下降更新。该函数接受模型参数集合、学习速率和批量大小作为输入。每一步更新的大小由学习速率lr决定。 因为我们计算的损失是一个批量样本的总和,所以我们用批量大小(batch_size)来归一化步长,这样步长大小就不会取决于我们对批量大小的选择。

// Saved in Training.java for later use
public static void sgd(NDList params, float lr, int batchSize) {
    for (int i = 0; i < params.size(); i++) {
        NDArray param = params.get(i);
        // Update param
        // param = param - param.gradient * lr / batchSize
        param.subi(param.getGradient().mul(lr).div(batchSize));
    }
}

3.2.7. 训练

现在我们已经准备好了模型训练所有需要的要素,可以实现主要的训练过程部分了。 理解这段代码至关重要,因为在整个深度学习的职业生涯中,你会一遍又一遍地看到几乎相同的训练过程。 在每次迭代中,我们读取一小批量训练样本,并通过我们的模型来获得一组预测。 计算完损失后,我们开始反向传播,存储每个参数的梯度。最后,我们调用优化算法 sgd 来更新模型参数。

概括一下,我们将执行以下循环:

  • 初始化参数

  • 重复,直到完成

    • 计算梯度 \(\mathbf{g} \leftarrow \partial_{(\mathbf{w},b)} \frac{1}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} l(\mathbf{x}^{(i)}, y^{(i)}, \mathbf{w}, b)\)

    • 更新参数 \((\mathbf{w}, b) \leftarrow (\mathbf{w}, b) - \eta \mathbf{g}\)

在每个迭代周期(epoch)中,我们使用 dataset.getData() 函数遍历整个数据集,并将训练数据集中所有样本都使用一次(假设样本数能够被批量大小整除)。这里的迭代周期个数numepochs和学习率lr都是超参数,分别设为3和0.03。设置超参数很棘手,需要通过反复试验进行调整。 我们现在忽略这些细节,以后会在 Section 11 中详细介绍。

float lr = 0.03f;  // Learning Rate
int numEpochs = 3;  // Number of Iterations

// Attach Gradients
for (NDArray param : params) {
    param.setRequiresGradient(true);
}

for (int epoch = 0; epoch < numEpochs; epoch++) {
    // Assuming the number of examples can be divided by the batch size, all
    // the examples in the training dataset are used once in one epoch
    // iteration. The features and tags of minibatch examples are given by X
    // and y respectively.
    for (Batch batch : dataset.getData(manager)) {
        NDArray X = batch.getData().head();
        NDArray y = batch.getLabels().head();

        try (GradientCollector gc = Engine.getInstance().newGradientCollector()) {
            // Minibatch loss in X and y
            NDArray l = squaredLoss(linreg(X, params.get(0), params.get(1)), y);
            gc.backward(l);  // Compute gradient on l with respect to w and b
        }
        sgd(params, lr, batchSize);  // Update parameters using their gradient

        batch.close();
    }
    NDArray trainL = squaredLoss(linreg(features, params.get(0), params.get(1)), labels);
    System.out.printf("epoch %d, loss %f\n", epoch + 1, trainL.mean().getFloat());
}
epoch 1, loss 0.042579
epoch 2, loss 0.000161
epoch 3, loss 0.000052

因为我们使用的是自己合成的数据集,所以我们知道真正的参数是什么。 因此,我们可以通过比较真实参数和通过训练学到的参数来评估训练的成功程度。事实上,真实参数和通过训练学到的参数确实非常接近。

float[] w = trueW.sub(params.get(0).reshape(trueW.getShape())).toFloatArray();
System.out.println(String.format("Error in estimating w: [%f, %f]", w[0], w[1]));
System.out.println(String.format("Error in estimating b: %f", trueB - params.get(1).getFloat()));
Error in estimating w: [-0.000233, -0.000601]
Error in estimating b: 0.000912

注意,我们不应该想当然地认为我们能够完美地恢复参数。 在机器学习中,我们通常不太关心恢复真正的参数,而更关心那些能高度准确预测的参数。 幸运的是,即使是在复杂的优化问题上,随机梯度下降通常也能找到非常好的解。其中一个原因是,在深度网络中存在许多参数组合能够实现高度精确的预测。

3.2.8. 小结

  • 我们学习了深度网络是如何实现和优化的。在这一过程中只使用NDArray和自动微分,不需要定义层或复杂的优化器。

  • 这一节只触及到了表面知识。在下面的部分中,我们将基于刚刚介绍的概念描述其他模型,并学习如何更简洁地实现其他模型。

3.2.9. 练习

  1. 如果我们将权重初始化为零,会发生什么。算法仍然有效吗?

  2. 假设你是 乔治·西蒙·欧姆 ,试图为电压和电流的关系建立一个模型。你能使用自动微分来学习模型的参数吗?

  3. 您能基于 普朗克定律 使用光谱能量密度来确定物体的温度吗?

  4. 如果你想计算二阶导数可能会遇到什么问题?你会如何解决这些问题?

  5. 为什么在 squaredLoss() 函数中需要使用 reshape() 函数?

  6. 尝试使用不同的学习率,观察损失函数值下降的快慢。

  7. 如果样本个数不能被批量大小整除,dataset.getData()函数的行为会有什么变化?