模型训练坎坷路--逐步提升模型准确率从40%到90%+

〇、前言

剪枝实验的基准得是一个微调好的准确率保持较高的模型,按照我之前yolov5的经验来看,这还不简单? 选模型+合适的数据集,20轮就OK!

我计划使用 ResNet-18模型加上Cifar-10数据集,训练轮次100轮,准确度达到95%我就立马停下。
可是过程却不大对劲,都20轮了我的准确率还是40%上下窜,一点进步的痕迹都没有,于是,果断按下停止键…

import copy
import math
import random
import time
from collections import OrderedDict, defaultdict
from typing import Union, List

import numpy as np
import torch
from matplotlib import pyplot as plt
from torch import nn
from torch.optim import *
from torch.optim.lr_scheduler import *
from torch.utils.data import DataLoader
from torchprofile import profile_macs
from torchvision.datasets import *
from torchvision.transforms import *
from torchvision import models  # 引入torchvision.models
from tqdm.auto import tqdm

from torchprofile import profile_macs

assert torch.cuda.is_available(), \
    "The current runtime does not have CUDA support." \
    "Please go to menu bar (Runtime - Change runtime type) and select GPU"

random.seed(0)
np.random.seed(0)
torch.manual_seed(0)

def download_url(url, model_dir='.', overwrite=False):
    import os, sys, ssl
    from urllib.request import urlretrieve
    ssl._create_default_https_context = ssl._create_unverified_context
    target_dir = url.split('/')[-1]
    model_dir = os.path.expanduser(model_dir)
    try:
        if not os.path.exists(model_dir):
            os.makedirs(model_dir)
        model_dir = os.path.join(model_dir, target_dir)
        cached_file = model_dir
        if not os.path.exists(cached_file) or overwrite:
            sys.stderr.write('Downloading: "{}" to {}\n'.format(url, cached_file))
            urlretrieve(url, cached_file)
        return cached_file
    except Exception as e:
        # remove lock file so download can be executed next time.
        os.remove(os.path.join(model_dir, 'download.lock'))
        sys.stderr.write('Failed to download from url %s' % url + '\n' + str(e) + '\n')
        return None

def train(
    model: nn.Module,
    train_dataloader: DataLoader,
    test_dataloader: DataLoader,
    criterion: nn.Module,
    optimizer: Optimizer,
    scheduler: LambdaLR,
    num_epochs: int,
    target_accuracy: float = 95.0,  # 目标准确率
    save_path: str = "finetuned_model.pth",  # 模型保存路径
    callbacks=None
) -> None:
    for epoch in range(num_epochs):  # 使用指定的训练轮数
        model.train()
        for inputs, targets in tqdm(train_dataloader, desc=f'train epoch {epoch+1}/{num_epochs}', leave=False):
            # Move the data from CPU to GPU
            inputs = inputs.cuda()
            targets = targets.cuda()

            # Reset the gradients (from the last iteration)
            optimizer.zero_grad()

            # Forward inference
            outputs = model(inputs)
            loss = criterion(outputs, targets)

            # Backward propagation
            loss.backward()

            # Update optimizer and LR scheduler
            optimizer.step()
            scheduler.step()

            if callbacks is not None:
                for callback in callbacks:
                    callback()

        # 训练完一个epoch后进行评估
        accuracy = evaluate(model, test_dataloader)
        print(f"Epoch {epoch+1}: Accuracy = {accuracy:.2f}%")

        # 检查是否达到目标准确率
        if accuracy >= target_accuracy:
            print(f"Reached target accuracy of {target_accuracy}%. Saving model...")
            torch.save(model.state_dict(), save_path)
            print(f"Model saved to {save_path}")
            return

