ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

NumPy手写BP神经网络:Iris分类PyCharm工程与实验报告

NumPy手写BP神经网络:Iris分类PyCharm工程与实验报告

简介:这份面向机器学习初学者与课程实验学习者的BP神经网络实践包,围绕Iris鸢尾花数据集的三分类任务展开,提供可直接在PyCharm中运行的Python实现与配套实验文档。压缩包共4个文件、约342KB,包含2个py脚本、1份docx实验报告和1个csv数据文件,分别对应网络代码、实验过程记录与原始数据,便于对照阅读和复现。已有671人学习下载。内容覆盖数据预处理、输入/隐藏/输出层结构设定、权重初始化、前向传播、误差计算、反向传播、循环训练及准确率等指标评估,并讨论学习率、迭代次数和网络结构对分类性能的影响。读者可据此理解BP神经网络处理非线性分类问题的完整流程,也能借助实验报告梳理参数设置、结果分析与优化思路,适合课程设计、实验复现和入门练手。

1. 从一份 rar 说起:BP 神经网络做 iris 分类到底能练到什么

把 iris 三分类当成 BP 神经网络的练手题,是很多人绕不开的一站:数据干净、特征只有四维、标签三类,一个 4-8-3 的全连接网络跑上几百轮,测试集准确率通常能到 95% 以上。这份 PyCharm 工程把整条链路装进了一个 rar——数据加载、标准化、one-hot 编码、前向传播、反向传播、训练循环、损失曲线、混淆矩阵,再加一份结构完整的实验报告。它适合两类人:刚学完链式求导、想看看公式怎么落成 NumPy 矩阵运算的新手;以及要给课程设计或实验课找一套能跑通、能改、结构清楚的基线代码的开发者。网络是自己写的,梯度是自己推的,正因为没有调包,改起来才知道每一步到底在动什么。

2. 工程骨架:一份 PyCharm 工程里每个文件到底管什么

2.1 解压之后的目录结构

压缩包解开,第一眼看到的往往是一个带.idea的 PyCharm 工程目录。.idea是编辑器自己的配置(解释器路径、运行配置、代码风格),跟程序逻辑无关,换个机器打开会重新生成,删掉也不影响运行。真正要关心的是下面这几层:

bp-iris/ ├── data/ │ └── iris.csv # 150 行 × 5 列,前 4 列特征,第 5 列是类别名 ├── src/ │ ├── config.py # 超参集中管理:隐藏层宽度、学习率、轮数、batch │ ├── dataset.py # 加载 / 标签编码 / one-hot / 标准化 / 划分 / batch │ ├── model.py # BPNet 类:初始化、forward、backward、step │ ├── train.py # 训练循环、日志打印、loss 记录 │ └── evaluate.py # 准确率、混淆矩阵、precision/recall/F1 ├── outputs/ │ ├── loss_curve.png # 训练损失与验证准确率曲线 │ ├── confusion_matrix.png # 3×3 混淆矩阵热图 │ └── metrics.txt # 最终指标落盘,方便抄进报告 ├── main.py # 唯一入口,把上面这些串成一条流水线 ├── report/ │ └── 实验报告.docx # 网络结构、公式推导、超参表、结果分析 └── requirements.txt

这个拆法不是唯一的,但方向是对的:把「数据怎么来」「模型算什么」「训练怎么跑」「结果怎么评」切成四块,而不是全部塞进一个main.py。对新手来说单文件看着省事,可一旦实验报告要求你对比三组超参、换两种初始化,单文件就会迅速膨胀成五百行,改一个变量要在里面翻三遍。分层之后,超参对比只需要复制config.py改几行,其余不动。

outputs/这一层容易被忽略,但它其实是实验报告的弹药库。跑完一次训练,曲线图、混淆矩阵、指标文本自动落盘,写报告时直接拖进 Word,不用再手动截图。

2.2 环境怎么搭:解释器、依赖与运行配置

Python 版本建议 3.9 到 3.11,太老的版本np.random.default_rng的某些用法会有差异,太新的版本又可能遇到 matplotlib 后端的问题。依赖不多,requirements.txt里常见的是这四行:

numpy>=1.23 pandas>=1.5 matplotlib>=3.6 scikit-learn>=1.2 # 仅用于 train_test_split 和作为对照基线,不参与网络实现

