机器学习基础第二部分:多元线性回归Draft

欢迎来到我的C语言机器学习基础系列的第二部分!如果你还没有看过,请先阅读第一部分 ——在本文中,我将假设你已经熟悉了第一部分中的概念。

超越单一变量

我们的第一个机器学习模型,单变量线性回归模型,从技术上讲确实是机器学习,但如果我们诚实面对自己,它并不那么令人印象深刻。那么,经历所有这些麻烦的意义何在?为了为更复杂的模型奠定基础,比如我们今天要探讨的多变量线性模型。

多元线性模型

多元线性模型(Multivariate Linear Model, MLM)本质上与其单变量对应模型相同,只是它允许我们使用多个输入变量来影响输出。在上一部分中,我们尝试仅根据房屋面积来预测房价。我们都知道,阿肯色州一套5000平方英尺的房子比圣何塞的便宜得多,但我们的模型会为两者输出相同的价格。

正是在这里,我们意识到需要让模型能够混合处理不同类别的变量,每个变量可能对预测产生不同的影响。

## 问题陈述

首先,让我们定义我们的问题:

我们有以下输入变量的数据及其对应的房屋价格 (以千美元为单位):

:房屋面积(以 为单位)

:地块宽度(以英尺为单位)

:地块深度(以英尺为单位)

:距离海岸的距离(以英里为单位)

给定一组新的数据,预测房屋的价格。

我们知道,这些因素中的每一个都会对价格产生影响。其中一些(如房屋面积和距离海岸的距离)会有非常大的影响,而其他因素的影响则较小。

那么,我们如何将这些关系表达为一个模型呢?让我们尝试通过查看一些图表并进行估算来构建一个模型。

### 图表:面积与价格的关系
Loading...

我们可以看到,面积与价格之间存在正相关关系。趋势线由 建模得出。

图表:地块宽度 vs. 价格

Loading...

这里也存在略微的正相关关系。趋势线为

### 图表:地块深度与价格
Loading...

与地块宽度相比,这里呈现出更强的正相关关系,表明购房者更看重较深的地块形状。趋势线为

图表:离岸距离与价格的关系

Loading...

正如我们所预期的,离岸越远,价格越低。我们还观察到,随着离岸距离的增加,这种影响逐渐减弱。趋势线为

模型的初步猜测

趋势线告诉我们每个特征与房价之间的大致关系。我们可以将这些趋势线结合起来,得到一个近似的数据模型。让我们尝试将所有趋势线相加,看看是否能得到一个合理的模型。

直观上,这是什么意思?我们有一个基础值(偏差)为650万美元,这代表了一个面积为0平方英尺、没有地块、位于海边的房子的理论价值。等等……这听起来不太对。我们可能应该取y轴截距的平均值,因为每个图都与其他图重叠。新模型:

好的……现在是160万美元。但这可能是合理的,因为它正好位于海边。模型的其他部分似乎也说得通。它表明:

每增加1单位 房价变化
面积 340美元
地块宽度 3300美元
地块深度 5840美元
离海岸距离 -8070美元

虽然合理,但这些只是基于一些粗略数学的假设。要真正解决我们的问题,我们需要找到一个最优的MLM,能够根据这4个特征预测房价。用数学术语来说,我们需要找到权重 和偏差 ,使得

能够以最小误差预测房价。

代码中的多元线性模型

就像在第一部分中一样,让我们定义我们的模型。由于我们有多个权重,我们需要使用一个 w 的数组。

struct mlinear_model {
	int num_weights;
	double *w;
	double b;
};

现在,你可能已经能够提前想到几步,意识到如果我们将 w 表示为一个向量,x 也表示为一个向量,那么我们可以将模型的输出表示为 的点积,再加上

啊,这样更简洁了吧?不过,这种简洁不会持续太久……

我在这里再进一步,将我们的 维向量表示为一个 的矩阵。

你很快就会看到这样做的好处。让我们定义我们的 matrix 结构体。

// matrix.h

// 这样我们以后可以轻松地将 `double` 替换为其他浮点类型
typedef double mfloat;

typedef struct {
	// 行优先
	mfloat *buf;
	int rows;
	int cols;
} matrix;

现在,模型看起来像这样:

// main.c
struct mlinear_model {
	matrix w;
	mfloat b;
};