@torch.inference_mode()
def evaluate(
    model: nn.Module,
    dataloader: DataLoader,
    verbose=True,
) -> float:
    model.eval()

    num_samples = 0
    num_correct = 0

    for inputs, targets in tqdm(dataloader, desc="eval", leave=False,
                                disable=not verbose):
        # Move the data from CPU to GPU
        inputs = inputs.cuda()
        targets = targets.cuda()

        # Inference
        outputs = model(inputs)

        # Convert logits to class indices
        outputs = outputs.argmax(dim=1)

        # Update metrics
        num_samples += targets.size(0)
        num_correct += (outputs == targets).sum()

    return (num_correct / num_samples * 100).item()

# 加载预训练模型和权重
model = models.resnet18(pretrained=True).cuda()  # 直接加载预训练的ResNet18模型
model.fc = nn.Linear(model.fc.in_features, 10).cuda()  # 修改最后的全连接层以适应CIFAR-10

# 数据处理
image_size = 32
transforms = {
    "train": Compose([
        RandomCrop(image_size, padding=4),
        RandomHorizontalFlip(),
        ToTensor(),
    ]),
    "test": Compose([
        ToTensor(),
    ]),
}

# 数据加载
train_dataset = CIFAR10(
    root="data/cifar10",
    train=True,
    download=True,
    transform=transforms["train"],
)
test_dataset = CIFAR10(
    root="data/cifar10",
    train=False,
    download=True,
    transform=transforms["test"],
)

train_dataloader = DataLoader(
    train_dataset,
    batch_size=512,
    shuffle=True,
    num_workers=0,
    pin_memory=True,
)

test_dataloader = DataLoader(
    test_dataset,
    batch_size=512,
    shuffle=False,
    num_workers=0,
    pin_memory=True,
)

# 设置训练轮数
num_epochs = 100  # 设置为一个较大的值,如果达到准确率要求会提前停止

# 训练和评估
criterion = nn.CrossEntropyLoss().cuda()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4)
scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=lambda epoch: 0.1**(epoch // 30))

train(model, train_dataloader, test_dataloader, criterion, optimizer, scheduler, num_epochs, target_accuracy=95.0, save_path="finetuned_resnet18.pth")

一、更改学习率

1.原理:过拟合需要减小学习率

学习率是深度学习中一个非常重要的超参数,它决定了模型在每一次参数更新时的步长大小。更改学习率可以直接影响模型的收敛速度和准确度。

学习率过大会导致模型在参数更新时跳过最优解,可能会导致模型无法收敛或者收敛到次优解。这种情况下,模型的准确度可能较低。

相反,学习率过小会导致模型收敛速度过慢,需要更多的迭代次数才能达到较好的准确度。在实际中,如果学习率过小,模型可能会陷入局部最优解,无法跳出。

一般来说,选择合适的学习率需要进行实验和调优,可以借助学习率调度策略(如学习率衰减、学习率动态调整等)来逐步调整学习率,以达到更好的模型准确度。

2.效果–>有用!

将学习率从0.1改成0.01,第一轮立马就60%了!可是后面也是一点变化都没有
在这里插入图片描述

二、更改训练批次batch_size

1.原理:更大的批量大小时,梯度估计更加精确

使用更大的批量大小时,梯度估计更加精确,因为它基于更多的数据样本。这通常会导致参数更新更加稳定,损失函数更快下降,因此每轮的训练准确性更高。

2.效果–>有点用

由于我本来使用的训练批次是512,已经很大了,再大俺的GPU受不住,所以反向一下这是使用batch_size=128的结果,可以看到模型维持在55%准确度左右。
在这里插入图片描述

三、更改数据预处理方式

1.原理:数据可能没有正确标准化或归一化,影响模型学习

稳定收敛:归一化数据可以使得特征的数值范围相近,这有助于瞬时下降算法更加稳定和快速地收敛。如果特征值的范围方差很大,模型在训练时可能会出现过多的震荡,收敛影响速度和稳定性。

防止特征主导:在没有归一化的数据集中,数值范围增大的特征可能会对损失函数产生过大的影响,从而更加主导模型的学习过程。归一化后,各个特征对模型的影响程度为队列。

原来的数据预处理方式

# 数据处理
image_size = 32
transforms = {
    "train": Compose([
        RandomCrop(image_size, padding=4),
        RandomHorizontalFlip(),
        ToTensor(),
    ]),
    "test": Compose([
        ToTensor(),
    ]),
}

改成

# 定义图像尺寸
image_size = 32

# 定义训练和测试数据的转换操作
transforms = {
    "train": Compose([
        RandomCrop(image_size, padding=4),   # 随机裁剪,带有4像素的填充
        RandomHorizontalFlip(),              # 随机水平翻转
        ToTensor(),                          # 转换为PyTorch张量
        Normalize(mean=[0.4914, 0.4822, 0.4465], std=[0.2023, 0.1994, 0.2010]) # 标准化
    ]),
    "test": Compose([
        ToTensor(),                          # 转换为PyTorch张量
        Normalize(mean=[0.4914, 0.4822, 0.4465], std=[0.2023, 0.1994, 0.2010]) # 标准化
    ]),
}

2.效果

我是在使用512批次大小以及使用0.01学习率的情况下,来更改这个数据集的预处理的。效果类比上面基本没有变化。
在这里插入图片描述

四、更改正则化强度

1.原理:过大的正则化会限制模型的学习能力,导致欠拟合

optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4)
将改成weight_decay=1e-4。