注意最后一行注释——这个工程里 sklearn 的定位是「对照组」,不是「实现体」。网络的前向反向全部由 NumPy 完成,sklearn 只负责切分数据,以及在报告里跑一个MLPClassifier做性能参照。把这条边界划清楚,报告的「自实现 vs 调包」对比才有意义。

PyCharm 里最容易翻车的一步是解释器配置。新建工程时如果选了「New environment using Virtualenv」,会在工程目录下生成.venv;如果选了「Previously configured interpreter」,用的是系统全局 Python。后者看着省事,装依赖时容易污染全局环境,换台机器复现就麻烦了。我一般强制走虚拟环境,装完依赖后确认一下:

python -c "import numpy, pandas, matplotlib, sklearn; print(numpy.__version__)"

能打印出版本号,说明解释器选对了。如果 PyCharm 右下角显示的解释器和终端里which python的结果不一致,那就是典型的「编辑器里能跑、终端里报 ModuleNotFoundError」,血泪经验是:先在 PyCharm 的 Settings → Project → Python Interpreter 里确认路径,再把这个路径下的 python 直接拿去跑脚本。

还有一个运行配置的细节:main.py里读data/iris.csv用的是相对路径,PyCharm 默认的 Working directory 是工程根目录,所以能读到。但如果你在终端里cd src && python ../main.py,工作目录变了,相对路径就失效。稳妥的写法是用pathlib基于脚本位置拼绝对路径,这个后面在避坑那章会展开。

2.3 为什么不用 sklearn 一把梭

sklearn.neural_network.MLPClassifier三行代码就能出结果,为什么还要手写?因为这个项目的核心产出是实验报告,报告里需要网络结构图、需要前向反向的公式推导、需要说清楚「梯度是怎么算出来的」。调包的话,这一段只能写「调用了 MLPClassifier」,评分和答辩都撑不住。

手写带来的另一个好处是可改。隐藏层宽度、激活函数、损失函数、优化器、初始化方式,全都暴露在你自己的代码里,想换 ReLU 为 tanh 就是改一行,想加动量就是多存一个速度项。这种「可拆解性」是调包给不了的。代价是要自己处理形状对齐、数值稳定性、梯度检查这些琐碎事,而这恰恰是这个工程最值得读的部分。

3. 数据管线:从 iris.csv 到可以喂进网络的矩阵

3.1 加载与标签编码:字符串标签怎么变成 0/1/2

原始iris.csv长这样:四列浮点特征(花萼长度、花萼宽度、花瓣长度、花瓣宽度),最后一列是字符串类别名,三种各 50 条。网络只认数字,所以第一步是把类别名映射成整数,再转 one-hot。

# src/dataset.py import numpy as np import pandas as pd from sklearn.model_selection import train_test_split CLASS_NAMES = ["setosa", "versicolor", "virginica"] def load_iris_csv(path): df = pd.read_csv(path, header=None) X = df.iloc[:, :4].to_numpy(dtype=np.float64) y_str = df.iloc[:, 4].astype(str).str.strip() # 显式映射,不用 factorize,保证类别序号和 CLASS_NAMES 严格对应 mapping = {name: i for i, name in enumerate(CLASS_NAMES)} y = y_str.map(mapping).to_numpy(dtype=np.int64) if np.isnan(y).any(): raise ValueError(f"存在未知类别标签: {set(y_str) - set(mapping)}") y_onehot = np.eye(len(CLASS_NAMES))[y] # (150, 3) return X, y, y_onehot

这里有个关键选择:用显式的mapping而不是pd.factorize或astype("category").cat.codes。后两者是按字符串字典序编码的,setosa / versicolor / virginica恰好字典序和语义序一致,看起来没问题,但一旦换成别的数据集、或者 CSV 里类别名拼写有大小写差异,编码顺序就会悄悄变化。混淆矩阵的行列顺序依赖这个映射,错位之后所有指标都是错的,而代码不会报错——这是最难查的一类坑。

y保留整数形式用于最后的argmax对比和混淆矩阵,y_onehot用于计算交叉熵,两份都留着,避免在不同地方反复转换。

3.2 标准化:均值方差只能从训练集算

