机器学习复习(自用版)

本文系统梳理四大基础机器学习算法:KNN(含距离度量、特征缩放、Pipeline防数据泄漏)、线性回归(最小二乘与梯度下降求解、优化器对比、R²评估)、K-Means聚类(肘部法选K、Inertia与轮廓系数评估、对尺度和异常值敏感性)以及感知机(线性可分原理、逻辑门实现及XOR不可分性)。强调预处理一致性、模型局限与工程实践要点。

1.KNN用法

1.1 KNN 的基本原理

KNN 的核心思想是:相似的样本,其标签或数值往往也相近。预测时,先计算新样本与训练样本的距离,找出最近的 K 个邻居,再根据邻居的结果进行预测。

KNN 既可以做分类,也可以做回归,区别在于“邻居的结果”如何汇总:

任务对应模型uniformdistance
分类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=3p 为 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 个中心点,然后反复执行“把每个样本分配给最近的中心”和“把中心更新为所属样本的均值”这两步,直到中心点基本不再移动。

完整流程可以概括为:

  1. 初始化:选择 K 个初始簇中心,sklearn 默认使用 k-means++,让初始中心尽量分散。
  2. 分配样本:计算每个样本到各个中心的距离,把它分给最近的中心。
  3. 更新中心:对每个簇,重新计算所有成员特征的均值,作为新的中心。
  4. 重复迭代:不断执行第 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 &lt;= 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

这也说明了一个重要结论:单层感知机只能解决线性可分问题。异或门需要两层结构才能表示,这正是多层神经网络出现的动机。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值