☰
Fashion-MNIST对抗攻击与对抗训练实战:从FGSM到PGD
2026/10/13 11:37:57 网站建设 项目流程

简介:面向图像分类模型安全研究的一份完整实践代码包,基于Fashion_MNIST数据集实现对抗攻击与对抗训练全流程,适合深度学习初学者及AI安全方向研究者快速上手。包内共121个文件,压缩后约15.12MB,涵盖9个Python脚本(包含FGSM、PGD等典型攻击算法实现与训练逻辑)、8个pkl数据文件、2个h5模型权重文件、100张对抗样本效果图及1份说明文档,覆盖从数据预处理、模型构建、扰动生成到鲁棒性评估的完整链路,代码结构清晰,便于二次修改与复现。已有115人学习下载。通过对照脚本和可视化结果,可直观理解对抗样本的生成原理、防御训练对模型性能与泛化能力的影响,为后续开展更复杂的攻防研究提供可运行的实验基座。整个资源即开即用,可作为课程设计、毕业设计或入门科研的起点。

1. 对抗攻击和对抗训练:为什么从 Fashion_MNIST 开始

图像分类模型的对抗攻击和对抗训练,这几年已经从论文里的黑匣子变成工程必修课。业务项目里最常见的翻车方式就是:训练时准确率很漂亮,上线后有人拿加了轻微噪声的图片一测,模型立刻错得离谱。要搞懂这套攻防逻辑,Fashion_MNIST 是最合适的练手数据集,28×28 灰度图让扰动和训练代价都能被完整观察,也最容易复现。

下面按攻击原理、完整攻击流程、对抗训练、踩坑排查、鲁棒性评估展开。目标只有一个:拿到标题里的 Attack_Fashion_MNIST 代码包后,你能自己改参数、复现结果、解释每个数字,而不是把脚本跑一遍就丢在一边。

2. 从 FGSM 到 PGD:对抗样本的生成原理与最小实现

2.1 梯度方向就是攻击方向:FGSM 的数学直觉

先回答一个最朴素的问题:为什么加了点噪声,模型就认错?分类模型的训练本质上是在输入空间里画一堆决策边界,边界附近的样本天然是脆弱的。Fashion_MNIST 的十个类别里,套衫和衬衫、运动鞋和踝靴这些类之间本来就很接近,一张图稍微往某个方向偏一点,置信度天平就会倒向错误一侧。

常见做法是沿着损失函数的梯度方向推输入。损失函数衡量的是“当前预测离正确答案有多远”,那让损失变大最快的方向,就是对输入求梯度后得到的方向。快速梯度符号法(FGSM)把这个思路做到最简:只取梯度的符号,按固定步长 ε 走一步,公式就是 x_adv = x + ε · sign(∇_x L(f(x), y))。取符号而不取完整梯度,是为了把单像素的改动幅度严格控制在 ε 范围内,保证扰动人眼几乎看不见。

为什么这么小的扰动会有效?Fashion_MNIST 的一张图是 784 维向量,每个像素只动一点点,但 784 个方向的微小增量会在线性加权和中叠加起来。神经网络的底层计算就是加权求和,当几千个“一点点”沿着同一个方向汇聚,足以把加权和推出决策边界。这个“高维空间里的同向叠加”效应,是对抗攻击最核心的直觉。

2.2 迭代攻击与投影约束:PGD 为什么更接近真实威胁

FGSM 只有一步,遇到弯曲一点的决策面经常失效。投影梯度下降(PGD)把它改成迭代过程:先从原始样本附近的一个随机点出发,反复执行“沿梯度方向走一小步 α,再把点投影回以 x 为中心、半径 ε 的邻域内”。这里的投影是关键,它保证每一步的扰动都没有超出威胁模型规定的预算,迭代结束时得到的样本仍然在允许的改动范围内。

PGD 相比 FGSM 的优势在于,单步攻击容易陷入“朝着一个方向猛推但没找到真正脆弱的点”的尴尬,而 PGD 每走一步都会重新观察模型当前的梯度,相当于是沿着决策面溜达一圈,找到最容易被推倒的位置。正因如此,工业界评估模型鲁棒性时,几乎不会只用 FGSM,而是用 PGD-20 甚至更多步的迭代攻击当作“最坏情况”基准。