iris 的四个特征量纲差异明显:花萼长度在 4.3~7.9,花瓣宽度在 0.1~2.5。不标准化直接送进网络,梯度下降会在不同方向上被拉伸,收敛慢且对学习率极其敏感。标准化的做法很朴素——减均值除标准差,但均值方差必须只从训练集统计:

def standardize(train_X, test_X): mu = train_X.mean(axis=0, keepdims=True) # (1, 4) sigma = train_X.std(axis=0, keepdims=True) # (1, 4) sigma[sigma == 0] = 1.0 # 防除零 return (train_X - mu) / sigma, (test_X - mu) / sigma, mu, sigma

参数说明:keepdims=True让均值保持(1, 4)形状,可以直接和(n, 4)广播,省掉一次reshape。sigma的除零保护是防御性写法,iris 里用不上,但换成常数列特征时能救命。

用测试集统计量、或者全量数据统计量做标准化,属于数据泄露——测试集的分布信息提前泄露进了训练过程,验证准确率会虚高几个点,而且这种虚高在换数据集时不会复现,非常具有欺骗性。正确顺序永远是:先切分,再只用训练集拟合标准化参数,然后 transform 测试集。

3.3 划分与小批量迭代器

def prepare(path, seed=42, test_size=0.2): X, y, y_onehot = load_iris_csv(path) X_tr, X_te, y_tr, y_te, oh_tr, oh_te = train_test_split( X, y, y_onehot, test_size=test_size, random_state=seed, stratify=y ) X_tr, X_te, mu, sigma = standardize(X_tr, X_te) return dict(X_tr=X_tr, y_tr=y_tr, oh_tr=oh_tr, X_te=X_te, y_te=y_te, oh_te=oh_te, mu=mu, sigma=sigma)

stratify=y保证训练集和测试集里三类比例都是 1:1:1。iris 每类只有 50 条,不分层的话随机切分出 40% 测试集时,某一类可能只进来十几条,指标波动会很大。150 条样本的规模下,这个参数属于必开。

小批量迭代器不难写,但写不好会埋雷:

def iterate_batches(n_samples, batch_size, rng): idx = rng.permutation(n_samples) # 每轮重新打乱 for start in range(0, n_samples, batch_size): yield idx[start:start + batch_size]

rng是外部传进来的np.random.default_rng(seed)实例,而不是全局的np.random。用独立的 Generator 对象,好处是打乱顺序只受这个 seed 控制,不会因为别处调用了np.random.rand()而改变——这一点直接决定了实验能不能复现,后面避坑那章会细说。最后一批不足batch_size时直接切到末尾,形状是(余数, 4),矩阵运算天然支持,不需要额外补零。

4. 网络核心:前向、反向、训练循环的矩阵实现

4.1 参数初始化与前向传播的形状推演

网络结构定成 4-8-3:输入 4 维特征,隐藏层 8 个神经元配 ReLU,输出 3 类配 softmax。参数一共四个矩阵/向量,形状必须一次想清楚,否则后面反向传播会连环报错。

# src/model.py import numpy as np class BPNet: def __init__(self, n_in=4, n_hidden=8, n_out=3, seed=42): rng = np.random.default_rng(seed) # He 初始化:适配 ReLU,方差 2/fan_in self.W1 = rng.normal(0.0, np.sqrt(2.0 / n_in), (n_in, n_hidden)) self.b1 = np.zeros((1, n_hidden)) self.W2 = rng.normal(0.0, np.sqrt(2.0 / n_hidden), (n_hidden, n_out)) self.b2 = np.zeros((1, n_out)) def forward(self, X): z1 = X @ self.W1 + self.b1 # (m,4)@(4,8)+(1,8) -> (m,8) a1 = np.maximum(0.0, z1) # ReLU z2 = a1 @ self.W2 + self.b2 # (m,8)@(8,3)+(1,3) -> (m,3) z2 = z2 - z2.max(axis=1, keepdims=True) # softmax 数值稳定 exp = np.exp(z2) probs = exp / exp.sum(axis=1, keepdims=True) # (m,3) return z1, a1, probs

形状推演:X是(m, 4),W1是(4, 8),乘完是(m, 8),加偏置(1, 8)靠广播对齐;a1 @ W2得到(m, 3),正好每个样本三个类别的分数。偏置用(1, n)而不是(n,),是为了让广播规则永远成立,减少一类「为什么加出来维度不对」的困惑。