注意,num_weights 现在存储在矩阵中。

由于我们切换到了矩阵,我们需要使用矩阵的点积而不是向量的点积。我添加了一些注释来解释代码,以防你需要复习一下线性代数。

// matrix.h

// 获取矩阵中的元素
mfloat
matrix_get(matrix m, int row, int col)
{
    return m.buf[row * m.cols + col];
}
// 设置矩阵中的元素
void
matrix_set(matrix m, int row, int col, mfloat val)
{
    m.buf[row * m.cols + col] = val;
}

// out = m1 点积 m2
void
matrix_dot(matrix out, const matrix m1, const matrix m2)
{
	// 在第一个矩阵的第 i 行
    for (int row = 0; row < m1.rows; row++) {
		// 在第二个矩阵的第 j 列
        for (int col = 0; col < m2.cols; col++) {
			// 运行向量点积并将结果放入 out[i][j]
            mfloat sum = 0.0;
			// m1.cols == m2.rows,所以 k 遍历所有元素
            for (int k = 0; k < m1.cols; k++) {
                mfloat x1 = matrix_get(m1, row, k);
                mfloat x2 = matrix_get(m2, k, col);
                sum += x1 * x2;
            }
            matrix_set(out, row, col, sum);
        }
    }
}

首先,让我们注意矩阵 之间的点积的以下性质:

  1. 只有当 X.cols == W.rows 时,才能计算点积。
  2. 结果矩阵的维度为 (X.rows, W.cols)

在这种情况下,

因此, 需要是一个行向量,而 需要是一个列向量,这样我们才能复现向量点积的行为。

你可以将矩阵点积理解为将第二个矩阵中的每一列翻转,然后与第一个矩阵中的行进行点积。

看一下代码,验证上面的乘法是否会返回所声称的结果。

现在,我们有了编写预测函数的工具。

// mlr.c

// x 是一个行向量,或者 (1 x n) 矩阵
mfloat predict(struct mlinear_model model, const matrix x) {
	// (1 x n) . (n x 1) => (1 x 1) 矩阵,即一个数字
	mfloat result[1][1] = {0.0};
	matrix tmp = {.buf = result, .rows = 1, .cols = 1};
	// 将 tmp 设置为结果
	matrix_dot(tmp, x, model.w);
	return tmp.buf[0] + model.b;
}

优化模型

我们有两个数组:输入数据,格式为 的矩阵。

每一行代表一个样本,每列中的值是上述描述的特征。

以及对应的房价 ,单位为千美元。

我们有 100 个数据样本,因此 。我将这些数据存储在 data.h 中。

// data.h

#define NUM_FEATURES 4
#define NUM_SAMPLES 100

static mfloat X_data[NUM_SAMPLES][NUM_FEATURES] = { /* 数据省略 */ };

static mfloat Y_data[NUM_SAMPLES] = { /* 数据省略 */ };

static matrix X = {.buf = (mfloat *)X_data,
                   .rows = NUM_SAMPLES,
                   .cols = NUM_FEATURES};

static matrix Y = {
    .buf = (mfloat *)Y_data, .rows = NUM_SAMPLES, .cols = 1};

现在,是优化的时候了!

这意味着我们需要找到参数 的模型,使得所有数据样本的误差最小化。但什么是我们的误差?我们实际上可以使用第 1 部分中的相同定义,因为我们仍然在比较两个数字

这是平方差之和的均值,或预期值与实际 值之间的平均平方差。这个值越接近 ,我们的模型就越好。让我们用 来重写

这里, 指的是 的第 行。 是第 个样本与权重的点积。加上偏置 后,我们得到 ,即预测值。

在代码中:

// 返回一个表示矩阵 m 第 i 行的单行矩阵
matrix matrix_get_row(matrix m, int i) {
  return (matrix){
      .buf = m.buf + i * m.cols, .rows = 1, .cols = m.cols};
}

mfloat cost(struct mlinear_model *model, matrix X,
                    matrix Y) {
  mfloat cost = 0.0;
  for (int i = 0; i < X.rows; i++) {
    matrix x_i = matrix_get_row(X, i);
    mfloat f_wb = predict(model, x_i);
    mfloat diff = matrix_get(Y, 0, i) - f_wb;
    cost += diff * diff;
  }
  return cost / (2.0 * X.rows);
}