PGD 有三个必调参数:ε 是总扰动预算,α 是每次迭代的步长,steps 是迭代次数。常见取法是 α = ε/4 配合 10 步左右,或者更保守一点 α = ε/steps。训练时为了控制成本常用 PGD-7,评估时用 PGD-20。步长太大容易在邻域边缘来回震荡,步长太小则迭代次数要相应增加,这两者需要一起调。

2.3 一个只依赖 autograd 的最小攻击函数

攻击函数并不复杂,只依赖 PyTorch 的自动求导就能写出来,不需要引入任何专门库。下面这个版本我特意写成和训练解耦的纯函数,方便在评估脚本和对抗训练脚本里反复调用。

import torch def fgsm_attack(model, x, y, eps): # 注意 detach 后再 clone,避免外部张量带梯度导致计算图串联 x = x.detach().clone().requires_grad_(True) loss = torch.nn.functional.cross_entropy(model(x), y) grad = torch.autograd.grad(loss, x)[0] x_adv = x + eps * grad.sign() return torch.clamp(x_adv, 0, 1).detach() def pgd_attack(model, x, y, eps, alpha, steps): # 随机初始化:在 [-eps, eps] 内随机起步,避免落入局部平坦区 x_adv = x + (torch.rand_like(x) * 2 - 1) * eps x_adv = torch.clamp(x_adv, 0, 1) for _ in range(steps): x_adv.requires_grad_(True) loss = torch.nn.functional.cross_entropy(model(x_adv), y) grad = torch.autograd.grad(loss, x_adv)[0] x_adv = x_adv.detach() + alpha * grad.sign() # 两次 clamp:先投影回原始样本的 eps 邻域,再裁剪到像素有效范围 x_adv = torch.clamp(x_adv, x - eps, x + eps) x_adv = torch.clamp(x_adv, 0, 1) return x_adv.detach()

fgsm_attack 里用grad.sign()取符号,保证每个像素只动 ε 那么多;detach()是为了让返回的对抗样本不再带着梯度信息,否则在对抗训练里会二阶求导。pgd_attack 多了一个随机初始化和一个投影操作,torch.clamp(x_adv, x - eps, x + eps)就是 L∞ 投影,把每一步的改动拉回 ε 球内。这两个函数都假设模型输入已经是 [0,1] 区间,如果数据管道做了归一化,这里的 clamp 上界要跟着改,具体坑在第 5 章展开。

3. 在 Fashion_MNIST 上跑通完整攻击:数据、靶子模型与攻击脚本

3.1 数据加载与两种归一化选择

Fashion_MNIST 由 torchvision 直接提供,首次运行会自动下载,量级不大。加载时的关键决策是要不要做 Normalize,这直接影响后面攻击函数的 clamp 边界。

from torch.utils.data import DataLoader from torchvision import datasets, transforms def load_data(batch_size=128, normalize=False): if normalize: # 训练集全局统计量:均值约 0.286,标准差约 0.353 transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.2860,), (0.3530,)) ]) else: transform = transforms.ToTensor() train_set = datasets.FashionMNIST( root='./data', train=True, download=True, transform=transform) test_set = datasets.FashionMNIST( root='./data', train=False, download=True, transform=transform) train_loader = DataLoader( train_set, batch_size=batch_size, shuffle=True, num_workers=2) test_loader = DataLoader( test_set, batch_size=batch_size, shuffle=False) return train_loader, test_loader

normalize=False 时,输入张量直接是 [0,1] 灰度值,攻击代码里的两次 clamp 都写 (0,1),思路最简单。normalize=True 时,输入被压成近似零均值分布,像素范围大约在 (-0.81, 2.02) 之间,此时攻击函数的 clamp 写成 (0,1) 就是错的。我的建议是练手阶段先 normalize=False 把整条链路跑通,确认攻击和对抗训练都正常后再切到归一化版本,顺便检验自己是否真的理解了输入域。

batch_size 取 128 是折中:Fashion_MNIST 单张图只有 1×28×28,这个批量在 CPU 上也能跑,PGD 的 10 步迭代不会把显存或内存吃满。shuffle 只在训练集开启,测试集保持固定顺序,评估结果更稳定。