weight_decay=5e-4 参数实际上就是L2正则化的系数。
L2正则化会在损失函数中增加一项与模型权重的平方和成正比的惩罚项。这可以防止模型权重变得过大,从而减少过拟合的风险。本实验的现象是:“训练集和测试集的准确度都较低且不升”,明显的欠拟合

假设你在操场上用树枝弹簧来控制一个弹性球的运动。你的目标是让弹性球精准地击中地上的一组标靶,代表着模型需要一个的数据点。

无正则化(无营养的约束):
如果没有弹簧的约束,弹性球可能会因为自身的弹性,弹得过高或过低,甚至局部乱弹(过度),这就像模型过度重复训练数据,无法很好地泛化到新数据。

正则化强度过大(弹簧过紧):
如果弹簧限制太紧,弹性球的自由度会很严重。球几乎无法弹出太远,甚至连接近标靶的机会都没有,只是在原地有点弹动。这就像模型参数被过度限制,无法有效学习数据的模式,导致不足。

适应的正则化强度(弹簧松紧适中):
如果弹簧的松紧度适中,弹性球能够有一定的自由度弹向目标目标,同时又不会弹得过远而失去控制。这样,弹性球可以多次尝试,最终击中目标目标。这就像模型在正则化强度适中时,既能够避免过度,又能有效地导出数据,从而提高模型的精度。

2.效果

变化不大,看来此处不是这个原因
在这里插入图片描述

五、更改优化器

1.原理

开始实验时:推荐首先尝试 Adam,因为它通常不需要过多的超参数调节。
在优化过程中:如果发现模型过拟合,或者训练不稳定,可以尝试 SGD with Momentum。
高性能模型:对于精细调优的大型模型,考虑 AdamW 或 Nadam。

# 训练和评估
criterion = nn.CrossEntropyLoss().to(device)
#optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4)
scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=lambda epoch: 0.1**(epoch // 30))

2.效果–>有用

准确度显著提高到70%,但是还是停滞住了
在这里插入图片描述

六、更改调度器

1.原理

其实经过上述的尝试,我发现我这个是“”欠拟合“;最有效的方法就是在它准确率停滞的时候降低学习率,因此我尝试更改调度器。
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode=‘max’, factor=0.1, patience=5)

2.效果–>有用,本质上还是更改学习率

结果可以看出,我最高准确率到了88%,但是后续停滞的原因是因为:精度有限学习率约0了
我觉得以上这些超参数的调优已经到头了,而且我这个训练轮次也差不多数量合适的样子
在这里插入图片描述

七、终极大招:改模型结构+数据增强 双管齐下

1.原理:通过将第一层的7x7卷积核改为3x3、去掉最大池化+使用了 RandomCrop、RandomHorizontalFlip、Normalize 和 Cutout 进行数据增强+250轮