猜测的效果如何?

让我们回到我们的猜测,看看它的表现如何。

int main() {
  matrix W = matrix_new(X.cols, 1);
  struct mlinear_model model = {.W = W, .b = 0.0};
  printf("Cost of zero model: %f\n", cost(&model, X, Y));
  matrix_set(W, 0, 0, 0.34);
  matrix_set(W, 1, 0, 3.3);
  matrix_set(W, 2, 0, 5.84);
  matrix_set(W, 3, 0, -8.07);
  model.b = 1634.5;
  printf("Cost of guesstimate model: %f\n",
         cost(&model, X, Y));
}

输出:

Cost of zero model:        3340683.781483
Cost of guesstimate model: 2641267.466911

谁能想到呢!它的表现比随机猜测稍微好一些。回到梯度下降。

让我们引入微积分。我们需要对 关于 求导,以便知道应该朝哪个方向,以及以什么幅度移动权重和偏置来减少误差。

很简单,对吧?对于 来说就没那么简单了。由于 是一个矩阵, 中的每个权重对输出都有单独的影响。这意味着我们需要为每个权重 计算单独的导数。

那个 项来自于对 的求导。对于给定的权重 和行 ,会有一个类似于 的项。由于 是关于 的偏导数中的常数系数,根据链式法则,它会被提取出来。

既然我们已经用矩阵的方式来思考,让我们写出 关于整个矩阵 的导数。

这样简化得很好!你可能开始明白为什么我们在机器学习中喜欢矩阵了。这是一种思考批量计算的好方法。

现在,来看代码。

struct grad {
	matrix dJ_dW;
	mfloat dJ_db;
};

/* 计算梯度并将结果写入 out。 */
void compute_gradient(struct grad *out,
                      struct mlinear_model *model, const matrix X,
                      const matrix Y) {
  int m = X.rows;  // 样本数量
  int n = X.cols;  // 特征数量

  // 使用 tmp 存储 X 的每一行
  matrix tmp = matrix_new(1, n);
  for (int i = 0; i < m; i++) {
    // tmp = X^(i)
    matrix curr_row = matrix_get_row(X, i);
    // y_hat = (X^(i) dot W) + b
    mfloat y_hat = predict(model, curr_row);
    // yi = y^(i)
    mfloat yi = matrix_get(Y, 0, i);
    // 括号中的项
    mfloat err = y_hat - yi;

    /*
     * 对于 dJ_dW,我们需要将误差乘以当前行,并将其加到累加和中
     */

    // tmp = X^(i) * (y_hat^(i) - y^(i))
    matrix_scalar_mul(tmp, curr_row, err);
    // dJ_dW += tmp
    matrix_ip_T_add(out->dJ_dW, tmp);

    // dJ_db += (y_hat^(i) - y^(i))
    out->dJ_db += err;
  }

  /*
   * 我将把 2/m 替换为 1/m,因为 2 可以在下一步中移到 alpha 中。
   */

  // dJ/db = (dJ/db) / m
  out->dJ_db /= m;
  // dJ/dW = (dJ/dW) / m
  matrix_scalar_ip_mul(out->dJ_dW, 1.0 / m);
  matrix_del(tmp);
}

注意:矩阵函数中的 ip 表示结果被赋值给第一个参数。否则,结果被赋值给作为第一个参数传入的缓冲区。

新的 matrix_* 函数相对简单,因此我将省略代码以缩短文章。事实上,这些函数是由 #define 宏 生成的,因此仓库中甚至没有完整的源代码。请期待第 2.5 部分,它将详细介绍这一点!

我鼓励你仔细阅读代码,并验证它是否与数学公式匹配。

现在我们有了梯度,实现梯度下降就很简单了。回顾一下第 1 部分中的算法:

重复直到收敛:

  1. 设置初始权重和偏置值:
  2. 将变量朝导数的相反方向移动,步长为
  3. 转到步骤 2。

在代码中:

void gradient_descent(struct mlinear_model *model, const matrix X,
                      const matrix Y, const int num_iterations,
                      const mfloat alpha) {
  // 可重用的梯度缓冲区
  int n = X.cols, m = X.rows;
  matrix dJ_dW = matrix_new(n, 1);
  struct grad tmp_grad = {.dJ_dW = dJ_dW, .dJ_db = 0.0};

  for (int i = 0; i < num_iterations; i++) {
    // 记录进度
    if (i % (num_iterations >> 4) == 0) {
      printf("\tCost at iteration %d: %f\n", i,
             compute_cost(model, X, Y));
    }
    // tmp_grad = 当前模型的梯度
    compute_gradient(&tmp_grad, model, X, Y);
    // dJ/dW *= -alpha
    matrix_scalar_ip_mul(tmp_grad.dJ_dW, -alpha);
    // W += dJ/dW
    matrix_ip_add(model->W, tmp_grad.dJ_dW);
    // b += -alpha * dJ/db
    model->b += -alpha * tmp_grad.dJ_db;
  }
  matrix_del(dJ_dW);
}

就这样!现在我们在数据上运行程序,看看我们的成本如何改善:

int main() {
  // 超参数
  const int num_iterations = 1e7;
  const mfloat alpha = 1e-8;

  int n = X.cols, m = X.rows;
  matrix W = matrix_new(n, 1);
  struct mlinear_model model = {.W = W, .b = 0.0};

  printf("Initial cost: %f\n", compute_cost(&model, X, Y));
  gradient_descent(&model, X, Y, num_iterations, alpha);
  printf("Final cost: %f\n", compute_cost(&model, X, Y));
  printf("Model parameters:\n");
  matrix_print(model.W);
  printf(" b=%f\n", model.b);
}

输出:

Initial cost: 3340683.781483
        Cost at iteration 0: 3340683.781483
        Cost at iteration 625000: 161369.409722
        Cost at iteration 1250000: 161332.630253
        Cost at iteration 1875000: 161315.326515
        Cost at iteration 2500000: 161298.041198
        Cost at iteration 3125000: 161280.768143
        Cost at iteration 3750000: 161263.507342
        Cost at iteration 4375000: 161246.258784
        Cost at iteration 5000000: 161229.022462
        Cost at iteration 5625000: 161211.798366
        Cost at iteration 6250000: 161194.586488
        Cost at iteration 6875000: 161177.386819
        Cost at iteration 7500000: 161160.199351
        Cost at iteration 8125000: 161143.024075
        Cost at iteration 8750000: 161125.860983
        Cost at iteration 9375000: 161108.710065
Final cost: 161091.571313
Model parameters:
W=[ 2.1185e-01 ]
[ 5.8627e+00 ]
[ 4.5904e+00 ]
[ -1.1702e+01 ]

 b=5.310395

太棒了!我们可以看到,每次迭代后成本都在下降。最终的模型如下:

优化优化器

你可能已经注意到,即使在梯度下降的第一千万次迭代中,成本仍在下降。为什么找到最优参数如此困难,尤其是当我们只有4个权重需要优化时?

很大程度上是由于特征的分布差异。看看这个箱线图:

Loading...

我们可以看到,平方英尺的权重需要比其他变量的权重移动更多。然而,当我们更新每个权重时,步长 是相同的。这意味着在其他3个特征权重收敛后,我们将不得不等待很长时间才能让平方英尺权重收敛。

一种解决方案是选择与分布成比例的 值,并根据相应的 移动每个权重。但这意味着需要存储另一个 值数组,这对于大型模型来说是昂贵的。

更好的解决方案是实际修改我们的输入数据,使它们具有相似的分布。我们可以通过将它们拟合到正态分布来实现这一点。首先,我们计算每个特征的均值。

然后,我们计算每个特征的分布,即标准差。

最后,我们对其进行归一化

通过减去每列的均值,数据被居中,使得分布在两侧相似。除以 使得每列的分布大致相同。让我们用矩阵操作来表示这一点:

// 归一化输入数据 `X`
void z_score_normalize(matrix X) {
  int n = X.cols, m = X.rows;
  // 用于存储 mu 的缓冲区
  matrix mean = matrix_new(1, n);
  // 用于存储 sigma 的缓冲区
  matrix stdev = matrix_new(1, n);

  // 计算 mu
  for (int i = 0; i < NUM_SAMPLES; i++) {
    matrix_ip_add(mean, matrix_get_row(X, i));
  }
  matrix_scalar_ip_mul(mean, 1.0 / NUM_SAMPLES);

  // 计算 sigma
  matrix buf = matrix_new(1, n);
  for (int i = 0; i < NUM_SAMPLES; i++) {
    matrix row = matrix_get_row(X, i);
    matrix_sub(buf, mean, row);
    matrix_ip_square(buf);
    matrix_ip_add(stdev, buf);
  }
  matrix_ip_sqrt(stdev);

  // 计算 Z
  for (int i = 0; i < NUM_SAMPLES; i++) {
    matrix row = matrix_get_row(X, i);
    matrix_ip_sub(row, mean);
    matrix_ip_div(row, stdev);
  }
}