3.2 一个能当靶子的 CNN:规模与速度的平衡

靶子模型不需要花哨,两层卷积加一个全连接头就够了。刻意不放 BatchNorm 和 Dropout,是因为攻击函数里 model(x) 的 forward 模式必须和训练时一致,这些层在 train/eval 下行为不同,容易让攻击梯度和训练梯度产生偏差。

import torch from torch import nn class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.conv = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) self.head = nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(), nn.Linear(128, 10) ) def forward(self, x): return self.head(self.conv(x)) def train_model(model, train_loader, epochs=5, lr=1e-3): opt = torch.optim.Adam(model.parameters(), lr=lr) loss_fn = nn.CrossEntropyLoss() model.train() for epoch in range(epochs): for x, y in train_loader: opt.zero_grad() loss = loss_fn(model(x), y) loss.backward() opt.step() print(f'epoch {epoch + 1}: loss = {loss.item():.4f}') return model

两层卷积池化后特征图是 64×7×7,接一个 128 维全连接层,参数量很小,CPU 上训练 5 个 epoch 也就是几分钟的量级。Adam 用默认学习率 1e-3 在这个任务上收敛很稳,普通训练后测试准确率通常在 90% 上下浮动。这里值得注意:靶子模型精度不是越高越好,反而稍差一点的模型对扰动更敏感,攻击效果更明显,更适合用来观察攻防差异。

3.3 攻击脚本:逐批生成对抗样本并统计攻击成功率

评估攻击效果时有个常见误区:把所有样本的攻击成功率都算进去,把模型本来就分错的那部分也当成“攻击成功”。正确口径是只看干净样本预测正确的那些,看对抗样本把它们翻错的比例。

def evaluate_attack(model, loader, attack_fn, **kwargs): model.eval() total = 0 success = 0 for x, y in loader: x_adv = attack_fn(model, x, y, **kwargs) with torch.no_grad(): pred_clean = model(x).argmax(dim=1) pred_adv = model(x_adv).argmax(dim=1) # 只统计干净样本被预测对、对抗样本被翻错的情况 success += ((pred_clean == y) & (pred_adv != y)).sum().item() total += y.numel() return success / total # 自然准确率:攻击函数直接返回原图 base_acc = evaluate_attack(model, test_loader, lambda m, x, y: x) # FGSM 攻击,扰动预算 0.1 fgsm_acc = evaluate_attack(model, test_loader, fgsm_attack, eps=0.1) # PGD 攻击,总预算 0.1,步长 0.025,迭代 10 步 pgd_acc = evaluate_attack( model, test_loader, pgd_attack, eps=0.1, alpha=0.025, steps=10) print(f'clean acc: {base_acc:.4f}') print(f'after FGSM: {fgsm_acc:.4f}') print(f'after PGD: {pgd_acc:.4f}')

evaluate_attack 的第三个参数是攻击函数,传入 lambda 就等价于不攻击,用来量自然准确率;传入 fgsm_attack 或 pgd_attack 时,额外关键字参数会被透传进去。eps=0.1 表示允许每个像素改动 0.1 的灰度值,在 Fashion_MNIST 上这个强度肉眼几乎无感,但足以把普通模型打崩。预期结果大致是:自然准确率约 0.90,FGSM 后掉到 0.20-0.35 区间,PGD 后只剩 0.05-0.15。如果看到 PGD 比 FGSM 攻击成功率低,先检查是不是 clamp 边界写错了,这是最常见的异常。

4. 对抗训练三件套:FGSM、PGD 与混合数据怎么选

4.1 为什么对抗训练能修好模型

对抗训练的本质不是往数据里加噪声,而是让模型在“最痛的方向”上学会稳下来。优化目标可以写成:min_θ E [ max_‖δ‖≤ε L(f_θ(x+δ), y) ]。内层的 max 是为了找出当前模型最容易被带偏的扰动,外层的 min 是让模型在这种最坏扰动下依然能预测正确。

