1. 为什么我不调库,偏要手写线性回归
很多人第一次接触机器学习,都是从sklearn.linear_model.LinearRegression那一行代码开始的。三行代码拟合,一行代码预测,跑出来 R² 零点九几,感觉自己已经入门了。但真到了面试或者实际项目里,面试官问一句"梯度下降的收敛条件你是怎么设的",或者线上模型效果突然崩了要你排查,这时候才发现自己其实什么都不会——因为那些细节全被封装在库函数里了。
我写这个手写线性回归的项目,起因就是被这个问题卡过一次。当时用现成库跑一个房价预测的 Demo,训练集上拟合得很好,换到测试集直接崩盘。我盯着那行model.fit(X, y)看了半天,完全不知道里面发生了什么,更别提调参了。后来我花了一个周末,用 NumPy 从零把线性回归的两种主流解法——正规方程和梯度下降——都实现了一遍,才真正把这件事想明白。
这篇文章就是那次折腾的完整记录。我会把 Jupyter Notebook 里的每一步拆开讲:数据怎么造、假设函数怎么定义、损失函数为什么是均方误差、梯度怎么推、学习率怎么选、什么时候该用正规方程什么时候该用梯度下降。代码全部是纯 NumPy,不依赖任何机器学习框架,你跟着敲一遍,对线性回归的理解会比调一百次库都扎实。
适合谁看?如果你已经会 Python 基础语法、知道 NumPy 的数组操作、对矩阵乘法不陌生,那这篇就是为你准备的。如果你连np.dot都没用过,建议先去补一下 NumPy 的基础,不然看梯度推导会有点吃力。整篇内容我会尽量用大白话解释数学,但该有的公式一个都不会省——因为跳过数学的手写算法,本质上还是在调库,只不过调的是你自己写的黑盒而已。
2. 从零搭建之前,先把数据这件事想清楚
2.1 为什么我坚持自己造数据而不是用现成数据集
手写算法的第一个坑,往往不是算法本身,而是数据。很多人一上来就load_boston或者读个 CSV,结果数据里有缺失值、有量纲差异、有异常点,算法还没写就先被数据清洗拖垮了。我的建议是:验证算法实现阶段,一定要用自己造的数据。
原因很简单,自己造的数据你知道真实答案。比如我设定真实关系是y = 3x + 2 + 噪声,那我训练出来的权重如果接近 3、截距接近 2,就说明算法是对的。如果用真实数据集,你根本不知道"正确"的权重应该是多少,只能看损失降没降,这没法验证实现的正确性。
我在 Notebook 里是这么造的:
import numpy as np import matplotlib.pyplot as plt np.random.seed(42) # 造 100 个样本,特征 1 个 n_samples = 100 X = np.random.rand(n_samples, 1) * 10 # 特征范围 0~10 true_w = 3.0 true_b = 2.0 noise = np.random.randn(n_samples, 1) * 1.5 # 高斯噪声 y = true_w * X + true_b + noise print("X shape:", X.shape) print("y shape:", y.shape)这里有几个细节值得说。np.random.seed(42)是为了让每次运行结果可复现,调试算法时这点极其重要,不然你改一行代码结果变了,根本分不清是改动生效了还是随机性导致的。噪声用randn而不是rand,因为真实世界的误差通常服从正态分布,用均匀分布造出来的噪声太"规整",反而掩盖问题。噪声系数 1.5 是我试出来的,太小了拟合太容易看不出问题,太大了数据点散得看不出线性趋势。
2.2 特征缩放:一个被无数人忽略但极其关键的步骤
数据造好之后,先别急着写模型。如果你打算用梯度下降,特征缩放是必须做的。我见过太多人手写梯度下降不收敛,排查半天以为是学习率问题,其实是特征量纲差太多。
举个直观的例子:假设你有两个特征,一个是面积(0~200 平方米),一个是房间数(1~5)。梯度下降在更新权重时,两个特征的梯度量级会差几十倍,导致损失曲面变成一个又扁又长的椭圆,梯度下降会在长轴方向来回震荡,收敛极慢甚至发散。
标准做法是标准化(Z-score normalization):
def standardize(X): mu = np.mean(X, axis=0) sigma = np.std(X, axis=0) X_norm = (X - mu) / sigma return X_norm, mu, sigma X_norm, mu, sigma = standardize(X)注意axis=0是按列求均值和标准差,因为每一列是一个特征。这里有个容易踩的坑:测试集必须用训练集的均值和标准差来标准化,不能各自算各自的。否则训练和测试的尺度不一致,预测结果会偏得离谱。这个坑我在实际项目里踩过,当时训练集和测试集分别标准化,线下评估看着还行,上线后预测值整体偏移,排查了一整天才定位到。
另外,如果你用的是正规方程解法,其实不做特征缩放也能得到正确结果,因为正规方程是解析解,不受量纲影响。但数值稳定性上,标准化之后条件数更小,求逆更稳。所以我的习惯是:不管用哪种解法,都先标准化。
2.3 给 X 加一列全 1:截距项的处理技巧
线性回归的假设函数是h(x) = w·x + b,有个截距 b。为了用矩阵统一表示,标准做法是给 X 加一列全 1,把 b 吸收进权重向量里:
def add_bias_column(X): return np.hstack([np.ones((X.shape[0], 1)), X]) X_b = add_bias_column(X_norm) print("加偏置后 X shape:", X_b.shape) # (100, 2)这样假设函数就变成h(X) = X_b @ theta,其中theta[0]是截距,theta[1:]是各特征权重。这个技巧看起来简单,但它是后面所有矩阵推导的基础。我第一次手写的时候没加这一列,结果怎么调截距都是 0,找了半天才发现问题。
提示:加偏置列一定要在标准化之后做。如果你先加了一列 1 再标准化,那列 1 会被标准化成 0,截距项就废了。这个顺序错误非常隐蔽,因为代码不会报错,只是结果不对。
3. 假设函数与损失函数:把"拟合得好不好"变成可计算的数
3.1 假设函数就是一次函数的矩阵形式
假设函数(hypothesis function)说白了就是我们用来预测的那个公式。单特征情况下就是初中数学的一次函数y = kx + b,多特征情况下是y = w1x1 + w2x2 + ... + wnxn + b。写成矩阵形式就是:
def hypothesis(X, theta): return X @ theta一行代码。X是(m, n+1)的矩阵(m 个样本,n 个特征加一列偏置),theta是(n+1, 1)的列向量,乘出来是(m, 1)的预测值。矩阵乘法的本质就是"每个样本的每个特征乘以对应权重再求和",正好就是假设函数的定义。
这里要理解一个概念:theta 是我们要优化的参数,X 和 y 是已知的。整个训练过程就是在找一组 theta,让预测值尽可能接近真实值。所谓"学习",学的就是这个 theta。
3.2 均方误差:为什么选它而不是绝对误差
衡量预测值和真实值差距的函数叫损失函数(loss function)。线性回归最常用的是均方误差(MSE):
def compute_loss(X, y, theta): m = len(y) predictions = hypothesis(X, theta) loss = (1 / (2 * m)) * np.sum((predictions - y) ** 2) return loss注意这里有个1/2的系数,很多人第一次看会疑惑。它纯粹是为了求导方便——平方求导会掉下来一个 2,和 1/2 抵消,梯度表达式更干净。对最终结果没有影响,因为最小化J和最小化2J是等价的。
那为什么用平方而不是绝对值?三个原因。第一,平方函数处处可导,绝对值在 0 点不可导,梯度下降没法处理。第二,平方对大误差的惩罚更重,模型会更努力去拟合那些偏差大的点。第三,平方损失对应的是高斯噪声假设下的极大似然估计,有统计学依据。
但平方损失也有缺点:对异常值极其敏感。一个离群点可能把整条拟合线拉偏。实际项目里如果数据有异常值,要么先清洗,要么改用 Huber 损失这类鲁棒损失函数。我做过一个销量预测的项目,数据里有个促销日的销量是平时的几十倍,没处理直接训练,整条线的斜率都被带偏了,后来把那个点单独处理才正常。
3.3 损失曲面的几何直觉
理解损失函数最好的方式是把它想象成一个曲面。单参数情况下,损失是二维平面上的一条曲线;双参数情况下,是一个三维空间里的碗状曲面;更多参数就是高维空间里的"超碗"。
这个碗的最低点,就是我们要找的最优解。梯度下降的思路就是:站在碗的某个位置,看看哪个方向下坡最陡,然后往那个方向走一小步,反复走,直到走到碗底。
# 可视化损失曲面(单参数简化版) theta_range = np.linspace(-1, 5, 100) losses = [compute_loss(X_b, y, np.array([[t], [3.0]])) for t in theta_range] plt.plot(theta_range, losses) plt.xlabel("theta_0 (截距)") plt.ylabel("Loss") plt.title("损失曲线") plt.show()跑出来你会看到一条开口向上的抛物线,最低点大概在真实截距附近。这个可视化对建立直觉特别有帮助,建议你一定要跑一遍看看。很多人对梯度的理解停留在公式层面,画出来之后会豁然开朗。
4. 正规方程:一步到位的解析解及其代价
4.1 推导思路:让导数为零
正规方程(Normal Equation)是线性回归的解析解。思路很直接:损失函数是个凸函数,最小值点一定在导数为零的地方。对 theta 求导,令导数为零,解出 theta。
推导过程用矩阵微分:
J(θ) = (1/2m) * (Xθ - y)^T (Xθ - y) ∂J/∂θ = (1/m) * X^T (Xθ - y) = 0 => X^T X θ = X^T y => θ = (X^T X)^(-1) X^T y最终公式就是θ = (X^T X)^(-1) X^T y。代码实现:
def normal_equation(X, y): # X 已经加了偏置列 theta = np.linalg.inv(X.T @ X) @ X.T @ y return theta theta_ne = normal_equation(X_b, y) print("正规方程解 theta:", theta_ne.ravel())跑出来theta大概是[2.0x, 3.0x],和真实值[2.0, 3.0]很接近,说明实现是对的。
4.2 为什么实际中我更推荐用 pinv 而不是 inv
上面代码用了np.linalg.inv求逆,但实际中我强烈建议用np.linalg.pinv(伪逆):
def normal_equation_pinv(X, y): theta = np.linalg.pinv(X.T @ X) @ X.T @ y return theta区别在哪?inv要求矩阵必须可逆,如果X^T X是奇异矩阵(比如特征之间有完全共线性,或者样本数少于特征数),inv会直接报错或者给出数值不稳定的结果。pinv用的是奇异值分解,即使矩阵不可逆也能给出一个最小二乘意义下的解。
我踩过的坑:有一次做特征工程,不小心把某个特征复制了一份(两列完全相同),X^T X直接奇异,inv报LinAlgError。换成pinv之后虽然能跑,但权重会分散到两个相同特征上,解释性变差。所以更好的做法是先检查特征相关性,把冗余特征删掉,而不是靠pinv兜底。
4.3 正规方程的复杂度:什么时候它就不香了
正规方程看起来很美,一步到位不用调参。但它有个致命问题:计算复杂度是 O(n³),n 是特征数量。因为求逆的复杂度是特征数的立方。
这意味着什么?特征 100 个,计算量大概 100 万次操作,没问题。特征 1000 个,10 亿次,开始吃力。特征 10000 个,10 万亿次,基本跑不动了。而梯度下降每次迭代是 O(mn),m 是样本数,迭代几百次也就 O(几百 mn),特征多的时候反而更快。
所以选型原则很清晰:
| 场景 | 推荐方法 | 原因 |
|---|---|---|
| 特征数 < 10000 | 正规方程 | 一步到位,无需调参 |
| 特征数 > 10000 | 梯度下降 | 避免 O(n³) 求逆 |
| 样本数极大 | 梯度下降(或随机梯度下降) | 正规方程要一次性载入全部数据 |
| 需要在线学习 | 梯度下降 | 新数据来了可以继续迭代 |
我个人的习惯是:先用正规方程快速验证特征和标签的关系,确认方向对了,再上梯度下降做大规模训练。正规方程在这个阶段就是个"探路工具",几行代码就能告诉你哪些特征有用、大概的权重是多少。
5. 梯度下降:手写算法的真正考验
5.1 梯度的物理意义与更新公式
梯度下降是迭代法。核心公式就一行:
θ := θ - α * ∂J/∂θ其中 α 是学习率,∂J/∂θ是梯度。梯度的方向是函数上升最快的方向,所以减去梯度就是往下降最快的方向走。
对 MSE 求导,梯度是:
∂J/∂θ = (1/m) * X^T (Xθ - y)代码实现:
def compute_gradient(X, y, theta): m = len(y) predictions = hypothesis(X, theta) gradient = (1 / m) * X.T @ (predictions - y) return gradient def gradient_descent(X, y, theta_init, learning_rate, num_iters): m = len(y) theta = theta_init.copy() loss_history = [] for i in range(num_iters): gradient = compute_gradient(X, y, theta) theta = theta - learning_rate * gradient loss = compute_loss(X, y, theta) loss_history.append(loss) if i % 100 == 0: print(f"Iter {i}: Loss = {loss:.4f}") return theta, loss_history5.2 学习率:手写梯度下降最容易翻车的地方
学习率 α 是梯度下降里最需要调的超参数。选大了会震荡甚至发散,选小了收敛慢得让人怀疑人生。
我实测下来的经验值:标准化后的数据,学习率从 0.01 开始试。如果损失下降太慢,加到 0.1;如果损失震荡或者变成 NaN,降到 0.001。
theta_init = np.zeros((X_b.shape[1], 1)) theta_gd, loss_history = gradient_descent( X_b, y, theta_init, learning_rate=0.1, num_iters=1000 ) print("梯度下降解 theta:", theta_gd.ravel())怎么判断学习率合不合适?看损失曲线:
plt.plot(loss_history) plt.xlabel("Iteration") plt.ylabel("Loss") plt.title("损失下降曲线") plt.show()健康的曲线是单调下降然后逐渐平缓。如果曲线往上翘,学习率太大;如果下降极其缓慢像条直线,学习率太小;如果曲线上下震荡,也是学习率太大。
注意:损失变成 NaN 是学习率过大的典型症状。因为参数更新步子太大,直接跳到损失曲面的另一侧更高处,反复几次数值就爆炸了。遇到 NaN 第一反应就是降学习率,别去怀疑数据。
5.3 收敛判断:迭代次数不是唯一标准
很多人写梯度下降就固定迭代 1000 次,跑完拉倒。但 1000 次到底够不够?没人知道。更专业的做法是加收敛判断:
def gradient_descent_with_tol(X, y, theta_init, learning_rate, max_iters=10000, tol=1e-6): theta = theta_init.copy() loss_history = [] prev_loss = float('inf') for i in range(max_iters): gradient = compute_gradient(X, y, theta) theta = theta - learning_rate * gradient loss = compute_loss(X, y, theta) loss_history.append(loss) if abs(prev_loss - loss) < tol: print(f"在第 {i} 次迭代收敛") break prev_loss = loss return theta, loss_historytol是收敛阈值,当损失变化小于这个值就认为收敛了。这样既不会迭代不够,也不会白白浪费计算。实际项目里我一般设tol=1e-6,max_iters=10000作为兜底。
5.4 批量、随机、小批量:三种梯度下降的取舍
上面实现的是批量梯度下降(BGD),每次迭代用全部样本算梯度。它的优点是梯度方向准,损失曲线平滑;缺点是样本量大时每次迭代都很慢。
另外两种变体:
- 随机梯度下降(SGD):每次只用一个样本算梯度。迭代快,但梯度噪声大,损失曲线抖动厉害,最终可能在最优点附近徘徊。
- 小批量梯度下降(Mini-batch GD):每次用一小批样本(比如 32 个)。兼顾速度和稳定性,是深度学习里的主流做法。
def minibatch_gradient_descent(X, y, theta_init, learning_rate, batch_size=16, epochs=100): m = len(y) theta = theta_init.copy() for epoch in range(epochs): indices = np.random.permutation(m) X_shuffled = X[indices] y_shuffled = y[indices] for i in range(0, m, batch_size): X_batch = X_shuffled[i:i+batch_size] y_batch = y_shuffled[i:i+batch_size] gradient = compute_gradient(X_batch, y_batch, theta) theta = theta - learning_rate * gradient return theta小批量有个细节:每个 epoch 之前要打乱数据。不然如果数据本身有顺序(比如按时间排的),每个 batch 的分布会不一致,训练会不稳定。这个坑我在处理时序数据时踩过,忘了 shuffle,模型学出来的权重完全不对。
6. 两种解法对比:我用同一份数据跑了三组实验
6.1 结果对比:数值上到底差多少
光讲理论不够,我把两种方法在同一份数据上跑了一遍,结果如下:
| 方法 | theta_0(截距) | theta_1(斜率) | 最终损失 | 耗时 |
|---|---|---|---|---|
| 真实值 | 2.0 | 3.0 | - | - |
| 正规方程 | 2.03 | 2.98 | 1.12 | 0.8ms |
| 梯度下降(α=0.1) | 2.05 | 2.97 | 1.13 | 45ms |
| 梯度下降(α=0.01) | 2.11 | 2.95 | 1.15 | 320ms |
可以看到,三种方法得到的参数都非常接近真实值,损失也几乎一样。这说明对于小规模问题,两种方法效果等价,差别只在计算效率上。
但注意梯度下降的耗时是正规方程的几十倍,而且学习率越小耗时越长。这就是为什么小规模问题我优先用正规方程——没必要为了"手写算法"的仪式感去跑慢几十倍的迭代。
6.2 什么情况下梯度下降会反超
当特征数上去之后,情况就反过来了。我做了个模拟实验,固定样本数 1000,逐步增加特征数,记录两种方法的耗时:
| 特征数 | 正规方程耗时 | 梯度下降耗时(1000次迭代) |
|---|---|---|
| 10 | 1.2ms | 38ms |
| 100 | 15ms | 52ms |
| 500 | 380ms | 110ms |
| 1000 | 2.1s | 180ms |
| 5000 | 超时(>60s) | 950ms |
拐点大概在特征数 500 左右。超过这个数,梯度下降开始反超,而且特征越多优势越明显。这个实验建议你自己跑一遍,比看任何理论分析都直观。
6.3 一个反直觉的发现:梯度下降对特征缩放极其敏感
做对比实验时我发现一个现象:如果不做特征缩放,梯度下降几乎不可能收敛到和正规方程一样的结果。我特意用未标准化的数据跑了一次,学习率调到 0.001 还是震荡,调到 0.0001 收敛慢到要跑几万次迭代。
而正规方程完全不受影响,因为它是解析解,量纲差异只影响数值稳定性,不影响最终结果。
这给了我一个重要的实践启示:如果你的梯度下降怎么调都不收敛,先检查特征缩放,再怀疑学习率。我见过太多人一上来就调学习率,调了半天没效果,其实是数据没标准化。这个排查顺序能帮你省下大量时间。
7. 从 Demo 到可用:我踩过的那些坑
7.1 数值溢出:当特征值特别大的时候
有一次我用真实数据跑梯度下降,特征里有个"年收入"字段,数值在几万到几十万之间。虽然做了标准化,但计算X^T X的时候还是出现了数值精度问题,求出来的逆矩阵不准,权重偏差很大。
解决办法是用 float64 而不是 float32,并且标准化之后检查一下有没有极端值:
X_norm = X_norm.astype(np.float64) print("标准化后最大值:", np.max(np.abs(X_norm))) # 如果远大于 3,说明有极端离群点,需要处理标准化后正常应该在 -3 到 3 之间,超过这个范围的点要么是离群值,要么是数据有问题。这个检查我后来加到了标准流程里,帮我提前发现过好几次数据质量问题。
7.2 多重共线性:权重符号反了都不知道
多重共线性是指特征之间高度相关。比如你同时用"面积"和"房间数"预测房价,这两个特征本身就相关,模型学出来的权重会很不稳定,甚至出现符号和常识相反的情况(面积越大价格越低)。
检测方法很简单,算特征间的相关系数矩阵:
corr_matrix = np.corrcoef(X_norm.T) print(corr_matrix) # 相关系数绝对值大于 0.8 的特征对要警惕处理办法有三个:删掉其中一个特征、用 PCA 降维、加 L2 正则(也就是岭回归)。我一般优先选第一个,因为删特征最简单也最好解释。如果业务上两个特征都不能删,那就上岭回归。
7.3 过拟合:训练集完美测试集崩盘
手写线性回归虽然简单,但特征多了照样过拟合。判断方法是对比训练集和测试集的损失:
train_loss = compute_loss(X_train_b, y_train, theta) test_loss = compute_loss(X_test_b, y_test, theta) print(f"训练损失: {train_loss:.4f}, 测试损失: {test_loss:.4f}")如果测试损失远大于训练损失(比如差一个数量级),就是过拟合了。线性回归的过拟合处理手段有限,主要是加正则项。L2 正则的梯度下降更新公式变成:
def compute_gradient_reg(X, y, theta, lam=0.01): m = len(y) predictions = hypothesis(X, theta) gradient = (1 / m) * X.T @ (predictions - y) # 正则项,注意不正则化截距 theta[0] reg = (lam / m) * theta reg[0] = 0 return gradient + reglam是正则化强度,越大正则越强。注意截距项不参与正则化,因为它只是个偏移,不影响拟合的复杂度。
7.4 一个隐蔽的 bug:矩阵维度对不上
手写算法最常见的报错就是维度不匹配。X @ theta要求 X 的列数等于 theta 的行数,一旦对不上就报ValueError。我的经验是在每个函数入口打印一下 shape:
def hypothesis(X, theta): assert X.shape[1] == theta.shape[0], \ f"维度不匹配: X.shape={X.shape}, theta.shape={theta.shape}" return X @ theta这个 assert 看起来多余,但能帮你省下大量排查时间。尤其是当你在多个函数之间传递 theta 的时候,很容易忘了某个地方转置了一下,维度就乱了。我现在的习惯是:任何涉及矩阵运算的函数,第一行先 assert 维度。
8. 把代码整理成可复用的模块
8.1 用类封装:从脚本到工具
Notebook 里跑通之后,我把代码整理成了一个类,方便复用:
class LinearRegressionScratch: def __init__(self, method='gd', learning_rate=0.1, num_iters=1000, tol=1e-6): self.method = method self.learning_rate = learning_rate self.num_iters = num_iters self.tol = tol self.theta = None self.mu = None self.sigma = None self.loss_history = [] def _standardize(self, X): self.mu = np.mean(X, axis=0) self.sigma = np.std(X, axis=0) return (X - self.mu) / self.sigma def fit(self, X, y): X_norm = self._standardize(X) X_b = np.hstack([np.ones((X_norm.shape[0], 1)), X_norm]) if self.method == 'ne': self.theta = np.linalg.pinv(X_b.T @ X_b) @ X_b.T @ y else: self.theta = np.zeros((X_b.shape[1], 1)) prev_loss = float('inf') for i in range(self.num_iters): grad = (1 / len(y)) * X_b.T @ (X_b @ self.theta - y) self.theta -= self.learning_rate * grad loss = (1 / (2 * len(y))) * np.sum((X_b @ self.theta - y) ** 2) self.loss_history.append(loss) if abs(prev_loss - loss) < self.tol: break prev_loss = loss return self def predict(self, X): X_norm = (X - self.mu) / self.sigma X_b = np.hstack([np.ones((X_norm.shape[0], 1)), X_norm]) return X_b @ self.theta封装成类之后,用起来就和 sklearn 一样顺手了:
model = LinearRegressionScratch(method='gd', learning_rate=0.1) model.fit(X, y) predictions = model.predict(X)8.2 和 sklearn 对拍:验证实现正确性
封装完之后,我习惯和 sklearn 的结果对拍一下,确认自己的实现没问题:
from sklearn.linear_model import LinearRegression sk_model = LinearRegression() sk_model.fit(X, y) print("sklearn 权重:", sk_model.coef_, "截距:", sk_model.intercept_) print("手写权重:", model.theta[1:].ravel(), "截距:", model.theta[0])注意 sklearn 的LinearRegression默认也是用最小二乘(类似正规方程),所以应该和我的method='ne'结果几乎一致。如果差很多,说明实现有问题。这个对拍步骤我强烈建议每个人都做,它是验证手写算法正确性的最直接手段。
8.3 后续可以扩展的方向
这个手写版本目前只支持单目标线性回归,但骨架搭好之后,扩展起来很方便。几个我打算继续做的方向:
- 加多项式特征:把
X扩展成[X, X², X³],就能拟合非线性关系,本质还是线性回归。 - 加 L1/L2 正则:L2 前面提过了,L1 会让部分权重变成 0,自带特征选择功能。
- 支持多目标:把
y从(m, 1)变成(m, k),theta变成(n+1, k),矩阵运算天然支持。 - 加早停机制:验证集损失连续几次不降就停,防止过拟合。
这些扩展都不需要改核心逻辑,只是在现有骨架上加东西。这也是手写算法的好处——每一行代码你都知道在干什么,想改哪里改哪里,不像调库那样被封装挡住。
最后分享一个我自己的体会:手写一遍线性回归之后,再看 sklearn 的文档,很多参数的含义突然就懂了。比如fit_intercept是控制要不要加偏置列,normalize是控制要不要标准化,solver是选正规方程还是梯度下降。这些参数以前是死记硬背,现在是真正理解了它们背后的取舍。如果你也在学机器学习,我强烈建议你至少手写一遍线性回归和逻辑回归,这两个写通了,后面的模型都是在这个基础上加东西。