两个数值细节值得说:初始化用 He 而不是全零或标准正态。全零初始化会让同一层所有神经元梯度完全相同,训练完还是对称的,等于只有 1 个神经元在干活;标准正态(方差 1)在 4 维输入下会让z1的方差偏大,ReLU 打死一片。He 初始化按sqrt(2/fan_in)缩放,正好把前向方差稳定在 1 附近。

softmax 前的减最大值操作是纯数值技巧。exp在输入较大时会上溢成inf,inf/inf得到nan,loss 直接变 NaN。减去每行最大值不改变 softmax 结果(分子分母同乘一个常数),但把指数范围压到 0 以下,杜绝上溢。

4.2 反向传播:从 softmax + 交叉熵 到 dW1

反向这一段是很多人的卡点,其实推导清楚了就是一个链式法则的机械展开。softmax 配交叉熵的联合梯度有一个非常干净的结果:dL/dz2 = (probs - y_onehot) / m。这个结论值得记住,它省掉了对 softmax 雅可比矩阵的显式求导。

def loss(self, probs, y_onehot): m = probs.shape[0] eps = 1e-12 ce = -np.sum(y_onehot * np.log(probs + eps)) / m return ce def backward(self, X, y_onehot, cache): z1, a1, probs = cache m = X.shape[0] dz2 = (probs - y_onehot) / m # (m,3) softmax+CE 的联合梯度 dW2 = a1.T @ dz2 # (8,m)@(m,3) -> (8,3) db2 = dz2.sum(axis=0, keepdims=True) # (1,3) da1 = dz2 @ self.W2.T # (m,3)@(3,8) -> (m,8) dz1 = da1 * (z1 > 0.0) # ReLU 导数:正区间为 1 dW1 = X.T @ dz1 # (4,m)@(m,8) -> (4,8) db1 = dz1.sum(axis=0, keepdims=True) # (1,8) return {"W1": dW1, "b1": db1, "W2": dW2, "b2": db2} def step(self, grads, lr): self.W1 -= lr * grads["W1"] self.b1 -= lr * grads["b1"] self.W2 -= lr * grads["W2"] self.b2 -= lr * grads["b2"]

几个参数的含义要交代清楚:m是当前 batch 的样本数,除以它意味着损失取的是 batch 内的平均值,这样学习率不会随 batch 大小漂移——batch 从 16 改成 32,梯度尺度不变。db2用sum(axis=0)而不是mean,是因为/m已经在dz2里做过了,再平均一次会让梯度缩小m倍,表现为「学习率明明设的 0.1 却几乎不更新」,这个坑很隐蔽。

dz1 = da1 * (z1 > 0.0)里的(z1 > 0.0)是布尔数组,和浮点相乘时自动转成 0/1,正好是 ReLU 的导数。注意必须用z1而不是a1判断,虽然在这两个都大于 0 时结果一样,但语义上导数是在激活前的值上取的。

形状自检可以写成一个断言,放在backward开头,出问题时第一时间定位:

def _check_shapes(self, grads): assert grads["W1"].shape == self.W1.shape, "dW1 形状不匹配" assert grads["W2"].shape == self.W2.shape, "dW2 形状不匹配" assert grads["b1"].shape == self.b1.shape, "db1 形状不匹配" assert grads["b2"].shape == self.b2.shape, "db2 形状不匹配"

4.3 训练循环与评估:loss 曲线、混淆矩阵与指标落盘

训练循环的骨架很标准:每轮打乱、按 batch 前向反向更新、轮末在验证集上算一次指标。

# src/train.py import numpy as np def train(model, data, epochs=1000, lr=0.1, batch_size=16, seed=42): rng = np.random.default_rng(seed) X_tr, oh_tr = data["X_tr"], data["oh_tr"] X_te, y_te = data["X_te"], data["y_te"] history = {"loss": [], "train_acc": [], "val_acc": []} for epoch in range(epochs): idx = rng.permutation(X_tr.shape[0]) epoch_loss = 0.0 for start in range(0, len(idx), batch_size): b = idx[start:start + batch_size] cache = model.forward(X_tr[b]) epoch_loss += model.loss(cache[2], oh_tr[b]) * len(b) grads = model.backward(X_tr[b], oh_tr[b], cache) model.step(grads, lr) history["loss"].append(epoch_loss / len(idx)) history["train_acc"].append(accuracy(model, X_tr, data["y_tr"])) history["val_acc"].append(accuracy(model, X_te, y_te)) if (epoch + 1) % 100 == 0: print(f"epoch {epoch+1:4d} | loss {history['loss'][-1]:.4f} " f"| train {history['train_acc'][-1]:.4f} " f"| val {history['val_acc'][-1]:.4f}") return history