这和普通数据增强有本质区别。高斯噪声是盲目的,它均匀地试探各个方向,而对抗扰动是精准打击模型当前弱点。也正因如此,对抗训练很容易过拟合生成样本时用的攻击方式:用 FGSM 生成的样本训练,模型往往只对 FGSM 鲁棒,换 PGD 立刻穿帮。这个“针对性越强、泛化越差”的规律,贯穿整个对抗训练章节。

4.2 方案一:FGSM 对抗训练,快但有陷阱

FGSM 对抗训练的实现最直接:每个 batch 先做一次 FGSM 攻击,再用对抗样本算损失并更新参数。因为攻击函数返回的张量已经 detach,backward 只对模型参数求梯度,不会产生二阶梯度。

def train_model_fgsm(model, train_loader, epochs=5, lr=1e-3, eps=0.1): opt = torch.optim.Adam(model.parameters(), lr=lr) loss_fn = nn.CrossEntropyLoss() model.train() for epoch in range(epochs): for x, y in train_loader: opt.zero_grad() x_adv = fgsm_attack(model, x, y, eps) loss = loss_fn(model(x_adv), y) loss.backward() opt.step() print(f'fgsm adv epoch {epoch + 1}: loss = {loss.item():.4f}') return model

训练耗时约为普通训练的 1.5 倍,因为每个 batch 多了一次 forward 和一次 backward。但 FGSM 的梯度是单步方向,模型只要稍微挪动决策边界就能避开这个固定方向,属于“临时抱佛脚”。典型结果是:FGSM 攻击下准确率能回到 0.70+,PGD 一测只剩 0.30-0.50。如果模型上线后面临的攻击者不一定会用迭代攻击,这个成本收益比可以接受;但如果要做严肃的鲁棒性评估,它只能当基线。

4.3 方案二:PGD 对抗训练,慢但更扎实

把训练脚本里的 fgsm_attack 换成 pgd_attack,剩下几乎不用动,这就是 PGD 对抗训练。代价是每个 batch 都要多跑 steps 次 forward-backward,训练耗时变成普通训练的 8-12 倍。

def train_model_pgd(model, train_loader, epochs=5, lr=1e-3, eps=0.1, alpha=0.025, steps=7): opt = torch.optim.Adam(model.parameters(), lr=lr) loss_fn = nn.CrossEntropyLoss() model.train() for epoch in range(epochs): for x, y in train_loader: opt.zero_grad() x_adv = pgd_attack(model, x, y, eps, alpha, steps) loss = loss_fn(model(x_adv), y) loss.backward() opt.step() print(f'pgd adv epoch {epoch + 1}: loss = {loss.item():.4f}') return model

steps 取 7 是工程上的起步值,论文里常用 10 甚至 40,但练手阶段 PGD-7 已经足够暴露模型的脆弱点。训练 5 个 epoch 太久的话,先跑 2 个 epoch 看 Loss 是否下降,再决定是否加大步数。我见过不少开发者一上来就用 PGD-20 训练,跑了一夜发现自然准确率掉到 60%,最后把 steps 降到 7、ε 从 0.3 降到 0.1,半小时就看到了正常效果。对抗训练的核心不是堆步数,而是让参数在“最痛方向”上站稳。

4.4 方案三:混合数据训练,保住自然准确率

完全用对抗样本训练,模型会把注意力全放在“扛打”上,干净样本的准确率必然掉。工程上更常用的做法是混合训练:一部分 batch 用原始样本,一部分用对抗样本,比例由 adv_freq 控制。

def train_model_mixed(model, train_loader, epochs=5, lr=1e-3, eps=0.1, alpha=0.025, steps=7, adv_freq=0.5): opt = torch.optim.Adam(model.parameters(), lr=lr) loss_fn = nn.CrossEntropyLoss() model.train() for epoch in range(epochs): for x, y in train_loader: if torch.rand(1).item() < adv_freq: x_in = pgd_attack(model, x, y, eps, alpha, steps) else: x_in = x opt.zero_grad() loss = loss_fn(model(x_in), y) loss.backward() opt.step() print(f'mixed epoch {epoch + 1}: loss = {loss.item():.4f}') return model