我们的归一化数据看起来如何?

Loading...

我们可以看到,现在所有特征的分布都在相同的尺度上。这如何提高我们的性能?

int main() {
  int n = X.cols, m = X.rows;
  z_score_normalize(X);
  matrix W = matrix_new(n, 1);
  struct mlinear_model model = {.W = W, .b = 0.0};
  printf("初始成本: %f\n", compute_cost(&model, X, Y));
  const int num_iterations = 1e7;
  const mfloat alpha = 1e-8;
  gradient_descent(&model, X, Y, num_iterations, alpha);
  printf("最终成本: %f\n", compute_cost(&model, X, Y));

  printf("模型参数:\nW=");
  matrix_print(model.W);
  printf(" b=%f\n", model.b);
}

输出:

初始成本: 3340683.781483
        迭代 0 的成本: 3340683.781483
        迭代 625000 的成本: 3305547.933747
        迭代 1250000 的成本: 3270852.031317
        迭代 1875000 的成本: 3236590.554988
        迭代 2500000 的成本: 3202758.054240
        迭代 3125000 的成本: 3169349.146943
        迭代 3750000 的成本: 3136358.518493
        迭代 4375000 的成本: 3103780.920969
        迭代 5000000 的成本: 3071611.172296
        迭代 5625000 的成本: 3039844.155415
        迭代 6250000 的成本: 3008474.817473
        迭代 6875000 的成本: 2977498.169013
        迭代 7500000 的成本: 2946909.283181
        迭代 8125000 的成本: 2916703.294939
        迭代 8750000 的成本: 2886875.400289
        迭代 9375000 的成本: 2857420.855511
最终成本: 2828334.976402
模型参数:
W=[ 8.3053e+00 ]
[ 2.4385e+00 ]
[ 6.0186e+00 ]
[ -2.2844e+00 ]

 b=227.136534

它甚至更慢了!?但等等——当我们把 改为 时,看看会发生什么,这原本会导致我们的旧模型发散。

初始成本: 3340683.781483
        迭代 0 的成本: 3340683.781483
        迭代 625000 的成本: 136947.544865
        迭代 1250000 的成本: 136947.544865
        迭代 1875000 的成本: 136947.544865
        迭代 2500000 的成本: 136947.544865
        迭代 3125000 的成本: 136947.544865
        迭代 3750000 的成本: 136947.544865
        迭代 4375000 的成本: 136947.544865
        迭代 5000000 的成本: 136947.544865
        迭代 5625000 的成本: 136947.544865
        迭代 6250000 的成本: 136947.544865
        迭代 6875000 的成本: 136947.544865
        迭代 7500000 的成本: 136947.544865
        迭代 8125000 的成本: 136947.544865
        迭代 8750000 的成本: 136947.544865
        迭代 9375000 的成本: 136947.544865
最终成本: 136947.544865
模型参数:
W=[ 8.1088e+03 ]
[ 8.1764e+02 ]
[ 6.6840e+02 ]
[ -3.6835e+03 ]

 b=2364.131545

哇!在第一次日志中,它已经收敛到最小值!实际上,使用 ,我们只需要大约 次迭代就可以收敛,而不是 次!因此,我们可以看到,使用z-score归一化可以显著加快梯度下降的速度,尤其是在特征分布差异较大时。

如果你注意到新的 与原始值相差甚远,那是因为我们从根本上改变了输入数据。我们现在基于特征样本的偏差大小进行建模,而不是其绝对值。

结论

以上就是多元线性回归的全部内容。这无疑是本系列中最难的一篇文章,所以不必担心未来的内容!所有代码都可以在这里 找到。我鼓励你运行并修改其行为。

如果你有任何问题或意见,欢迎留言或给我发邮件。

✦ No LLMs were used in the ideation, research, writing, or editing of this article.