欢迎来到我的C语言机器学习基础系列的第二部分!如果你还没有看过,请先阅读第一部分 ——在本文中,我将假设你已经熟悉了第一部分中的概念。
超越单一变量
我们的第一个机器学习模型,单变量线性回归模型,从技术上讲确实是机器学习,但如果我们诚实面对自己,它并不那么令人印象深刻。那么,经历所有这些麻烦的意义何在?为了为更复杂的模型奠定基础,比如我们今天要探讨的多变量线性模型。
多元线性模型
多元线性模型(Multivariate Linear Model, MLM)本质上与其单变量对应模型相同,只是它允许我们使用多个输入变量来影响输出。在上一部分中,我们尝试仅根据房屋面积来预测房价。我们都知道,阿肯色州一套5000平方英尺的房子比圣何塞的便宜得多,但我们的模型会为两者输出相同的价格。
正是在这里,我们意识到需要让模型能够混合处理不同类别的变量,每个变量可能对预测产生不同的影响。
## 问题陈述
首先,让我们定义我们的问题:
我们有以下输入变量的数据及其对应的房屋价格 (以千美元为单位):
:房屋面积(以 为单位)
:地块宽度(以英尺为单位)
:地块深度(以英尺为单位)
:距离海岸的距离(以英里为单位)
给定一组新的数据,预测房屋的价格。
我们知道,这些因素中的每一个都会对价格产生影响。其中一些(如房屋面积和距离海岸的距离)会有非常大的影响,而其他因素的影响则较小。
那么,我们如何将这些关系表达为一个模型呢?让我们尝试通过查看一些图表并进行估算来构建一个模型。
### 图表:面积与价格的关系
我们可以看到,面积与价格之间存在正相关关系。趋势线由 建模得出。
图表:地块宽度 vs. 价格
这里也存在略微的正相关关系。趋势线为 。
### 图表:地块深度与价格
与地块宽度相比,这里呈现出更强的正相关关系,表明购房者更看重较深的地块形状。趋势线为 。
图表:离岸距离与价格的关系
正如我们所预期的,离岸越远,价格越低。我们还观察到,随着离岸距离的增加,这种影响逐渐减弱。趋势线为 。
模型的初步猜测
趋势线告诉我们每个特征与房价之间的大致关系。我们可以将这些趋势线结合起来,得到一个近似的数据模型。让我们尝试将所有趋势线相加,看看是否能得到一个合理的模型。
直观上,这是什么意思?我们有一个基础值(偏差)为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);
}
}
}
首先,让我们注意矩阵 和 之间的点积的以下性质:
- 只有当
X.cols == W.rows时,才能计算点积。 - 结果矩阵的维度为 (
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 部分中的算法:
重复直到收敛:
- 设置初始权重和偏置值:
- 将变量朝导数的相反方向移动,步长为 :
- 转到步骤 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个权重需要优化时?
很大程度上是由于特征的分布差异。看看这个箱线图:
我们可以看到,平方英尺的权重需要比其他变量的权重移动更多。然而,当我们更新每个权重时,步长 是相同的。这意味着在其他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);
}
}
我们的归一化数据看起来如何?
我们可以看到,现在所有特征的分布都在相同的尺度上。这如何提高我们的性能?
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归一化可以显著加快梯度下降的速度,尤其是在特征分布差异较大时。
如果你注意到新的 和 与原始值相差甚远,那是因为我们从根本上改变了输入数据。我们现在基于特征样本的偏差大小进行建模,而不是其绝对值。
结论
以上就是多元线性回归的全部内容。这无疑是本系列中最难的一篇文章,所以不必担心未来的内容!所有代码都可以在这里 找到。我鼓励你运行并修改其行为。
如果你有任何问题或意见,欢迎留言或给我发邮件。