adv_freq=0.5 表示大约一半 batch 走对抗路径、一半走原始路径。调参经验是:adv_freq 从 0.3 起步,如果自然准确率掉得不多就往上加;如果对抗准确率不够就加大 ε 或 steps,而不是盲目把 adv_freq 拉到 1.0。另一个细节是可以在前几个 epoch 用纯原始数据预热,后面再把对抗比例拉高,这样模型先学到基础特征,再做鲁棒性精调,效果通常比全程混合更稳。

4.5 三种方案的效果对比与选型

下表是 Fashion_MNIST 上、ε=0.1、batch=128、Adam lr=1e-3 训练 5 个 epoch 的典型范围。不同架构和随机种子会有浮动,但相对关系基本稳定。

方案自然准确率FGSM 攻击后准确率PGD 攻击后准确率训练耗时
普通训练约 0.90约 0.20-0.35约 0.05-0.15基准
FGSM 对抗训练约 0.87约 0.70-0.80约 0.30-0.501.5 倍左右
PGD 对抗训练约 0.84约 0.82-0.88约 0.70-0.808-12 倍
混合训练约 0.87约 0.78-0.85约 0.60-0.754-6 倍

选型的判断标准是威胁模型:如果你的业务场景里攻击者只会做单次低成本扰动,FGSM 对抗训练性价比最高;如果要做严肃的鲁棒性交付,直接上 PGD 对抗训练;如果模型对自然场景准确率有硬指标,混合训练是唯一能两头兼顾的做法。注意 PGD 对抗训练后自然准确率掉到 0.84 左右是正常现象,不是模型坏了,这是“用精度换鲁棒性”的固有代价。

5. 避坑手册:对抗训练不生效、Loss 爆炸与结果对不上

5.1 对抗训练后自然准确率暴跌,对抗准确率也没涨

现象:普通训练自然准确率 0.90,加对抗训练后反而掉到 0.60 甚至更低,同时 PGD 攻击后准确率也只有 0.40 左右,两头都没保住。原因几乎都是 ε 设得太大。在 [0,1] 像素域里 ε=0.3 意味着允许每个像素改变 30% 的灰度,扰动已经破坏了服饰的纹理和轮廓,模型被迫去拟合一堆“看不清的图”,自然准确率当然崩。

解决:把 ε 降到 0.05-0.1 区间重新跑。ε 是威胁模型的半径,不是越大越好,超出业务实际风险的扰动预算只会浪费模型容量。我会先固定其他参数,只扫描 ε = 0.03、0.05、0.1、0.2 四档,各跑 2 个 epoch 看趋势,再决定正式训练的取值。

5.2 训练 Loss 震荡不下降,或直接爆炸

现象:对抗训练时 Loss 在前几个 epoch 反复横跳,甚至出现 NaN,打印出来的数值一次 1.2、一次 3.5、一次 1.8,完全没有衰减趋势。原因通常是 α 和 ε 不匹配:比如 ε=0.1 但 α=0.05,两步迭代还没到第三、四步就冲出约束球,被 clamp 粗暴拉回来,梯度方向来回反转;加上学习率还用着普通训练的 1e-3,两个“步长”叠加在一起,优化过程直接发散。

解决:把 α 固定为 ε/steps。ε=0.1、steps=7 时 α 取 0.014 到 0.02 之间,学习率从 1e-3 降到 5e-4。另外确认数据没有在 Normalize 后又被错误地 clamp,梯度尺度异常也容易导致 NaN。

5.3 攻击成功率虚高,对抗样本全是黑块或噪点

现象:换用带 Normalize 的数据管道后,FGSM 和 PGD 攻击成功率都接近 100%,把对抗样本存成图片一看,全是黑色块或密集噪点,根本不是“人眼无感”的扰动。原因是 clamp 边界错了。归一化后模型输入的范围不在 [0,1],直接 torch.clamp(x_adv, 0, 1) 会把有效扰动的大部分截断,剩下的噪声被模型当成极端输入。

解决:归一化时把 clamp 边界换算成 (-0.286/0.353, 0.714/0.353),也就是 (-0.81, 2.02)。更省事的做法是攻击阶段全程用 [0,1] 张量,只在喂给模型前做 Normalize。这就是很多开源脚本干脆不归一化的原因,不是他们偷懒,是为了让攻击代码的边界条件保持直观。

5.4 同一个脚本两次运行结果对不上

