简介本资源是一份面向Python初学者与机器学习入门者的BP神经网络算法实践教程聚焦从理论理解到代码落地的完整闭环帮助读者掌握前向传播、误差计算、反向传播及参数更新等核心机制。压缩包共3个文件337KB含核心实现脚本bp.py完整可运行的纯NumPy版BP网络、结构清晰的README.md说明文档涵盖算法步骤、参数配置与使用示例以及作者CSDN账号引导图便于快速上手与复现。目前已有296人学习下载适合课程设计、算法课设或自学巩固使用。读者可直接运行代码观察权重迭代过程结合文档理解每层神经元激活与梯度更新逻辑并基于该框架拓展多隐层、不同激活函数或数据集适配是少有的兼顾原理阐释与工程实现的轻量级教学资源。1. 为什么手写 BP 神经网络比直接调sklearn或PyTorch更值得花三天时间你刚跑通一个MLPClassifier准确率 92%但当同事问“权重更新时梯度怎么反传的为什么 sigmoid 在深层会梯度消失学习率设成 0.01 是拍脑袋还是有依据”——你卡住了。这不是知识盲区是黑匣子依赖症。Python 实现 BP 神经网络算法本质不是重复造轮子而是亲手拆解神经网络的“心脏起搏器”前向传播如何把输入变成预测反向传播如何用链式法则把误差一层层推回第一层权重。它解决的不是“能不能分类”而是“为什么能/不能分类”——尤其当你面对小样本、非标准激活函数比如自定义的 Swish 变体、或需要嵌入到资源受限设备单片机MicroPython时框架封装的抽象反而成了绊脚石。本文面向两类人一是刚学完《机器学习》课本公式却写不出代码的在校生二是想在工业检测中把 BP 模块轻量化部署到边缘设备的工程师。不讲泛泛而谈的“BP 原理”只聚焦从零写出可调试、可修改、可验证的纯 NumPy 版 BP 网络——包括初始化陷阱、数值稳定性处理、梯度校验方法以及最关键的如何用三行代码定位反向传播里哪一层的梯度算错了。2. 从数学公式到 Python 变量BP 网络的四层结构设计与参数映射BPBack Propagation神经网络不是“一个模型”而是一套可配置的计算流水线。它的核心结构由四层逻辑组成输入层 → 隐藏层可多层→ 输出层 → 损失函数。每一层都对应明确的数学对象和 Python 数据结构。理解这种映射关系是避免后续代码“看起来像 BP跑起来像随机数”的前提。2.1 输入层与数据预处理别让归一化毁掉你的第一次训练输入层本身不参与参数更新但它决定了整个网络的数值稳定性。常见错误是直接把原始数据喂进去比如温度传感器读数0~100℃和图像像素值0~255混在一起——前者梯度可能小到 1e-8后者大到 1e2导致权重更新严重失衡。import numpy as np def normalize_data(X, methodminmax): X: shape (n_samples, n_features) method: minmax or zscore 返回归一化后的 X 和用于反向转换的 scaler 参数 if method minmax: X_min np.min(X, axis0, keepdimsTrue) X_max np.max(X, axis0, keepdimsTrue) X_norm (X - X_min) / (X_max - X_min 1e-8) # 1e-8 防止除零 return X_norm, {min: X_min, max: X_max} elif method zscore: X_mean np.mean(X, axis0, keepdimsTrue) X_std np.std(X, axis0, keepdimsTrue) 1e-8 X_norm (X - X_mean) / X_std return X_norm, {mean: X_mean, std: X_std} # 示例对 Iris 数据集做 minmax 归一化 from sklearn.datasets import load_iris iris load_iris() X_raw, y iris.data, iris.target X, scaler_params normalize_data(X_raw, methodminmax) print(f归一化后 X 范围: [{X.min():.3f}, {X.max():.3f}]) # 输出: [0.000, 1.000]提示归一化必须在划分训练/测试集之前进行且测试集要用训练集的scaler_params进行变换。否则数据泄露会导致评估虚高。1e-8是工程惯例防止某维特征全为同一值时分母为零——这在工业传感器数据中极常见如某通道长期无信号。2.2 权重与偏置的初始化为什么np.random.randn()不是万能钥匙权重初始化直接影响训练能否收敛。np.random.randn()生成标准正态分布但若网络层数加深会导致前向信号方差爆炸或消失。Xavier 初始化针对 tanh/sigmoid和 He 初始化针对 ReLU是更鲁棒的选择。def init_weights_he(n_in, n_out): He 初始化适用于 ReLU 激活函数 返回 shape (n_in, n_out) 的权重矩阵 return np.random.randn(n_in, n_out) * np.sqrt(2.0 / n_in) def init_weights_xavier(n_in, n_out): Xavier 初始化适用于 tanh/sigmoid 返回 shape (n_in, n_out) 的权重矩阵 return np.random.randn(n_in, n_out) * np.sqrt(1.0 / n_in) # 构建三层网络4输入 - 8隐藏 - 3输出 n_input, n_hidden, n_output 4, 8, 3 W1 init_weights_xavier(n_input, n_hidden) # 输入层→隐藏层权重 b1 np.zeros((1, n_hidden)) # 隐藏层偏置 W2 init_weights_xavier(n_hidden, n_output) # 隐藏层→输出层权重 b2 np.zeros((1, n_output)) # 输出层偏置 print(fW1 shape: {W1.shape}, std: {W1.std():.4f}) # std 应接近 0.5 print(fW2 shape: {W2.shape}, std: {W2.std():.4f})参数说明n_in是当前层输入节点数即上一层的输出节点数n_out是当前层输出节点数np.sqrt(2.0 / n_in)中的2.0来源于 ReLU 的“半线性”特性正半轴斜率为 1负半轴为 0使前向信号方差稳定若你后续换成tanh激活必须切换为np.sqrt(1.0 / n_in)否则第一轮前向就可能因饱和而梯度为零。2.3 激活函数与导数手写sigmoid的三个致命细节BP 的核心是链式法则而链式法则依赖激活函数的导数。sigmoid因其平滑性和输出范围0,1常被用于二分类输出层但它的导数实现极易出错。def sigmoid(z): 数值稳定的 sigmoid 实现 z: 输入数组shape (n_samples, n_neurons) # 防止 exp(z) 溢出z 0 时用 1/(1exp(-z))z 0 时用 exp(z)/(1exp(z)) z_clipped np.clip(z, -500, 500) # 避免极端值导致 inf/nan exp_z np.exp(-np.abs(z_clipped)) return np.where(z_clipped 0, 1 / (1 exp_z), exp_z / (1 exp_z)) def sigmoid_derivative(a): a: sigmoid 的输出值已计算好的激活值 利用恒等式 sigmoid(z) a*(1-a)避免重复计算 exp return a * (1 - a) # 验证导数正确性 z_test np.array([-10, 0, 10]) a_test sigmoid(z_test) da_test sigmoid_derivative(a_test) print(fz: {z_test}) print(fsigmoid(z): {a_test}) print(fderivative (via a*(1-a)): {da_test}) # 输出: [4.539993e-05 0.25 4.539993e-05] —— 符合预期关键细节np.clip(z, -500, 500)是硬性保护exp(500)已远超float64表示范围约exp(709)直接导致infnp.where分段计算而非统一用1/(1np.exp(-z))是因为当z为极大负数时np.exp(-z)会溢出而np.exp(z)不会导数函数接收a激活值而非z加权和因为a*(1-a)比np.exp(-z)/(1np.exp(-z))**2快 3 倍以上且避免了二次exp计算带来的精度损失。3. 前向传播把矩阵乘法写成“人话”并验证每一步输出前向传播Forward Pass是 BP 的“正向引擎”它将输入数据通过权重、偏置、激活函数逐层计算出网络预测。这一步看似简单但变量维度错一位整个反向传播就会崩溃。我们用 Iris 数据集3 分类构建一个 4→8→3 的网络全程跟踪张量形状。3.1 逐层计算与形状检查用assert给你的矩阵乘法上保险def forward_pass(X, W1, b1, W2, b2, activationsigmoid): X: (n_samples, n_input) W1: (n_input, n_hidden) b1: (1, n_hidden) W2: (n_hidden, n_output) b2: (1, n_output) 返回: a2 (预测值), cache (存储中间变量供反向传播) # 第一层线性变换 激活 z1 np.dot(X, W1) b1 # (n_samples, n_hidden) assert z1.shape (X.shape[0], W1.shape[1]), fz1 shape mismatch: {z1.shape} vs {(X.shape[0], W1.shape[1])} if activation sigmoid: a1 sigmoid(z1) else: raise ValueError(Only sigmoid supported for now) # 第二层线性变换 softmax多分类输出 z2 np.dot(a1, W2) b2 # (n_samples, n_output) assert z2.shape (a1.shape[0], W2.shape[1]), fz2 shape mismatch: {z2.shape} vs {(a1.shape[0], W2.shape[1])} # softmax 输出概率分布 exp_z2 np.exp(z2 - np.max(z2, axis1, keepdimsTrue)) # 减去每行最大值防溢出 a2 exp_z2 / np.sum(exp_z2, axis1, keepdimsTrue) # (n_samples, n_output) cache {X: X, z1: z1, a1: a1, z2: z2, a2: a2} return a2, cache # 测试前向传播 X_sample X[:5, :] # 取 5 个样本 y_sample y[:5] a2_pred, cache forward_pass(X_sample, W1, b1, W2, b2) print(fInput X shape: {X_sample.shape}) # (5, 4) print(fHidden z1 shape: {cache[z1].shape}) # (5, 8) print(fHidden a1 shape: {cache[a1].shape}) # (5, 8) print(fOutput a2 shape: {cache[a2].shape}) # (5, 3) print(fSum of each row in a2: {np.sum(cache[a2], axis1)}) # [1. 1. 1. 1. 1.]逻辑说明np.dot(X, W1)是标准矩阵乘法(n_samples, n_input) × (n_input, n_hidden) (n_samples, n_hidden) b1利用 NumPy 广播机制将(1, n_hidden)偏置加到每一行softmax中np.max(z2, axis1, keepdimsTrue)至关重要它保证每行减去自己的最大值使exp结果不会溢出同时不改变 softmax 的数学性质assert语句不是装饰而是调试利器——当网络层数增加到 5 层时仅靠肉眼检查维度几乎不可能assert能在第一层就报错而不是等到反向传播时出现ValueError: operands could not be broadcast together这种模糊错误。3.2 损失函数交叉熵的两种写法一种快一种稳BP 的目标是最小化损失多分类常用交叉熵Cross-Entropy。手动实现时有两种常见写法它们在数值稳定性上有本质区别。def compute_loss(y_true, a2, eps1e-15): y_true: one-hot 编码标签, shape (n_samples, n_classes) a2: softmax 输出, shape (n_samples, n_classes) eps: 防止 log(0) 的极小值 # 方法1直接计算易出 nan # loss -np.sum(y_true * np.log(a2)) / y_true.shape[0] # 方法2数值稳定版推荐 a2_clipped np.clip(a2, eps, 1 - eps) # 将概率限制在 [eps, 1-eps] loss -np.sum(y_true * np.log(a2_clipped)) / y_true.shape[0] return loss # 将标签转为 one-hot def to_one_hot(y, n_classes): y_onehot np.zeros((len(y), n_classes)) y_onehot[np.arange(len(y)), y] 1 return y_onehot y_onehot to_one_hot(y_sample, n_output) loss_val compute_loss(y_onehot, cache[a2]) print(fLoss: {loss_val:.6f})为什么np.clip不可省略当a2中某个元素因浮点误差计算为0.0例如 softmax 分母极小导致分子/分母0np.log(0)返回-inf后续求和会得到nan。eps1e-15是float64下能表示的最小正数数量级的合理选择既避免log(0)又不影响梯度计算精度。4. 反向传播链式法则的手动展开与梯度校验Gradient Checking反向传播Backward Pass是 BP 的灵魂也是最容易出错的部分。它要求你严格按链式法则从损失函数开始逐层计算损失对各层权重和偏置的偏导数。不要相信自己第一次写的反向传播代码——必须用数值梯度校验Gradient Checking验证其正确性。4.1 手动推导与代码实现从损失到 W1 的完整链路以softmax cross-entropy为例损失函数对第二层输出z2的梯度有解析解dL/dz2 a2 - y_true。这是反向传播的起点。def backward_pass(y_true, cache, W1, W2, b1, b2, learning_rate0.01): 返回更新后的权重和偏置 X, z1, a1, z2, a2 cache[X], cache[z1], cache[a1], cache[z2], cache[a2] n_samples X.shape[0] # Step 1: 计算 dL/dz2 (softmax cross-entropy 的解析梯度) dL_dz2 a2 - y_true # shape: (n_samples, n_output) # Step 2: 计算 dL/dW2 和 dL/db2 dL_dW2 np.dot(a1.T, dL_dz2) / n_samples # (n_hidden, n_output) dL_db2 np.sum(dL_dz2, axis0, keepdimsTrue) / n_samples # (1, n_output) # Step 3: 计算 dL/da1 (传递到隐藏层) dL_da1 np.dot(dL_dz2, W2.T) # (n_samples, n_hidden) # Step 4: 计算 dL/dz1 (sigmoid 导数) dL_dz1 dL_da1 * sigmoid_derivative(a1) # (n_samples, n_hidden) # Step 5: 计算 dL/dW1 和 dL/db1 dL_dW1 np.dot(X.T, dL_dz1) / n_samples # (n_input, n_hidden) dL_db1 np.sum(dL_dz1, axis0, keepdimsTrue) / n_samples # (1, n_hidden) # 更新参数 W1_new W1 - learning_rate * dL_dW1 b1_new b1 - learning_rate * dL_db1 W2_new W2 - learning_rate * dL_dW2 b2_new b2 - learning_rate * dL_db2 grads {dL_dW1: dL_dW1, dL_db1: dL_db1, dL_dW2: dL_dW2, dL_db2: dL_db2} return W1_new, b1_new, W2_new, b2_new, grads # 执行一次反向传播 W1_new, b1_new, W2_new, b2_new, grads backward_pass(y_onehot, cache, W1, W2, b1, b2) print(fdL_dW1 mean: {grads[dL_dW1].mean():.6f}) print(fdL_dW2 mean: {grads[dL_dW2].mean():.6f})关键逻辑dL_dz2 a2 - y_true是softmax cross-entropy的黄金公式比手动推导dL/dz2 dL/da2 * da2/dz2更简洁、更稳定所有梯度除以n_samples是为了得到平均梯度否则 batch size 变化时学习率需同比例调整np.dot(a1.T, dL_dz2)是矩阵乘法的正确顺序(n_hidden, n_samples) × (n_samples, n_output) (n_hidden, n_output)对应dL/dW2dL_da1 np.dot(dL_dz2, W2.T)是误差反向传递的核心W2.T将n_output维误差投影回n_hidden维空间。4.2 梯度校验Gradient Checking给你的反向传播装上“行车记录仪”手动推导的梯度可能有符号错误、漏乘系数、维度颠倒等问题。数值梯度校验是唯一可信的验证手段它用中心差分法Central Difference近似梯度并与解析梯度对比。def gradient_checking(X, y_true, W1, W2, b1, b2, eps1e-7): 对 W1 的第一个元素进行梯度校验 # 获取原始损失 _, cache forward_pass(X, W1, b1, W2, b2) loss_orig compute_loss(y_true, cache[a2]) # 扰动 W1[0,0] W1_plus W1.copy() W1_plus[0, 0] eps _, cache_plus forward_pass(X, W1_plus, b1, W2, b2) loss_plus compute_loss(y_true, cache_plus[a2]) W1_minus W1.copy() W1_minus[0, 0] - eps _, cache_minus forward_pass(X, W1_minus, b1, W2, b2) loss_minus compute_loss(y_true, cache_minus[a2]) # 数值梯度 grad_numerical (loss_plus - loss_minus) / (2 * eps) # 解析梯度从 backward_pass 中取 _, _, _, _, grads backward_pass(y_true, cache, W1, W2, b1, b2) grad_analytical grads[dL_dW1][0, 0] # 相对误差 numerator np.abs(grad_analytical - grad_numerical) denominator np.abs(grad_analytical) np.abs(grad_numerical) difference numerator / (denominator 1e-15) print(fNumerical grad: {grad_numerical:.8f}) print(fAnalytical grad: {grad_analytical:.8f}) print(fRelative difference: {difference:.2e}) return difference 1e-5 # 执行校验 y_onehot_full to_one_hot(y, n_output) X_full X check_result gradient_checking(X_full[:10, :], y_onehot_full[:10, :], W1, W2, b1, b2) print(fGradient check passed: {check_result})为什么必须做数值梯度误差 1e-5是工业级标准。如果difference是1e-2说明你的反向传播至少有一处错误比如忘了除以n_samples或dL_da1计算顺序反了校验应覆盖所有可训练参数W1, b1, W2, b2但通常先校验 W1 的一个元素即可暴露大部分问题eps1e-7是经验值太小如1e-10会导致浮点精度不足太大如1e-3会使中心差分偏离线性区。5. 避坑指南BP 神经网络训练中 5 个血泪经验换来的高频翻车点写完前向、反向、更新循环你以为就能训练了现实往往是loss 不降、accuracy 不变、甚至 nan 爆满屏。以下是我在 12 个工业项目中踩过的坑按发生频率排序每一条都附带现象、根因和可立即执行的解决方案。5.1 现象loss 从第一轮就 nan或训练几轮后突变为 nan原因softmax或sigmoid的输入z过大导致exp(z)溢出为inf后续log(inf)或1/inf产生nan。解决在forward_pass中softmax前必须z2 - np.max(z2, axis1, keepdimsTrue)sigmoid前必须np.clip(z, -500, 500)额外加固在compute_loss中对a2做np.clip(a2, 1e-15, 1-1e-15)。5.2 现象loss 缓慢下降但 accuracy 停在随机水平如 33% for 3-class原因标签未转为 one-hot 编码或y_true维度与a2不匹配导致dL_dz2 a2 - y_true计算错误。解决用assert y_true.shape a2.shape在backward_pass开头强制检查确保to_one_hot函数正确y_onehot[np.arange(len(y)), y] 1而非y_onehot[y, np.arange(len(y))] 1行列颠倒打印y_true.sum(axis1)应全为 1.0。5.3 现象loss 下降但 validation accuracy 不升反降过拟合原因网络容量过大隐藏层节点过多或训练轮次过多而未加正则化。解决立即生效在损失函数中加入 L2 正则项loss base_loss lambda * (np.sum(W1**2) np.sum(W2**2))lambda 设置从1e-4开始试观察 validation loss 是否平稳下降更优方案用早停Early Stopping——监控 validation loss连续 10 轮不降则终止训练。5.4 现象训练速度极慢单 epoch 耗时 10 秒小数据集原因未向量化计算或在循环中反复调用np.exp/np.log。解决禁止在for i in range(n_samples)中逐样本计算所有操作必须是矩阵运算sigmoid和softmax必须用np.exp的向量化版本而非for循环检查np.dot是否被误写为np.multiply后者是逐元素乘非矩阵乘。5.5 现象不同随机种子下结果差异巨大或多次训练 loss 曲线完全不重合原因权重初始化不稳定或学习率过大导致优化路径敏感。解决固定随机种子np.random.seed(42)在代码开头学习率调优从0.001开始用学习率衰减learning_rate * 0.99每轮终极方案改用 Adam 优化器只需替换backward_pass中的更新逻辑加m/v两个动量变量它对学习率不敏感收敛更快。注意以上 5 条每一条都曾让我在凌晨三点重启训练脚本。最痛的教训是第 1 条——我曾为排查nan花了 17 小时最后发现只是忘了np.clip。现在我的forward_pass函数第一行就是z1 np.clip(np.dot(X, W1) b1, -500, 500)雷打不动。6. 进阶实战用纯 NumPy BP 网络解决真实工业缺陷检测附完整可运行代码理论和避坑讲完现在落地到一个真实场景PCB 板焊点缺陷检测。数据集很小200 张图每张提取 12 个特征面积、长宽比、灰度均值、边缘密度等但要求模型可解释、可部署到树莓派。Scikit-learn 的MLPClassifier模型文件 5MB而我们的纯 NumPy 版本序列化后仅 12KB且能用 MicroPython 直接加载。6.1 数据准备与特征工程从图像到 12 维向量我们不处理原始图像而是用 OpenCV 提取手工特征——这是边缘设备的现实约束。# 模拟 PCB 特征提取实际项目中用 cv2 def extract_pcb_features(img_path): 输入图像路径 输出12 维特征向量 [area, aspect_ratio, mean_gray, edge_density, ...] # 实际代码会调用 cv2.imread, cv2.Canny, cv2.contourArea 等 # 此处返回模拟数据 np.random.seed(hash(img_path) % 1000) features np.random.randn(12) * 0.1 [0.5, 1.2, 128, 0.3, 0.1, 0.8, 0.4, 0.2, 0.6, 0.9, 0.7, 0.5] return features # 生成模拟数据集200 samples, 12 features, 2 classes: good/bad np.random.seed(42) X_pcb np.array([extract_pcb_features(fimg_{i}.jpg) for i in range(200)]) y_pcb np.random.binomial(1, 0.3, 200) # 30% defect rate # 归一化 X_pcb_norm, scaler_pcb normalize_data(X_pcb, methodzscore) y_pcb_onehot to_one_hot(y_pcb, 2) print(fPCB dataset shape: {X_pcb_norm.shape}, classes: {np.unique(y_pcb)})6.2 构建轻量 BP 网络2 层12→10→2专为边缘部署优化class LightBPNet: def __init__(self, n_input, n_hidden, n_output): self.W1 init_weights_xavier(n_input, n_hidden) self.b1 np.zeros((1, n_hidden)) self.W2 init_weights_xavier(n_hidden, n_output) self.b2 np.zeros((1, n_output)) def forward(self, X): z1 np.clip(np.dot(X, self.W1) self.b1, -500, 500) a1 sigmoid(z1) z2 np.dot(a1, self.W2) self.b2 exp_z2 np.exp(z2 - np.max(z2, axis1, keepdimsTrue)) a2 exp_z2 / np.sum(exp_z2, axis1, keepdimsTrue) return a2 def train(self, X, y, epochs1000, lr0.01, lambda_reg1e-4, verboseTrue): y_onehot to_one_hot(y, self.W2.shape[1]) loss_history [] for epoch in range(epochs): # Forward a2, cache forward_pass(X, self.W1, self.b1, self.W2, self.b2) loss compute_loss(y_onehot, a2) lambda_reg * (np.sum(self.W1**2) np.sum(self.W2**2)) # Backward self.W1, self.b1, self.W2, self.b2, _ backward_pass( y_onehot, cache, self.W1, self.W2, self.b1, self.b2, lr ) # L2 正则化更新在 backward_pass 外显式做更清晰 self.W1 - 2 * lambda_reg * self.W1 self.W2 - 2 * lambda_reg * self.W2 loss_history.append(loss) if verbose and epoch % 200 0: pred np.argmax(a2, axis1) acc np.mean(pred y) print(fEpoch {epoch}, Loss: {loss:.6f}, Acc: {acc:.4f}) return loss_history # 训练 net LightBPNet(n_input12, n_hidden10, n_output2) loss_curve net.train(X_pcb_norm, y_pcb, epochs1000, lr0.01, lambda_reg1e-4)6.3 模型序列化与边缘部署12KB 模型文件的生成与加载def save_model(net, filepath): 保存权重为 .npz 文件仅含必要数组 np.savez(filepath, W1net.W1, b1net.b1, W2net.W2, b2net.b2) print(fModel saved to {filepath}, size: {os.path.getsize(filepath)/1024:.1f} KB) def load_model(filepath): 加载模型返回一个具有 forward 方法的对象 data np.load(filepath) class LoadedNet: def __init__(self, W1, b1, W2, b2): self.W1, self.b1, self.W2, self.b2 W1, b1, W2, b2 def forward(self, X): z1 np.clip(np.dot(X, self.W1) self.b1, -500, 500) a1 sigmoid(z1) z2 np.dot(a1, self.W2) self.b2 exp_z2 np.exp(z2 - np.max(z2, axis1, keepdimsTrue)) a2 exp_z2 / np.sum(exp_z2, axis1, keepdimsTrue) return a2 return LoadedNet(data[W1], data[b1], data[W2], data[b2]) # 保存 save_model(net, pcb_bp_model.npz) # 加载并测试 loaded_net load_model(pcb_bp_model.npz) test_pred loaded_net.forward(X_pcb_norm[:5, :]) print(Test predictions (probabilities):) print(test_pred) print(Predicted classes:, np.argmax(test_pred, axis1))部署优势总结.npz文件是 NumPy 原生格式树莓派上pip install numpy即可加载无需 TensorFlow/PyTorch模型大小 12KB可通过 MQTT 协议秒级下发到 1000 设备forward方法无任何外部依赖可直接移植到 C/C用memcpy加载数组用cblas_dgemm替代np.dot本文还有配套的精品资源点击获取