epoch_loss累加时乘了len(b),最后除以总样本数,这样最后一个不满 batch 的小批不会因为权重过大而扭曲本轮平均损失。日志每 100 轮打一次,太密会刷屏,太疏又看不出收敛趋势。

评估部分给一个手写的混淆矩阵,不依赖 sklearn 的confusion_matrix:

def confusion_matrix(y_true, y_pred, n_class=3): cm = np.zeros((n_class, n_class), dtype=np.int64) for t, p in zip(y_true, y_pred): cm[t, p] += 1 return cm def accuracy(model, X, y): probs = model.forward(X)[2] return float((probs.argmax(axis=1) == y).mean())

混淆矩阵的行是真实类别、列是预测类别,cm[i][j]表示「真实是 i、被预测成 j」的样本数。对角线是分对的,非对角线看错到哪儿去了——iris 里常见的错法是 versicolor 和 virginica 互相串,因为这两个类在特征空间里本来就有重叠,setosa 基本是线性可分的,几乎不会错。把这个观察写进实验报告,比只贴一个 96.7% 的准确率有说服力得多。

指标文件metrics.txt里建议落这几项:最终训练/测试准确率、每个类别的 precision/recall/F1、混淆矩阵、总训练耗时。用np.savetxt或者直接f.write都行,关键是格式固定,多组实验之间能直接对比。

5. 避坑与常见问题排查:五个把准确率按在地上摩擦的细节

5.1 随机性与数据泄露相关的坑

现象一:同一份代码连跑三次,准确率分别是 93.3%、96.7%、90.0%,实验报告不知道该写哪个数。

原因是工程里混用了全局随机源。train_test_split有自己的random_state,参数初始化用了np.random.randn,batch 打乱用了np.random.shuffle——三处都受全局状态影响,只要有一处没固定种子,整条链路就不可复现。更麻烦的是,np.random.seed(42)在 import 了别的库之后调用,某些库在 import 时已经消耗过随机状态,结果依然会变。

解决办法是把随机源全部显式化。切分用random_state=seed,初始化和打乱各自用独立的np.random.default_rng(seed),并且每个需要随机的地方传不同的 seed 派生值,比如default_rng(seed)和default_rng(seed + 1),避免两条流产生相同的序列。固定之后跑三次结果必须完全一致,不一致就说明还有漏网的全局随机调用。

现象二:测试集准确率冲到 99%,换一组数据或者多跑几轮就掉回 95%,报告里的结论站不住。

按经验,八成是标准化或者特征选择把测试集信息泄露进来了。典型写法是先对全部 150 条样本算均值和标准差,再切分训练测试——测试集的分布信息参与了训练前的预处理,模型在测试集上表现虚高。类别不平衡时,重采样(过采样/欠采样)做在切分之前也是同样的错误。

正确的顺序是「先切分,再拟合预处理参数,最后 transform」。标准化参数只用训练集算,测试集调用同一个mu/sigma;如果做交叉验证,每一折内部都要重新拟合一次,不能全量拟合后复用。

现象三:混淆矩阵看着不对劲,某一类的召回率是 0,但模型明明在训练集上准确率很高。

标签编码顺序和CLASS_NAMES列表不一致。用astype("category").cat.codes时,编码按字典序来;用pd.factorize时按出现顺序来;手动map时按你写的字典来。三种方式在 iris 上恰好都能对,但换一份类别名不同的 CSV、或者某一行末尾多了个空格,顺序就变了。变完之后 one-hot 的顺序、混淆矩阵的轴、argmax的结果三者不同步,指标全乱,而代码不会抛异常。

解决办法是显式映射加校验,就是 3.1 里那段代码:先定义CLASS_NAMES常量,用字典映射,映射后检查有没有 NaN,发现未知类别直接抛错。同时把CLASS_NAMES传给画混淆矩阵的函数当轴标签,保证图和编码同源。

