简介:CIFAR10.zip 面向刚接触神经网络与深度学习、希望用卷积神经网络完成图像分类实战的学习者,提供从数据说明到模型训练与验证的一套可直接运行的配套材料。压缩包共6个文件,体量约2.82MB,包含2个Python脚本、1个H5权重文件、2张PNG结果图和1个TXT说明,分别对应训练代码、已训练模型、损失与准确率曲线及测试效果展示,便于快速复现实验并观察训练过程。该资源已有4780人学习下载,适合课程实验、入门项目练习和代码对照。读者可直接使用完整代码与训练好的模型文件,省去环境搭建后从零调参的重复工作,也能借助曲线图与测试图判断模型收敛情况和分类表现,并在此基础上修改网络结构、调整超参数或迁移到相近的图像分类任务,形成较完整的卷积神经网络实践闭环。
1. 这份 CIFAR-10 压缩包:六万张图、十类互斥,够不够撑起一条 CNN 训练链路
拿到一个只写着CIFAR10.zip的压缩包,很多人的第一反应是随手解开看一眼,然后被b'data'这种字节串劝退。它本质上是深度学习入门最经典的一块试金石:六万张 32×32 彩色图、十个互斥类别、五万张训练一万张测试,配一套卷积神经网络加 TensorFlow 就能打通从数据管线到精度评估的完整链路。体量小是它最大的好处——单卡几十分钟跑完一轮,归一化漏掉、通道顺序搞反、标签错位这些翻车当场就能看出来。适合正在啃卷积神经网络、想亲手把训练脚本从零跑通的人,也适合已经会调 API、却说不清 3072 这个数字怎么来的从业者拿来当基线。数据集小不代表没门槛,下面把这些门槛一条条拆开。
2. 解压与数据管线:3072 个字节怎么排,tf.data 怎么接
2.1 先看清目录:batches.meta 与 data_batch_1..5 各是什么
解压之后正常会得到一个cifar-10-batches-py目录,里面躺着七个文件:data_batch_1到data_batch_5、一个test_batch,外加一个batches.meta。每份批次文件都是 Python 的 pickle 序列化字典,键是 bytes 类型(b'data'、b'labels'、b'filenames'),这是早年 Python 2 序列化留下的习惯,Python 3 直接pickle.load会得到带b''前缀的键,这是新手第一个卡点。
b'data'的形状是(10000, 3072),uint8;b'labels'是 0–9 的整数列表;batches.meta里的label_names才是那十个类别的英文名,顺序依次对应标签 0–9,不要自己凭印象去猜顺序。常见做法是先把这个 meta 捞出来存成变量,后面画图、算混淆矩阵都靠它。
import pickle from pathlib import Path ROOT = Path("./cifar-10-batches-py") with open(ROOT / "batches.meta", "rb") as f: meta = pickle.load(f, encoding="bytes") LABEL_NAMES = [n.decode("utf-8") for n in meta[b"label_names"]] print(LABEL_NAMES) # ['airplane','automobile','bird','cat','deer','dog','frog','horse','ship','truck']这里encoding="bytes"是关键参数:告诉 pickle 遇到 Python 2 的str时按字节原样返回,而不是尝试用 ASCII 解码。不写这个参数,pickle.load在某些环境下会直接抛UnicodeDecodeError,报错信息还很隐晦。
2.2 用 tf.data 搭一条可复现的输入管线
单次读取五万张图完全放得进内存(50000×3072 字节约 147MB),所以没必要上tf.data.TFRecordDataset那一套重型武器。常规做法是先把 numpy 数组一次性读进来,再用from_tensor_slices切片。要注意 reshape 的顺序:(N, 3072)拆成(N, 3, 32, 32)再转置成(N, 32, 32, 3),这是后面 2.3 要展开的重点。
import numpy as np import pickle def load_batch(path): with open(path, "rb") as f: d = pickle.load(f, encoding="bytes") # 3062 = 3 通道 * 32 * 32,平面排列:先 R 全部,再 G 全部,再 B 全部 x = d[b"data"].reshape(-1, 3, 32, 32).transpose(0, 2, 3, 1) y = np.array(d[b"labels"], dtype=np.int32) return x, y def load_train(root): xs, ys = [], [] for i in range(1, 6): x, y = load_batch(root / f"data_batch_{i}") xs.append(x); ys.append(y) return np.concatenate(xs), np.concatenate(ys) x_train, y_train = load_train(ROOT) x_test, y_test = load_batch(ROOT / "test_batch") print(x_train.shape, y_train.shape, x_test.shape) # (50000, 32, 32, 3) (50000,) (10000, 32, 32, 3)接tf.data的顺序也有讲究:先shuffle再map再batch再prefetch。把map放在batch之前,每张图单独做类型转换,内存峰值低;放到batch之后虽然批量处理更快,但float32的中间张量会多占一份内存。prefetch(AUTOTUNE)让 CPU 准备下一批数据的同时 GPU 在算当前批,这是把 GPU 利用率从 60% 拉到 90% 以上最便宜的一招。
import tensorflow as tf AUTOTUNE = tf.data.AUTOTUNE def make_ds(x, y, training=False, batch_size=128): ds = tf.data.Dataset.from_tensor_slices((x, y)) if training: # buffer 取 10000 足够打散,太大只是白占内存 ds = ds.shuffle(buffer_size=10000, seed=42, reshuffle_each_iteration=True) ds = ds.map(lambda img, lab: (tf.cast(img, tf.float32) / 255.0, lab), num_parallel_calls=AUTOTUNE) return ds.batch(batch_size).prefetch(AUTOTUNE) train_ds = make_ds(x_train, y_train, training=True, batch_size=128) test_ds = make_ds(x_test, y_test, training=False, batch_size=256)参数说明:shuffle的buffer_size不是越大越好,超过数据集长度没有额外收益,反而每次迭代都要重新填缓冲区;seed固定下来是复现的前提,但注意reshuffle_each_iteration=True意味着每轮打乱顺序不同,这是训练想要的效果。测试集不需要 shuffle,也不需要增强,batch_size给 256 纯粹是为了推理快一点。
2.3 通道顺序与归一化:那 3072 个字节怎么排的
3072 = 3 × 32 × 32这个等式有两种拆法,拆错了模型也能跑,只是准确率会莫名其妙地卡在 45% 左右。官方数据是按“平面”存的:前 1024 个字节全是 R 通道,中间 1024 个是 G,最后 1024 个是 B。所以必须先reshape(-1, 3, 32, 32)再transpose(0, 2, 3, 1),如果图省事直接reshape(-1, 32, 32, 3),得到的是一张颜色和形状都错位的花屏图——模型依然能学,但学到的是噪声。
验证方法很简单,抽一张出来用 matplotlib 画一下,标签是airplane的话应该看到一架灰蓝色的飞机轮廓,而不是一片彩色噪点。这一步花三十秒,能省掉后面几个小时“为什么 loss 降不下去”的排查。
归一化同样不能省。uint8直接喂进网络,输入数值在 0–255 之间,配合默认初始化的权重,第一层激活值直接炸到饱和区,梯度全是零。除以 255 是最低要求;追求更高精度的话,用训练集的逐通道均值和标准差做标准化,通常能再涨零点几个点。数据集量级小,算这两个统计量几乎不花时间。
# 逐通道标准化,均值方差只从训练集算,避免测试集信息泄漏 mean = x_train.mean(axis=(0, 1, 2)) / 255.0 std = x_train.std(axis=(0, 1, 2)) / 255.0 print(mean, std) # 大致在 [0.49,0.48,0.45] / [0.25,0.25,0.25] 附近注意:标准化统计量一定要只用训练集算。拿全量数据算均值再训练,是数据泄漏里最不容易被发现的一种,测试精度会虚高一点点,等你换真数据就露馅。
3. 卷积网络选型:三层卷积的参数量账本与 BN、Dropout 的位置
3.1 一个能跑过 80% 的三层卷积基线
CIFAR-10 上不需要一上来就 ResNet。三层卷积加 BN 的基线,参数量十几万,单卡几十秒一轮,三十轮左右稳定在 82%–85%。这个数字看起来不高,但它是一个诚实的基线:你知道每一层在干什么,出了问题能定位到具体层。
结构上按“卷积—BN—激活—池化”的节奏走,通道数 32→64→128 递增,每次池化把空间尺寸砍半(32→16→8),最后用全局平均池化替代Flatten。用全局平均池化代替展平再接全连接,参数量能少一个量级,过拟合风险也低——CIFAR-10 只有五万张图,全连接层是过拟合重灾区。
from tensorflow.keras import layers, models def build_baseline(num_classes=10): inputs = layers.Input(shape=(32, 32, 3)) x = layers.Conv2D(32, 3, padding="same", use_bias=False)(inputs) x = layers.BatchNormalization()(x) x = layers.Activation("relu")(x) x = layers.Conv2D(32, 3, padding="same", use_bias=False)(x) x = layers.BatchNormalization()(x) x = layers.Activation("relu")(x) x = layers.MaxPooling2D(2)(x) # 32 -> 16 x = layers.Conv2D(64, 3, padding="same", use_bias=False)(x) x = layers.BatchNormalization()(x) x = layers.Activation("relu")(x) x = layers.Conv2D(64, 3, padding="same", use_bias=False)(x) x = layers.BatchNormalization()(x) x = layers.Activation("relu")(x) x = layers.MaxPooling2D(2)(x) # 16 -> 8 x = layers.Conv2D(128, 3, padding="same", use_bias=False)(x) x = layers.BatchNormalization()(x) x = layers.Activation("relu")(x) x = layers.GlobalAveragePooling2D()(x) # 8x8x128 -> 128 x = layers.Dropout(0.3)(x) outputs = layers.Dense(num_classes)(x) # 输出 logits,不做 softmax return models.Model(inputs, outputs) model = build_baseline() model.summary()三个细节值得说。第一,use_bias=False是因为后面紧跟BatchNormalization,BN 自带的可学习偏移beta已经承担了偏置的作用,再留一个 bias 是纯冗余参数。第二,输出层不加softmax,配合from_logits=True的损失函数,数值稳定性比先 softmax 再取对数好得多,这是 TensorFlow 官方推荐写法。第三,padding="same"保证卷积不改变空间尺寸,尺寸变化全部交给池化层,排查问题时一眼能对上。
3.2 批归一化与 Dropout 该放在哪一层之后
BN 的位置主流有两种:卷积后、激活前(Conv→BN→ReLU),或者卷积后、激活后(Conv→ReLU→BN)。前者是原论文的写法,也是绝大多数预训练权重的排布方式,选它不会有争议。放到激活之后,ReLU 把负半轴清零,BN 再去减均值除方差,等于把一批已经稀疏的激活强行拉回正态分布,效果通常略差。
Dropout 的位置更讲究。放在卷积层之间意义不大,因为卷积核参数共享,单个神经元的丢弃对特征图影响有限,反而拖慢收敛。放在全局平均池化之后、分类层之前,才是它该待的地方——这一层参数最多、最容易记住训练集的噪声。比率 0.3 是个稳妥起点,数据集小可以到 0.5,但超过 0.5 容易欠拟合,训练损失都降不下去。
还有个小陷阱:BatchNormalization在训练和推理两种模式下的行为不同(用批统计量还是滑动统计量)。自己写评估循环时忘了传training=False,评估结果会随 batch 组成浮动,同一份模型两次评估差两三个点,很玄学。
3.3 参数量与显存的快速估算
不用真跑一遍summary()也能心算出量级:一个卷积层的参数是(k × k × Cin + 1) × Cout,去掉 bias 就是k × k × Cin × Cout。按这个公式,基线模型五层卷积的参数量分别是 864、9216、18432、36864、73728,加起来约 13.9 万;三个 BN 层各带2 × C个参数,合计不到 500;最后的分类层128 × 10 + 10。总量约 14 万参数,权重文件 500 多 KB,随便一台笔记本都能跑。
显存估算看的是激活值而不是参数。以 batch=128 为例,第一层输出128 × 32 × 32 × 32个 float32,约 16MB;整个前向过程的中间张量峰值大概在 100–200MB 量级,加上反向传播要缓存的前向激活,训练时 1GB 显存绰绰有余。真正吃显存的是你随手把 batch 调到 1024,或者数据增强里塞了RandomRotation的多次采样。
| 组件 | 参数量 | 占比 | 备注 |
|---|---|---|---|
| 五层卷积核 | 约 139,104 | 94% | 3×3 小核,通道 32/64/128 |
| BN 层(gamma/beta) | 约 448 | 0.3% | 每层 2×C |
| 分类层 | 1,290 | 0.9% | 128→10 |
| 合计 | 约 140,842 | 100% | 权重文件约 550KB |
4. 训练循环与调参:学习率、增强、批次大小的取值边界
4.1 训练脚本骨架:compile/fit 与手写循环的取舍
model.compile+model.fit能覆盖九成场景,回调、验证集、早停都是现成的。什么时候该切到手写GradientTape循环?需要梯度裁剪、需要多任务加权损失、需要在 batch 内部做 MixUp 这种“先算损失再更新”的操作时。入门阶段没必要折腾,先把 fit 跑通。
损失函数选SparseCategoricalCrossentropy(from_logits=True),因为标签是 0–9 的整数而不是 one-hot。指标用accuracy,但注意它是在 batch 内逐批平均的,和各批次样本数不等时的真实精度有细微差异,最终评估要用model.evaluate单独跑一遍。
import math import tensorflow as tf BATCH = 128 EPOCHS = 30 steps_per_epoch = math.ceil(len(x_train) / BATCH) total_steps = EPOCHS * steps_per_epoch lr_schedule = tf.keras.optimizers.schedules.CosineDecay( initial_learning_rate=3e-3, decay_steps=total_steps, alpha=0.05, # 末尾学习率降到初值的 5% warmup_target=3e-3, warmup_steps=int(0.1 * total_steps), # 前 10% 步数线性升温 ) optimizer = tf.keras.optimizers.Adam(learning_rate=lr_schedule) loss_fn = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True) model.compile(optimizer=optimizer, loss=loss_fn, metrics=["accuracy"]) history = model.fit(train_ds, validation_data=test_ds, epochs=EPOCHS)warmup_target和warmup_steps这两个参数在较新的tf.keras里才有。本地版本不支持的话,换成tf.keras.callbacks.LearningRateScheduler手动写一个分段函数,效果一样,多几行代码而已。别为了用新 API 去升整个 TensorFlow,工程环境里牵一发动全身。
4.2 学习率调度与 warmup 的实际取值
Adam 配 3e-3 的初始学习率,在这个模型规模上属于“激进但没到崩”的区间。想更稳一点,用 1e-3;想更快收敛、接受前期 loss 抖一抖,3e-3 也行。SGD 的话完全是另一套参数:初始 0.1、momentum 0.9、weight decay 5e-4,配上 cosine 衰减,最终精度通常比 Adam 高半个点到一个点,代价是要调更多轮。
warmup 存在的意义是避免训练最开始那几十步把权重带偏。BN 层的滑动统计量在最初几步还没收敛,此时大学习率会让参数跑飞,表现为 loss 前 100 步剧烈震荡甚至变成nan。前 10% 步数线性升温,是一个非常保守但几乎不会错的选择。
判断学习率是否合适的土办法:看前两轮训练损失。降得太慢(两轮后还在 2.0 以上)说明小了;第一轮就掉到 0.5 以下然后第二轮反弹,说明大了。理想的曲线是第一轮结束在 1.2–1.5,第二轮 0.9 左右,然后平稳下滑。
4.3 数据增强的消融:哪些增强在 32×32 上真的有用
32×32 是个很尴尬的尺寸,图像增强的很多标准操作在这里会引入伪影。水平翻转几乎无脑可用,十个类别里没有哪个的语义会因为左右翻转而改变;随机平移 10% 也安全,但填充方式要选reflect而不是默认的零填充,零填充会在边缘造出一圈黑边,模型很快学会“看到黑边就认为是平移过的样本”,学偏了。
随机旋转要谨慎。旋转 15 度在 32×32 上意味着大量像素被重采样,细节损失严重,同时四个角会露出填充区域。实测下来 5 度以内还有点用,超过 10 度基本是负收益。随机裁剪(crop + pad)在 ImageNet 上是标配,在这个尺寸上如果把 32 裁到 28 再补回去,物体的关键部位经常被切掉,猫的耳朵没了,模型就分不清猫和狗。
data_aug = tf.keras.Sequential([ layers.RandomFlip("horizontal"), layers.RandomTranslation( height_factor=0.1, width_factor=0.1, fill_mode="reflect"), layers.RandomZoom(height_factor=0.1, fill_mode="reflect"), layers.RandomRotation(factor=0.03, fill_mode="reflect"), # 约 ±5 度 ], name="aug") # 训练时接在模型最前面,推理时自动跳过 inputs = layers.Input(shape=(32, 32, 3)) x = data_aug(inputs) x = model(x) train_model = models.Model(inputs, x)增强层直接嵌进模型有个副作用:model.save保存下来的是带增强的完整图,部署时不用手动拆,training=False时增强自动关闭。这是 Keras 预处理层设计得比较舒服的一点。
再往上一档是 Cutout 和 MixUp。Cutout 在图上挖一个 8×8 的方块填零,逼模型不能只依赖局部特征,通常能涨 1–2 个点;MixUp 把两张图按比例混合、标签也按比例混,对校准性和泛化都有好处,但训练轮数要相应加长。这两个属于“基线跑通之后再上”的优化,一开始就堆上去,出了问题分不清是增强的锅还是模型的锅。
5. 避坑与排查:准确率恰好 10% 的五个翻车现场
5.1 准确率正好 0.1:标签错位与 logits 冲突
现象很典型:训练三十轮,准确率稳定在 0.1000 附近,损失停在 2.30 左右不动。ln(10) ≈ 2.3026这个数字就是十类均匀分布的信息熵,说明模型输出的概率分布和随机猜没区别。
原因通常有两个。一是标签编码错位:某些数据整理脚本会把 0–9 存成 1–10,多出来的那个 10 超出Dense(10)的输出范围,被静默截断或当成非法值。二是损失函数和输出层打架:输出层已经加了softmax,损失函数又设了from_logits=True,等于对概率值再做一次 softmax,梯度被压得极小。
解决办法是先做一次“零训练检查”:不训练,直接把模型在训练集上前向一遍,看准确率是不是 0.1。然后把np.unique(y_train)打出来确认范围是[0..9],再检查损失函数的from_logits和模型最后一层是否匹配。两分钟能定位的问题,别耗在调学习率上。
5.2 损失在降但精度上不去:通道顺序和归一化
现象:训练损失从 2.3 降到 1.5 就不再动了,准确率卡在 40%–50%,模型看起来“在学”,但学得很吃力。
原因八成是 2.3 说的那个坑:reshape(-1, 32, 32, 3)直接把平面数据当 HWC 用了。这种错位不会报错,只会让每张图变成马赛克,模型被迫从一堆错位像素里找模式,能学到一点统计规律,但学不到形状。另一个可能是忘了除 255,输入值域 0–255 让 BN 层疲于奔命。
解决方式:抽一张图plt.imshow(x_train[0]),肉眼确认是不是一架飞机。图像正常,再去检查x_train.max()是不是 1.0 附近。这两个检查做完,问题基本就锁死了。
5.3 训练集 99%、测试集 60%:划分泄漏与模型容量失配
现象:训练准确率一路飙到 99%,验证准确率在 60% 附近反复横跳,两个曲线越拉越开。
先怀疑划分。用 numpy 手动切分时,x[:50000]和x[50000:]这种写法在数据本身有序(比如按类别排好)的情况下,会让训练集完全没有某个类别。检查方式是打印训练集和测试集的标签直方图,十个类别的占比应该都在 10% 左右。
划分没问题的话,就是容量过剩。五万张图撑不起一个带三四个全连接层的大模型,把Dense(512)换成GlobalAveragePooling加Dropout(0.5),或者干脆砍掉一半通道数,验证曲线立刻收敛。数据量小的时候,“模型小一点”几乎总是对的。
5.4 跑到第三轮突然 OOM:prefetch 与 cache 的隐性开销
现象:前两轮正常,第三轮开始报ResourceExhaustedError,显存占用一路爬升不释放。
tf.data的cache()是常见元凶——把整个数据集缓存到内存或显存,五万张 32×32 的图(float32)是 600MB 级别,看着不多,但和模型激活值叠加就爆了。另一个可能是prefetch(AUTOTUNE)在数据预处理复杂时会把缓冲区开得很大。解决办法是把cache()放到归一化之前(缓存 uint8 而不是 float32,省四分之三),prefetch从AUTOTUNE改成固定值 2。
还有一类是评估循环里忘了del中间张量,或者用model.predict时 batch 给得太大。排查顺序建议是:先把 batch 减半试一次,好了就是显存问题;没好就把cache去掉,再不行就上tf.config.experimental.get_memory_info看实际占用。
5.5 同一份代码两次跑出不同结果:随机种子的三层覆盖
现象:代码一个字没改,两次训练最终精度差两个点,损失曲线形状都不一样。
TensorFlow 的随机性来自三个地方:Python 的random、numpy 的np.random、TensorFlow 自己的图级和执行级种子。三个都要设,少一个都不算复现。tf.data的shuffle还要单独给seed,num_parallel_calls开的并行 map 在浮点运算顺序上也可能引入微小差异。
import os, random import numpy as np import tensorflow as tf def set_seed(seed=42): os.environ["PYTHONHASHSEED"] = str(seed) random.seed(seed) np.random.seed(seed) tf.random.set_seed(seed) # 强制确定性算子,速度会慢一点,换来结果可复现 tf.config.experimental.enable_op_determinism() set_seed(42)enable_op_determinism()是有代价的,某些算子会退化到非优化实现,训练慢 10%–20%。调试阶段开着,正式跑长训练时再关掉。另外 GPU 上的cudnn卷积在默认配置下是非确定性的,要彻底复现得设TF_DETERMINISTIC_OPS=1,但一般项目里做到前四步就够了。
6. 进阶验证:把 90% 这个数字坐实的三件事
精度报出来之前,先问自己三个问题:这个数字是在哪份数据上算的、每一类都好吗、换个批次还算得出来吗。
第一件事是混淆矩阵。总体精度 88% 可能意味着飞机和船都是 95%,而猫和狗只有 70%。这两个类别在 32×32 分辨率下本来就难分,毛发纹理几乎糊成一团。看混淆矩阵能立刻知道该往哪个方向优化:是加数据增强,还是干脆承认这批数据的天花板。
第二件事是分类报告里的每类召回。宏平均(macro avg)和加权平均(weighted avg)差距大,说明类别不平衡;差距小但都低,说明模型整体欠拟合。这两个数字放一起看,比单看一个 accuracy 信息量大得多。
import numpy as np from sklearn.metrics import classification_report, confusion_matrix logits = model.predict(test_ds, verbose=0) y_pred = np.argmax(logits, axis=1) print(classification_report(y_test, y_pred, target_names=LABEL_NAMES, digits=4)) cm = confusion_matrix(y_test, y_pred) pairs = sorted( [(int(cm[i, j]), LABEL_NAMES[i], LABEL_NAMES[j]) for i in range(10) for j in range(10) if i != j], reverse=True)[:5] print("最容易混的五对:", pairs) # 典型输出里 cat/dog、automobile/truck 会排在前列第三件事是测试时增强(TTA)的一致性检查。原图和水平翻转各推理一次,概率取平均,正常情况下精度会涨 0.3–0.8 个点。如果 TTA 之后反而掉了,说明模型对翻转敏感,八成是训练时没开RandomFlip,或者数据里混进了方向性很强的样本。
def predict_tta(model, x, batch=256): outs = [] for i in range(0, len(x), batch): chunk = tf.constant(x[i:i + batch], dtype=tf.float32) / 255.0 # 两次前向:原图 + 水平翻转,概率取均值 p = model(chunk, training=False) p = p + model(tf.image.flip_left_right(chunk), training=False) outs.append((p / 2.0).numpy()) return np.concatenate(outs) y_tta = np.argmax(predict_tta(model, x_test), axis=1) print("TTA 精度:", (y_tta == y_test).mean())还有一个容易漏的验证:把模型存盘、重载、再评估一次。model.save("cifar10.keras")之后load_model回来,精度应该和保存前逐位一致(允许 1e-6 级别的浮点误差)。差超过 0.1 个点,说明有自定义层或者预处理没被正确序列化——这在把模型交给别人部署时是致命的,对方环境里跑出来的结果和你本地对不上,排查起来毫无头绪。
我自己的习惯是:任何一次“这个模型不错”的判断,都必须经过混淆矩阵 + 每类召回 + TTA 一致性 + 存盘重载这四道关,缺一道就不写进结论。有一次赶时间跳过了存盘重载,交付后对方环境精度掉了六个点,回头查是 BN 层的滑动统计量没被正确保存,白白折腾了一个通宵。从那以后,不管模型多小,这四步都强制走一遍。希望帮到你。
本文还有配套的精品资源,点击获取