a. 卷积核大小与感受野的关系

  • 卷积核的大小决定了卷积层的感受野,即该层能“看到”的输入图像区域的大小。较大的卷积核(如7x7)能够一次性捕捉到较大的图像区域信息,但这也意味着每次卷积操作后,图像的空间分辨率会下降得更快,导致部分细节丢失。
  • 相比之下,3x3的卷积核感受野较小,它对图像的局部信息捕捉更加精细,能更好地保留图像的空间细节。这对于包含更多局部特征的任务(例如分类较小的物体或细节丰富的图像)尤其重要。

b. 避免过度池化

  • 原始ResNet中的第一个7x7卷积核通常伴随着步幅为2的下采样操作,以及一个最大池化层,这两个操作都会显著减少输入图像的空间尺寸。这种下采样对于非常大的图像可能有帮助,但对于较小的输入图像,会导致信息的丢失,从而影响模型的表现。通过将7x7卷积核换成3x3并移除最大池化层,模型可以在保留更多空间信息的情况下,进行更细致的特征提取,从而提高准确度。
import os
import torch
from torch import nn
from torchvision import models
from torch.utils.data import DataLoader
from torchvision.datasets import CIFAR10
from torchvision.transforms import Compose, RandomCrop, RandomHorizontalFlip, ToTensor, Normalize
from tqdm import tqdm  # 确保你已经导入 tqdm 库

# 检查是否有GPU可用
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

class CustomResNet18(models.ResNet):
    def __init__(self, num_classes=10):
        # 初始化ResNet18
        super().__init__(models.resnet.BasicBlock, [2, 2, 2, 2], num_classes=num_classes)
        # 修改第一层卷积
        self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False)
        self.maxpool = nn.Identity()  # 移除最大池化层

# 实例化模型
model = CustomResNet18().to(device)

# 加载已保存的模型权重(如果存在)
save_path = "finetuned_resnet18.pth"
if os.path.exists(save_path):
    print(f"Loading model weights from {save_path}...")
    model.load_state_dict(torch.load(save_path))
else:
    print(f"No saved model found at {save_path}. Starting fresh training.")

# 定义损失函数和优化器
criterion = nn.CrossEntropyLoss().to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=0.1, weight_decay=2e-4)  # 初始学习率设置为0.1

# 定义数据转换,包含数据增强
transforms = {
    "train": Compose([
        RandomCrop(32, padding=4),  # 随机裁剪
        RandomHorizontalFlip(),      # 随机水平翻转
        ToTensor(),                  # 转换为张量
        Normalize(mean=[0.4914, 0.4822, 0.4465], std=[0.2023, 0.1994, 0.2010]), # 归一化
        Cutout(n_holes=1, length=16),  # Cutout 数据增强
    ]),
    "test": Compose([
        ToTensor(),
        Normalize(mean=[0.4914, 0.4822, 0.4465], std=[0.2023, 0.1994, 0.2010]),
    ]),
}

# 数据加载
train_dataset = CIFAR10(root="data/cifar10", train=True, download=True, transform=transforms["train"])
test_dataset = CIFAR10(root="data/cifar10", train=False, download=True, transform=transforms["test"])

train_dataloader = DataLoader(train_dataset, batch_size=512, shuffle=True, num_workers=0, pin_memory=True)
test_dataloader = DataLoader(test_dataset, batch_size=512, shuffle=False, num_workers=0, pin_memory=True)