现象:有次某开发者拿同一份攻击脚本跑了两遍,PGD 攻击成功率差了 8 个百分点,他一度怀疑是数据集下载出了问题。其实第一次干净准确率 0.90,第二次 0.87,模型本身就不一样。PyTorch 默认不固定随机种子,卷积初始化、DataLoader 的 shuffle 顺序都会影响最终模型参数。

解决:脚本开头固定随机种子,并关闭 cuDNN 的非确定性算子。

import random import numpy as np import torch def set_seed(seed=0): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False

set_seed(0) 要在模型初始化之前调用。DataLoader 如果开了 num_workers 多进程,子进程的随机状态不受主进程控制,严格复现时把 num_workers 设成 0,或者接受 ±1% 级别的浮动。对抗训练本身对随机性更敏感,固定 seed 后依然建议做两次重复实验取均值再下结论。

5.5 对抗训练后 FGSM 防住了,PGD 一测就穿

现象:FGSM 对抗训练出来的模型,FGSM 攻击后准确率 0.78,换成 PGD 后立刻掉到 0.25,看起来像白训了。原因是单步攻击的扰动方向很固定,模型只需要把决策边界稍微挪开就能避开这个方向,但边界附近的其他方向完全没有被加固。这就是对抗样本领域常说的“梯度遮蔽”:模型没有被真正钝化,只是藏起了最容易被发现的那条路。

解决:评估鲁棒性时一律用 PGD-20 作为基准,不拿 FGSM 的数字撑门面;训练端至少用 PGD-7 起步。如果训练成本实在有限,先用 FGSM 对抗训练跑通流程,但要清楚它防不住认真一点的攻击者。养成这个习惯后,你会对“某个防御方案效果不错”的说法本能地多问一句:用什么攻击评估的?

6. 鲁棒性评估与可视化:epsilon 扫描看清防御边界

6.1 画出 epsilon-准确率曲线

单点评估最容易被片面的数字误导。同一个 PGD 对抗训练模型,在 ε=0.05 下表现优秀,到 ε=0.3 一样崩。正确习惯是描一条“扰动预算-攻击后准确率”的曲线,看防御在哪个强度区间内成立。

def eval_eps_curve(model, loader, eps_list, alpha_ratio=0.25, steps=20): curve = [] for eps in eps_list: acc = evaluate_attack(model, loader, pgd_attack, eps=eps, alpha=eps * alpha_ratio, steps=steps) curve.append(acc) return curve eps_list = [0, 0.02, 0.05, 0.1, 0.15, 0.2, 0.3] curve = eval_eps_curve(model, test_loader, eps_list) for eps, acc in zip(eps_list, curve): print(f'eps={eps:.2f}, acc={acc:.4f}')

alpha 跟随 eps 按比例缩放,保证步数相同的情况下迭代行为一致。eps=0 时攻击退化为原始样本,这一点的准确率就是自然准确率。曲线越平缓,说明防御越扎实;如果曲线从 0.05 到 0.1 之间断崖式下跌,说明这个模型只在低扰动域有效。

6.2 把扰动画出来,确认攻击在“轮廓附近”而不是全图糊

数字之外还要看视觉证据。对抗扰动的理想形态是集中在服饰边缘、纹理等特征敏感区域,如果扰动看起来像均匀噪点铺满全图,通常说明攻击没有找到结构化弱点,或者 clamp 边界写错了。

import torchvision.utils def save_adv_samples(model, x, y, attack_fn, eps, save_path): x_adv = attack_fn(model, x, y, eps) diff = (x_adv - x).abs() vis = diff / (diff.max() + 1e-8) # 归一化到 [0,1] torchvision.utils.save_image(vis, save_path, nrow=8)

把原始图和对抗样本并排存下来,肉眼几乎看不出差异,说明扰动确实在 ε 预算内;再把扰动强度图放大保存,你会看到高亮区域通常集中在衣物轮廓和纹理边缘。这也是我最常用的验证手段:每次改完攻击参数,先看一张图再谈准确率数字。

这套流程跑熟之后,我养成了一个习惯:任何防御方案先出 epsilon 曲线,再谈效果。那些只在单点 ε 上漂亮的方案,在曲线图里往往原形毕露。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询