5.2 训练过程与工程配置相关的坑

现象四:loss 在前几十轮剧烈震荡,甚至出现 NaN,训练完全跑不下去。

三个常见诱因,按排查顺序来:学习率太大(0.1 在标准化之后通常没问题,不标准化时 0.1 就可能炸);softmax 没做减最大值导致exp上溢;标签里混进了越界值导致np.eye(3)[y]报 IndexError,或者 loss 计算出log(0)。

处理方式:先确认输入已经标准化,均值为 0、方差为 1;softmax 里加上z2 - z2.max(axis=1, keepdims=True);loss 里给log加1e-12的平滑项。如果还是 NaN,把学习率降到 0.01 观察,同时打印第一个 batch 的probs和grads的范数——梯度范数在 1e3 以上就是明显偏大,问题在尺度上。

现象五:PyCharm 里点运行一切正常,命令行跑或者换台机器就报FileNotFoundError: data/iris.csv。

相对路径依赖工作目录。PyCharm 的运行配置默认把 Working directory 设成工程根目录,所以pd.read_csv("data/iris.csv")能找到;终端里从src/目录启动,工作目录变成了src/,路径就断了。换机器时如果目录层级不同,同样会挂。

解决办法是用pathlib基于文件位置拼路径,让读数据不依赖启动目录:

from pathlib import Path ROOT = Path(__file__).resolve().parent.parent # 从 src/ 回到工程根 IRIS_PATH = ROOT / "data" / "iris.csv" OUTPUT_DIR = ROOT / "outputs" OUTPUT_DIR.mkdir(exist_ok=True) # 输出目录不存在就建,省得手动 mkdir

这个写法在 PyCharm、终端、Jupyter 里行为一致,是我现在写任何脚本的默认姿势。顺带把outputs/的创建也放进来,避免第一次跑的时候因为目录不存在而savefig失败——这种错误通常出现在深夜赶报告的时候,代价是一个小时。

6. 二次开发:把这套基线改成能写进简历的实验平台

跑通只是起点,这个工程真正的价值在于它足够小,小到你可以放心地拆。第一步我会把超参从config.py里彻底外置成命令行参数,用argparse或者一个experiment.yaml,这样可以写一个 shell 循环跑网格:

for lr in 0.01 0.05 0.1; do for h in 4 8 16; do python main.py --lr $lr --hidden $h --epochs 1000 --tag "lr${lr}_h${h}" done done

每个 tag 对应一个独立的outputs/{tag}/目录,loss 曲线和指标各存一份,最后一并读进 pandas 画对比图。这一步做完,报告里的「超参对收敛速度的影响」就是真数据,而不是编的。

第二步是加早停和学习率衰减,这两个改动小、收益直观。早停盯验证集 loss,连续 30 轮不降就break,同时把验证 loss 最低那一轮的参数存下来当最终模型——别用最后一轮的参数,震荡时最后一步未必最好。学习率衰减简单点就用lr * 0.95 ** (epoch // 100),复杂点换成余弦退火,效果差异在 iris 这种小数据上不明显,但写进报告里是个加分项。

改造方向改动位置预期收益
早停 + 最优权重保存train.py训练循环避免过拟合末期掉点
Adam 优化器model.py新增step_adam对学习率不敏感,收敛更快
K 折交叉验证main.py外层150 条样本下指标更可信
特征标准化方式对比dataset.py报告里多一组分析维度

第三步是拿sklearn.MLPClassifier做严格对照:同样的划分、同样的隐藏层、同样的轮数,把两边的准确率和训练耗时放进一张表。常见的结果是自实现版本慢一些但可控,调包版本快但黑盒。这个对比写进报告,比单方面吹手写实现客观得多。

改的时候有个习惯我一直保持:任何一次结构调整,先在不改逻辑的前提下跑一遍原版,记下基线指标,改完再跑一遍,两次的数字必须能对上。对不上就是引入 bug 了,而不是「模型效果变好了」。在那之前我吃过亏——调了半天超参发现准确率提升是因为数据划分的随机种子变了,白忙一个下午。从那以后我每次动代码前都强制走一遍基线复现,把数字贴在便签上。希望这套东西能帮你少走两段弯路。

本文还有配套的精品资源,点击获取

返回列表