def adjust_learning_rate(optimizer, epoch):
    """Sets the learning rate to the initial LR decayed by 10 every 50 epochs"""
    lr = 0.1 * (0.1 ** (epoch // 35))
    for param_group in optimizer.param_groups:
        param_group['lr'] = lr

@torch.inference_mode()
def evaluate(model: nn.Module, dataloader: DataLoader, verbose=True) -> float:
    model.eval()
    num_samples = 0
    num_correct = 0
    for inputs, targets in tqdm(dataloader, desc="eval", leave=False, disable=not verbose):
        inputs = inputs.to(device)
        targets = targets.to(device)
        outputs = model(inputs)
        outputs = outputs.argmax(dim=1)
        num_samples += targets.size(0)
        num_correct += (outputs == targets).sum()
    return (num_correct / num_samples * 100).item()

def train(
    model: nn.Module,
    train_dataloader: DataLoader,
    test_dataloader: DataLoader,
    criterion: nn.Module,
    optimizer: torch.optim.Optimizer,
    num_epochs: int,
    target_accuracy: float = 92.0,
    save_path: str = "finetuned_resnet18.pth"
) -> None:
    best_accuracy = 0.0
    for epoch in range(num_epochs):
        adjust_learning_rate(optimizer, epoch)
        current_lr = optimizer.param_groups[0]['lr']
        print(f'Epoch {epoch+1}/{num_epochs}, Learning Rate: {current_lr:.6f}')
        model.train()
        for inputs, targets in tqdm(train_dataloader, desc=f'train epoch {epoch+1}/{num_epochs}', leave=False):
            inputs = inputs.to(device)
            targets = targets.to(device)
            optimizer.zero_grad()
            outputs = model(inputs)
            loss = criterion(outputs, targets)
            loss.backward()
            optimizer.step()
        accuracy = evaluate(model, test_dataloader)
        print(f"Epoch {epoch+1}: Accuracy = {accuracy:.2f}%")
        if accuracy > best_accuracy:
            best_accuracy = accuracy
            print(f"New best accuracy: {best_accuracy:.2f}%. Saving model...")
            torch.save(model.state_dict(), save_path)
        if accuracy >= target_accuracy:
            print(f"Reached target accuracy of {target_accuracy}%. Training stopped early.")
            return
    print(f"Training complete. Best accuracy: {best_accuracy:.2f}%. Saving final model...")
    torch.save(model.state_dict(), save_path)

# 设置训练轮数
num_epochs = 250
from google.colab import drive
drive.mount('/content/drive')

os.chdir('/content/drive/My Drive')
print(model)

# 开始训练
train(
    model,
    train_dataloader,
    test_dataloader,
    criterion,
    optimizer,
    num_epochs,
    target_accuracy=92.0,
    save_path="finetuned_resnet18.pth"
)

2.效果

出乎我的意料,效果并没有变好。
在这里插入图片描述
然后我就参考其他人的训练,我发现大家都用的SGD优化器结合学习率衰减的策略来训练的。那么使用Adam优化器和使用SGD有啥区别呢?
1.1 SGD(随机梯度下降)
特点:
基础的优化方法,每次使用一个或一小批样本更新模型参数。
可能会导致收敛速度慢或陷入局部最小值。
引入 动量 可以加速收敛并减少震荡。
适用场景:
大规模数据集。
对数据分布有较好的控制时(如预处理良好,归一化适当)。
希望更直接地控制学习率和动量。
1.2 Adam(自适应矩估计)
特点:
自适应调整学习率,结合了 AdaGrad 和 RMSProp 的优点。
通常不需要手动调整学习率。
在许多任务中表现良好,尤其是复杂的神经网络(如卷积网络、RNN)。
对学习率相对不敏感,初始学习率通常设置为 0.001。
适用场景:
初学者或对超参数调优不敏感的场景。
复杂的深度网络(如 Transformer、BERT)。
不平稳目标(例如在处理 NLP 任务时)。

我发现:使用Adam之后就不要自己再手动减小学习率了。

3.实验对比Adam和SGD

我结合网上大家最常用的SGD和Adam对比一下两者到底区别大不大:都使用128批次大小训练比较前100轮情况(哪个效果好哪个能跑到250轮),当然我都加载我之前那些失败品里面最好的权重(准确度81%)。

  • Adam使用初始学习率为0.01;
    在这里插入图片描述

为什么Adam 不是默认的优化算法?

  • SGD使用optimizer = optim.SGD(model.parameters(), lr=lr, momentum=0.9,
    weight_decay=5e-4)设置初始学习率为0.1,每当经过10个epoch训练的验证集损失没有下降时,学习率变为原来的0.5,共训练250个epoch。

在这里插入图片描述

八、结论

结果表明,哪里用得着300轮,好的训练策略和模型只要30轮嘤嘤嘤

1.最终代码

详细代码以及训练好的模型权重见,各位以后年薪百万的给我个star,小白我也想找工作:
俺的GitHub

# 定义基本残差块
class BasicBlock(nn.Module):
    expansion = 1

    def __init__(self, in_planes, planes, stride=1):
        super(BasicBlock, self).__init__()
        self.conv1 = nn.Conv2d(in_planes, planes, kernel_size=3, stride=stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(planes)
        self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, stride=1, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(planes)

        self.shortcut = nn.Sequential()
        if stride != 1 or in_planes != self.expansion * planes:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_planes, self.expansion * planes, kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(self.expansion * planes)
            )

    def forward(self, x):
        out = torch.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        out += self.shortcut(x)
        out = torch.relu(out)
        return out

# 定义ResNet18模型
class ResNet(nn.Module):
    def __init__(self, block, num_blocks, num_classes=10):
        super(ResNet, self).__init__()
        self.in_planes = 64
        self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(64)
        self.layer1 = self._make_layer(block, 64, num_blocks[0], stride=1)
        self.layer2 = self._make_layer(block, 128, num_blocks[1], stride=2)
        self.layer3 = self._make_layer(block, 256, num_blocks[2], stride=2)
        self.layer4 = self._make_layer(block, 512, num_blocks[3], stride=2)
        self.linear = nn.Linear(512 * block.expansion, num_classes)

    def _make_layer(self, block, planes, num_blocks, stride):
        strides = [stride] + [1] * (num_blocks - 1)
        layers = []
        for stride in strides:
            layers.append(block(self.in_planes, planes, stride))
            self.in_planes = planes * block.expansion
        return nn.Sequential(*layers)

    def forward(self, x):
        out = torch.relu(self.bn1(self.conv1(x)))
        out = self.layer1(out)
        out = self.layer2(out)
        out = self.layer3(out)
        out = self.layer4(out)
        out = F.avg_pool2d(out, 4)  # 使用 F.avg_pool2d
        out = out.view(out.size(0), -1)
        out = self.linear(out)
        return out

# 实例化ResNet18
def Resnet18():
    return ResNet(BasicBlock, [2, 2, 2, 2])



在这里插入图片描述
在这里插入图片描述

2.思考体会

在模型训练过程中,影响模型准确率的关键次要因素主要集中在优化器的选择和学习率的设置上,最重要的还是模型结构的调整。

  1. 优化器的选择:优化器直接影响模型的收敛速度和最终性能。尽管Adam优化器通常是默认的选择,但在特定情况下,使用其他优化器(如SGD或RMSprop)可能会带来更好的效果。因此,优化器的选择应根据具体任务和模型的需求进行评估。

  2. 学习率的动态分阶段设置:学习率是影响模型训练的关键参数之一。固定的学习率可能无法适应整个训练过程的需求,因此通常采用动态调整的策略。在训练的初期,可以使用较高的学习率以加速收敛;在训练的后期,逐步降低学习率以精细化参数调整。这种分阶段的学习率设置有助于在不同的训练阶段优化模型表现。

  3. 明确模型潜力再相应改进:在进行模型训练时,参考与自己任务相似的模型和数据集案例是一个重要的步骤。通过分析这些案例,你可以了解在相同或相似的数据集上,其他模型能够达到的最好效果。这样,你可以明确当前模型在现有数据集上的潜力,进而判断是需要 优化模型结构 ,还是需要 调整数据集(如增加数据量或复杂性) 来进一步提升性能。如果通过参考案例发现你的模型已经接近理论最佳效果,那么就可能需要更改模型结构以适应数据的需求;反之,如果还有较大提升空间,则可能需要优化训练策略或数据处理方式。

  • 6
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值