1.KNN用法
1.1 KNN 的基本原理
KNN 的核心思想是:相似的样本,其标签或数值往往也相近。预测时,先计算新样本与训练样本的距离,找出最近的 K 个邻居,再根据邻居的结果进行预测。
KNN 既可以做分类,也可以做回归,区别在于“邻居的结果”如何汇总:
| 任务 | 对应模型 | uniform | distance |
|---|---|---|---|
| 分类 | KNeighborsClassifier | 按邻居数量投票 | 按距离权重投票 |
| 回归 | KNeighborsRegressor | 对邻居的目标值取平均 | 对邻居的目标值取加权平均 |
1.2 基本使用
from sklearn.neighbors import KNeighborsRegressor
knn = KNeighborsRegressor(n_neighbors=2, weights="distance")
knn.fit(x_train, y_train) # 保存和组织训练数据
y_pred = knn.predict(x_test)
n_neighbors=2 表示参考最近的两个训练样本。weights='distance' 表示距离越近,权重越大;对于距离非零的邻居,权重为距离的倒数。回归模型最终计算加权平均值。
这里把预测结果命名为 y_pred 更合适,因为得到的是预测的目标值。fit() 的作用是保存和组织训练数据,KNN 属于惰性学习,训练阶段几乎不计算,真正的计算发生在预测时。
1.3 K 值大小的影响
| K 值 | 通常表现 |
|---|---|
太小,例如 K=1 | 容易受到噪声影响,可能过拟合 |
| 太大 | 容易忽略局部规律,可能欠拟合 |
| 合适 | 通过交叉验证比较选择 |
例如,附近有一个标签标错的样本:
- 只参考最近的 1 个,可能直接被它误导。
- 参考附近的多个,其他邻居可能抵消这个错误的影响。
这些是一般趋势,具体还与数据和权重设置有关。可参考 scikit-learn 官方说明。
1.4 距离参数 p 与特征缩放
网格搜索里用到了 p,它决定距离的计算方式:
| 参数 | 距离计算方式 |
|---|---|
p=1 | 曼哈顿距离 |
p=2 | 欧氏距离 |
p=3 | p 为 3 的闵可夫斯基距离 |
以 (0, 0) 和 (3, 4) 为例:
曼哈顿距离:3 + 4 = 7
欧氏距离:√(3² + 4²) = 5
为什么 KNN 要做缩放?假设特征是“年龄”和“年收入”:
年龄差:10 岁
收入差:100000 元
直接计算距离时,收入的数字会占据很大影响。因此需要缩放,使不同特征的尺度更接近。
1.5 归一化缩放器与标准化缩放器
MinMaxScaler:
scaler = MinMaxScaler(feature_range=(-1, 1))
X_scaled = scaler.fit_transform(X)
feature_range=(-1, 1) 表示把拟合数据中每列的最小值映射为 -1,最大值映射为 1。
StandardScaler:
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
StandardScaler 让每列均值为 0、标准差为 1,没有固定上下界。
1.6 模型评分不只有准确率
对于 KNeighborsClassifier,score() 得到的是准确率。分类任务还可以查看混淆矩阵和分类报告:
from sklearn.metrics import confusion_matrix, classification_report
y_pred = knn.predict(x_test)
print(confusion_matrix(y_test, y_pred))
print(classification_report(y_test, y_pred))
- 准确率:全部样本中预测正确的比例。
- 精确率:预测为正类的样本中,真正属于正类的比例。
- 召回率:实际正类样本中,被正确找出来的比例。
而 KNeighborsRegressor.score() 默认返回 R²,不要把分类和回归的 score() 都理解成准确率。
1.7 完整示例
import pandas as pd
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.preprocessing import StandardScaler, OneHotEncoder # 标准化缩放器和独热编码器
from sklearn.compose import ColumnTransformer # 列转换器
from sklearn.pipeline import Pipeline # 流水线
from sklearn.neighbors import KNeighborsClassifier # KNN 分类器
import joblib
1. 加载数据集
dataset = pd.read_csv("../data/heart_disease.csv")
2. 数据清洗
dataset.dropna(inplace=True)
dataset.info()
print(dataset.head())
3. 划分特征和标签
X = dataset.drop("是否患有心脏病", axis=1)
y = dataset["是否患有心脏病"]
4. 数据集划分
x_train, x_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
5. 特征工程(特征转换)
数值型特征
numerical_features = ["年龄", "静息血压", "胆固醇", "最大心率", "运动后的ST下降", "主血管数量"]
类别型特征
categorical_features = ["胸痛类型", "静息心电图结果", "峰值ST段的斜率", "地中海贫血"]
二元特征
binary_features = ["性别", "空腹血糖", "运动性心绞痛"]
创建列转换器
column_transformer = ColumnTransformer(transformers=[
("num", StandardScaler(), numerical_features),
("cat", OneHotEncoder(drop="first"), categorical_features),
("bin", "passthrough", binary_features),
])
6. 用 Pipeline 把预处理和模型串起来
pipeline = Pipeline(steps=[
("preprocess", column_transformer),
("knn", KNeighborsClassifier(n_neighbors=3)),
])
7. 模型训练
pipeline.fit(x_train, y_train)
8. 模型评估
score = pipeline.score(x_test, y_test)
print("测试集准确率:", score)
9. 保存整条流程(预处理规则 + 模型)
joblib.dump(pipeline, "knn_pipeline.joblib")
10. 加载训练好的流程,做预测
pipeline_loaded = joblib.load("knn_pipeline.joblib")
y_pred = pipeline_loaded.predict(x_test[10:11])
print(y_pred, y_test.iloc[10])
11. 超参数调优
自动尝试多种超参数组合,帮你找到效果最好的一组参数
param_grid = {
"knn__n_neighbors": list(range(1, 16)),
"knn__p": [1, 2, 3],
"knn__weights": ["uniform", "distance"],
}
gridSearch_CV = GridSearchCV(estimator=pipeline, param_grid=param_grid, cv=10)
模型训练 + 交叉验证
gridSearch_CV.fit(x_train, y_train)
打印结果
print(pd.DataFrame(gridSearch_CV.cv_results_).to_string())
print(gridSearch_CV.best_estimator_)
print(gridSearch_CV.best_params_)
print(gridSearch_CV.best_score_)
score = gridSearch_CV.best_estimator_.score(x_test, y_test)
print("测试集评分:", score)
12. 保存调参后选出的最佳流程
joblib.dump(gridSearch_CV.best_estimator_, "knn_best_pipeline.joblib")
关于样本索引:
x_test[10:11] # 按位置取第 11 个样本,并保留二维形状
y_test.iloc[10] # 按位置取第 11 个标签
而 y_test[10] 在这里查找的是索引标签为 10 的那一项。数据集打乱划分后,它不一定对应第 11 个样本,也可能不存在。可参考 Pandas 位置索引说明。
1.8 为什么预处理要放进 Pipeline
如果先对整个训练集学习标准化和编码规则,再交给 10 折交叉验证,顺序是:
对整个训练集学习标准化和编码规则
↓
再把转换后的训练集交给 10 折交叉验证
问题在于:交叉验证中每一折的验证数据,已经参与了前面预处理规则的确定。虽然独立的测试集没有参与拟合,但内部交叉验证仍存在预处理信息泄漏,可能让交叉验证评分偏乐观。
更规范的顺序是:
每一折:只用该折的训练部分学习预处理规则
↓
转换该折的训练部分和验证部分
↓
训练 KNN,评估验证部分
实现这个流程的工具就是 Pipeline:把 column_transformer 和 KNN 放在一起,再交给 GridSearchCV。此时参数名要加前缀,例如 knn__n_neighbors。可参考 官方关于数据泄漏的建议。
1.9 模型保存与 KNN 的局限
如果只保存 knn,保存的是调参之前的 KNN 模型,而且没有保存 column_transformer。后续拿到原始新数据时,还缺少训练时使用的标准化和编码规则。
实际使用时,需要保存预处理器和训练好的模型;使用 Pipeline 后,可以把整条流程一起保存。如果进行了调参,应保存调参后选出的最佳 Pipeline。
另外可以简记 KNN 的局限:需要保存训练数据,数据量大时预测可能较慢,特征维度很高时距离也可能变得不够有效。
2.线性回归
线性回归假设目标值是特征的线性组合,模型形式为 y = beta0 + beta1 * x1 + ... + betan * xn。训练的目标就是找到一组参数,使预测值与真实值的误差平方和最小。
from sklearn.linear_model import LinearRegression
model = LinearRegression(fit_intercept=True)
fit_intercept=True 表示模型会自动估计截距项 beta0;如果数据已经中心化,或者业务上明确要求直线过原点,可以设为 False。
2.1 最小二乘与梯度下降
线性回归的损失函数通常取均方误差:
J(beta) = (1 / m) * sum((X @ beta - y) ** 2)
求解这组参数有两条常见路线:
- 解析解:直接解正规方程
beta = (X^T X)^(-1) X^T y,一次算出结果,sklearn的LinearRegression默认走这条路。 - 数值解:用梯度下降反复迭代,逐步逼近最优参数,适合特征维度很高、无法直接求逆的场景。
正规方程需要对矩阵求逆,时间复杂度约为 O(n^3),特征数很多时会很慢;梯度下降每次只用到一部分数据,更适合大规模问题,但需要自己设置学习率和迭代次数。
2.2 具体示例
下面用 PyTorch 手写一遍梯度下降,观察参数 beta0 和 beta1 的收敛轨迹:
import torch
import matplotlib.pyplot as plt
1. 定义数据,转换成浮点类型的张量
X = torch.tensor(
[[5], [8], [10], [12], [15], [3], [7], [9], [14], [6]],
dtype=torch.float32
)
y = torch.tensor(
[55, 65, 70, 75, 85, 50, 60, 72, 80, 58],
dtype=torch.float32
).reshape(-1, 1)
n = X.shape[0]
添加全为 1 的一列,用于计算截距
X = torch.cat([torch.ones((n, 1)), X], dim=1)
2. 定义损失函数,不需要再手写梯度函数
def J(beta):
return torch.mean((X @ beta - y) ** 2)
3. 初始化参数
requires_grad=True 表示需要计算损失对 beta 的梯度
beta = torch.tensor(
[[1.0], [1.0]],
requires_grad=True
)
alpha = 0.005
num_iters = 10000
告诉优化器:要更新的参数是 beta,学习率是 alpha
optimizer = torch.optim.SGD([beta], lr=alpha)
beta0 = []
beta1 = []
4. 重复迭代
for i in range(num_iters):
# item() 把单个张量数值取出来,方便记录和画图
beta0.append(beta[0, 0].item())
beta1.append(beta[1, 0].item())
# 清除上一次迭代留下的梯度
optimizer.zero_grad()
计算当前损失
loss = J(beta)
自动计算梯度,结果保存在 beta.grad 中
loss.backward()
根据梯度更新 beta
optimizer.step()
if i % 100 == 0:
# 打印时不需要跟踪梯度
with torch.no_grad():
print(
f"beta = {beta.reshape(-1).tolist()}"
f"\tLoss = {J(beta).item():.6f}"
)
5. 画参数的变化轨迹
plt.plot(beta0, beta1)
plt.xlabel("beta0")
plt.ylabel("beta1")
plt.show()
几个容易忽略的细节:
- 为什么要加一列全 1:把截距
beta0也当成一个参数,就能用统一的矩阵乘法X @ beta表示预测值,不用单独处理截距。 - 学习率
alpha:太大会让损失来回震荡甚至发散,太小则收敛很慢。这里取0.005是因为特征数值在 3 到 15 之间,量级较大。 zero_grad()不能省:PyTorch 的梯度默认会累加,不清零就会把上一轮的梯度叠加进来。- 特征缩放:如果特征量级差异很大,梯度下降会走得很慢,通常先做标准化再训练。
2.3 常见优化器
梯度下降只规定了“沿着梯度反方向走”,具体怎么走由优化器决定。常见优化器对比如下:
| 优化器 | 主要特点 |
|---|---|
SGD | 直接根据当前梯度更新参数 |
带动量的 SGD | 结合之前的更新方向,减少来回震荡 |
Adam | 根据历史梯度,为不同参数调整更新幅度 |
AdamW | 在 Adam 的基础上,使用解耦的权重衰减进行正则化 |
RMSprop | 根据历史梯度的平方调整更新幅度 |
补充几点说明:
- SGD:更新公式为
beta = beta - lr * grad,实现最简单,但对学习率敏感,损失曲面狭长时容易震荡。 - 动量(Momentum):引入速度项
v = beta * v + grad,让更新方向具有惯性,既能加速收敛,也能冲过小的局部波动。 - Adam:同时维护梯度的一阶矩和二阶矩,相当于“动量 + 自适应学习率”,每个参数有各自的步长,通常不需要太多调参。
- AdamW:Adam 的权重衰减是加在梯度里的,和自适应学习率耦合在一起;AdamW 把权重衰减单独拿出来作用在参数上,正则化效果更符合预期。
- RMSprop:只使用梯度平方的滑动平均来缩放学习率,适合非平稳目标,是 Adam 的前身之一。
选择建议:小规模或需要精细控制时用 SGD + Momentum;大多数默认场景用 Adam 或 AdamW 就能得到不错的效果。
2.4 用 sklearn 完成一次线性回归
实际项目中更常用 sklearn,它直接给出解析解,不需要手写迭代:
import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error, r2_score
X = np.array([[5], [8], [10], [12], [15], [3], [7], [9], [14], [6]], dtype=float)
y = np.array([55, 65, 70, 75, 85, 50, 60, 72, 80, 58], dtype=float)
x_train, x_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42
)
model = LinearRegression(fit_intercept=True)
model.fit(x_train, y_train)
print("截距 beta0 =", model.intercept_)
print("斜率 beta1 =", model.coef_)
y_pred = model.predict(x_test)
print("MSE =", mean_squared_error(y_test, y_pred))
print("R2 =", r2_score(y_test, y_pred))
训练完成后,model.intercept_ 是截距,model.coef_ 是各特征的系数。回归任务的 score() 返回的是 R²,越接近 1 说明拟合越好,这一点和 KNN 分类的准确率不同。
2.5 使用线性回归的注意事项
- 特征缩放:用解析解时缩放不影响结果,但用梯度下降时强烈建议先标准化,否则收敛很慢。
- 多重共线性:特征之间高度相关时,
X^T X接近奇异,系数估计会不稳定,可以考虑岭回归Ridge。 - 异常值敏感:平方损失会放大离群点的影响,数据清洗很重要。
- 只能拟合线性关系:特征与目标呈明显非线性时,需要做特征变换或改用其他模型。
3.K-Means 聚类
K-Means 是一种无监督学习算法:训练数据只有特征 X,没有标签 y。它的目标是把样本划分成 K 个簇,使同一簇内的样本尽量相似,不同簇之间尽量分开。
3.1 K-Means 的基本原理
K-Means 的核心思想是:先随机选 K 个中心点,然后反复执行“把每个样本分配给最近的中心”和“把中心更新为所属样本的均值”这两步,直到中心点基本不再移动。
完整流程可以概括为:
- 初始化:选择 K 个初始簇中心,
sklearn默认使用k-means++,让初始中心尽量分散。 - 分配样本:计算每个样本到各个中心的距离,把它分给最近的中心。
- 更新中心:对每个簇,重新计算所有成员特征的均值,作为新的中心。
- 重复迭代:不断执行第 2、3 步,直到中心点变化很小,或者达到最大迭代次数。
K-Means 优化的目标函数是簇内平方和(Inertia):
J = sum over k of sum over x in Ck of || x - mu_k ||^2
其中 Ck 是第 k 个簇,mu_k 是该簇的中心。这个值越小,说明簇内样本越紧凑。
3.2 基本使用
下面用 make_blobs 生成三个簇的二维数据,再用 K-Means 聚类并可视化:
import os
os.environ['OMP_NUM_THREADS'] = '2'
import matplotlib.pyplot as plt
from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs
plt.rcParams['font.sans-serif'] = ['KaiTi']
plt.rcParams['axes.unicode_minus'] = False
1. 生成数据
X, y = make_blobs(
n_samples=300,
n_features=2,
centers=3,
cluster_std=2,
random_state=42
)
散点图
fig, ax = plt.subplots(2, figsize=(8, 8))
ax[0].scatter(X[:, 0], X[:, 1], c="gray", label="原始数据")
ax[0].set_title("原始数据(无监督)")
ax[0].legend()
2. 定义模型
kmeans = KMeans(n_clusters=3)
3. 训练模型
kmeans.fit(X)
得到聚簇中心点
centers = kmeans.cluster_centers_
print(centers)
4. 预测每个点的分簇标签
y_pred = kmeans.predict(X)
画图:按聚簇结果染成不同颜色
ax[1].scatter(X[:, 0], X[:, 1], c=y_pred, label="聚簇结果数据")
ax[1].scatter(centers[:, 0], centers[:, 1], s=200, c="red", label="簇中心")
ax[1].set_title("K-Means聚簇结果(K=3)")
ax[1].legend()
plt.show()
几个参数和属性的含义:
n_clusters=3:指定要划分成几个簇,这是 K-Means 最重要的超参数,必须由使用者给出。fit(X):完成迭代,得到最终的簇中心,结果保存在cluster_centers_中。predict(X):把样本分配给距离最近的中心,返回每个样本的簇编号。fit_predict(X):等价于先fit再predict,一步拿到标签。labels_:训练数据自身的簇标签,和predict(X)的结果一致。
需要注意:簇编号本身没有含义。第 0 簇不代表“最好”或“第一类”,每次运行编号可能不同,比较结果时应该看簇的成员构成,而不是编号。
3.3 评估聚类效果
聚类没有真实标签,评估方式和分类不同。常用的指标有:
from sklearn.metrics import silhouette_score, calinski_harabasz_score
print(kmeans.inertia_)
print(silhouette_score(X, y_pred))
print(calinski_harabasz_score(X, y_pred))
| 指标 | 含义 | 方向 |
|---|---|---|
inertia_ | 簇内平方和,衡量簇内紧凑程度 | 越小越好,但会随 K 增大而单调下降 |
silhouette_score | 结合簇内紧密度和簇间分离度,取值约在 -1 到 1 | 越接近 1 越好 |
calinski_harabasz_score | 簇间离散度与簇内离散度的比值 | 越大越好 |
因为 inertia_ 会随着 K 增大一直下降,不能直接用它选 K。常见做法是画肘部图:横轴是 K,纵轴是 inertia_,选择下降速度明显变缓的拐点。
inertias = []
for k in range(1, 11):
km = KMeans(n_clusters=k, random_state=42)
km.fit(X)
inertias.append(km.inertia_)
plt.plot(range(1, 11), inertias, marker="o")
plt.xlabel("K")
plt.ylabel("inertia")
plt.title("肘部法选择 K")
plt.show()
也可以结合轮廓系数一起看:轮廓系数在某个 K 上明显更高,通常说明这个划分更合理。
3.4 K-Means 的注意事项与局限
- 需要预先指定 K:K 值选错会直接得到错误的分组,通常用肘部法、轮廓系数或业务经验确定。
- 对初始中心敏感:不同的初始化可能收敛到不同结果,
sklearn默认用k-means++缓解这个问题,也可以通过n_init多次初始化取最优。 - 假设簇是凸形、大小相近:对于环形、月牙形等非凸分布,K-Means 效果较差,可以考虑 DBSCAN 或谱聚类。
- 对特征尺度敏感:距离计算受量纲影响,使用前应先做标准化,这一点和 KNN 相同。
- 对异常值敏感:中心是均值,离群点会把中心拉偏,必要时先做异常值处理。
- 结果具有随机性:建议固定
random_state,方便复现。
可参考 scikit-learn 关于 K-Means 的官方说明。
4.感知机
感知机(Perceptron)是最简单的神经网络模型,也是理解神经网络的基础。它接收若干个输入,对每个输入乘以一个权重后求和,再加上偏置,最后通过一个阶跃函数输出 0 或 1。本质上,感知机就是一个线性二分类器:在二维平面上,它对应一条直线,把平面分成两部分。
4.1 感知机的基本原理
感知机的核心思想是:对输入做加权求和,如果结果大于阈值就输出 1,否则输出 0。权重决定每个输入的重要程度,偏置(阈值的相反数)决定判断的临界位置。
以两个输入为例,感知机的计算过程可以写成:
res = w1 * x1 + w2 * x2 + b
y = 1 (res > 0)
y = 0 (res <= 0)
其中 w1、w2 是权重,b 是偏置。把阈值 theta 移到左边,就得到 b = -theta,所以“与阈值比较”和“与 0 比较”是等价的两种写法。
用向量形式表示更简洁:
res = w @ x + b
这里的 w @ x 就是两个向量的点积,输入维度增加时,只需要把 w 和 x 同步加长,代码结构完全不用改。
感知机的输出只有 0 和 1,因此它天然适合表示逻辑门。下面用同一套代码实现与门、与非门、或门,再用它们组合出异或门。
4.2 基本实现
先定义一个通用的感知机类,权重用 numpy 数组表示,这样输入维度可以灵活扩展:
import numpy as np
class Perceptron:
# w 为长度为 2 的向量,ndarray[w1, w2],b 为标量
def init(self, w, b):
self.w = w
self.b = b
# x 为长度为 2 的向量,ndarray[x1, x2]
def predict(self, x):
# 加权求和
res = self.w @ x + self.b
# 跟阈值 0 做判断
if res <= 0:
return 0
else:
return 1</code></pre>
几个细节值得注意:
为什么用 @:self.w @ x 是点积运算,等价于 w1 * x1 + w2 * x2,但写法更简洁,维度变化时也不用改代码。
为什么和 0 比较:把阈值 theta 吸收进偏置 b 之后,判断条件就统一成 res > 0,模型参数只剩 w 和 b。
为什么用 <= 而不是 <:这是约定问题,res == 0 时归为 0 类。只要前后一致,不影响模型表达能力。
4.3 逻辑门实现
感知机的输出只有 0 和 1,正好对应逻辑门的真值表。下面用同一个 Perceptron 类,只改权重和偏置,就能得到不同的逻辑门。
与门(AND)
与门要求两个输入都为 1 时才输出 1,可以取 w = [1, 1]、b = -1.5:
and_gate = Perceptron(np.array([1, 1]), -1.5)
for x1 in (0, 1):
for x2 in (0, 1):
print(x1, x2, "->", and_gate.predict(np.array([x1, x2])))
输出结果:
0 0 -> 0
0 1 -> 0
1 0 -> 0
1 1 -> 1
或门(OR)
或门只要有一个输入为 1 就输出 1,可以取 w = [1, 1]、b = -0.5:
or_gate = Perceptron(np.array([1, 1]), -0.5)
for x1 in (0, 1):
for x2 in (0, 1):
print(x1, x2, "->", or_gate.predict(np.array([x1, x2])))
与非门(NAND)
与非门是与门取反,把权重和偏置同时取反即可:w = [-1, -1]、b = 1.5。
nand_gate = Perceptron(np.array([-1, -1]), 1.5)
for x1 in (0, 1):
for x2 in (0, 1):
print(x1, x2, "->", nand_gate.predict(np.array([x1, x2])))
异或门(XOR)
异或门无法用单个感知机表示,因为它的正负样本不是线性可分的。但可以用前面三个门组合出来:
x1 XOR x2 = (x1 NAND x2) AND (x1 OR x2)
def xor_gate(x1, x2):
x = np.array([x1, x2])
s1 = nand_gate.predict(x)
s2 = or_gate.predict(x)
return and_gate.predict(np.array([s1, s2]))
for x1 in (0, 1):
for x2 in (0, 1):
print(x1, x2, "->", xor_gate(x1, x2))
输出结果:
0 0 -> 0
0 1 -> 1
1 0 -> 1
1 1 -> 0
这也说明了一个重要结论:单层感知机只能解决线性可分问题。异或门需要两层结构才能表示,这正是多层神经网络出现的动机。
本文系统梳理四大基础机器学习算法:KNN(含距离度量、特征缩放、Pipeline防数据泄漏)、线性回归(最小二乘与梯度下降求解、优化器对比、R²评估)、K-Means聚类(肘部法选K、Inertia与轮廓系数评估、对尺度和异常值敏感性)以及感知机(线性可分原理、逻辑门实现及XOR不可分性)。强调预处理一致性、模型局限与工程实践要点。

3701

被折叠的 条评论
为什么被折叠?



