线性神经网络(sotfmax回归)

专栏:神经网络复现目录

本文章系统讲解如何从零实现一个softmax回归神经网络,并附上pytorch代码的详细解释
!!本文章代码部分来自《动手学深度学习》

获取最新神经网络信息可以加我微信公众号
请添加图片描述

定义

为了估计所有可能类别的条件概率,我们需要一个有多个输出的模型,每个类别对应一个输出。 为了解决线性模型的分类问题,我们需要和输出一样多的仿射函数(affine function)。 每个输出对应于它自己的仿射函数。 在我们的例子中,由于我们有4个特征和3个可能的输出类别, 我们将需要12个标量来表示权重(带下标的), 3个标量来表示偏置(带下标的)。
(3.4.2)

网络架构

与线性回归一样,softmax回归也是一个单层神经网络。 由于计算每个输出o1、o2和o3取决于所有输入x1、x2、x3和x4, 所以softmax回归的输出层也是全连接层。
在这里插入图片描述

softmax运算

softmax函数能够将未规范化的预测变换为非负数并且总和为1,同时让模型保持 可导的性质。 为了完成这一目标,我们首先对每个未规范化的预测求幂,这样可以确保输出非负。 为了确保最终输出的概率值总和为1,我们再让每个求幂后的结果除以它们的总和。
在这里插入图片描述

softmax回归实现(MNIST数据集)

数据集的处理

读取数据集

MNIST数据集 (LeCun et al., 1998) 是图像分类中广泛使用的数据集之一,但作为基准数据集过于简单。 我们将使用类似但更复杂的Fashion-MNIST数据集 (Xiao et al., 2017)。

%matplotlib inline
import torch
import torchvision
from torch.utils import data
from torchvision import transforms
from d2l import torch as d2l

d2l.use_svg_display()

这段代码用于下载和加载 Fashion-MNIST 数据集,并对图像进行转换处理。

首先,transforms.ToTensor() 方法将每张图片转换为 PyTorch 中的张量形式,并将像素值的范围从 [0, 255] 转换为 [0, 1]。

然后,torchvision.datasets.FashionMNIST() 方法分别从 Fashion-MNIST 数据集的训练集和测试集中下载数据,并对其进行转换处理。其中,root 参数指定了数据集存储的根目录,train=True 表示加载训练集,train=False 表示加载测试集,transform=trans 表示对每张图片进行转换处理,download=True 表示如果本地没有该数据集则进行下载。

最后,mnist_train 和 mnist_test 分别存储了转换后的训练集和测试集数据。可以通过 torch.utils.data.DataLoader() 方法将这些数据打包成数据迭代器,以便后续的训练和测试。

# 通过ToTensor实例将图像数据从PIL类型变换成32位浮点数格式,
# 并除以255使得所有像素的数值均在0~1之间
trans = transforms.ToTensor()
mnist_train = torchvision.datasets.FashionMNIST(
    root="../data", train=True, transform=trans, download=True)
mnist_test = torchvision.datasets.FashionMNIST(
    root="../data", train=False, transform=trans, download=True)

Fashion-MNIST由10个类别的图像组成, 每个类别由训练数据集(train dataset)中的6000张图像 和测试数据集(test dataset)中的1000张图像组成。 因此,训练集和测试集分别包含60000和10000张图像。 测试数据集不会用于训练,只用于评估模型性能。

查看形状

在这里插入图片描述

数据可视化

获取Fashion-MNIST数据集的文本标签

def get_fashion_mnist_labels(labels):  #@save
    """返回Fashion-MNIST数据集的文本标签"""
    text_labels = ['t-shirt', 'trouser', 'pullover', 'dress', 'coat',
                   'sandal', 'shirt', 'sneaker', 'bag', 'ankle boot']
    return [text_labels[int(i)] for i in labels]

展示图片

def show_img(imgs,num_rows,num_cols,titles=None,scale=1.5):
	# 首先,该函数计算图形的大小,其中 scale 参数控制图形的缩放比例。接着,调用 subplots() 方法创建一个新的图形,并返回一个元组 (fig, axes),其中 fig 是整个图形对象,axes 是图形中的子图形列表。
    figsize=(num_cols*scale,num_rows*scale)
    _,axes=d2l.plt.subplots(num_rows,num_cols,figsize=figsize)
    # 接下来,将子图形列表 axes 转换成一维数组,以便后续可以使用单个索引访问子图形
    axes=axes.flatten()
    # 接着,函数使用 zip() 方法将子图形列表 axes 和要显示的图片列表 imgs 配对,并逐一遍历它们。对于每一对子图形 ax 和图片 img,函数分别调用 ax.imshow() 方法将图片添加到子图形中,并将其 x 轴和 y 轴标签设置为不可见。如果提供了 titles 参数,函数还会为每张图片添加标题。
    for i,(ax,img) in enumerate(zip(axes,imgs)):
        if torch.is_tensor(img):
            ax.imshow(img.numpy())
        else:
            ax.imshow(img)
        ax.axes.get_xaxis().set_visible(False)
        ax.axes.get_yaxis().set_visible(False)
        if titles:
            ax.set_title(titles[i])
    return axes

首先,该函数计算图形的大小,其中 scale 参数控制图形的缩放比例。接着,调用 subplots() 方法创建一个新的图形,并返回一个元组 (fig, axes),其中 fig 是整个图形对象,axes 是图形中的子图形列表。

接下来,将子图形列表 axes 转换成一维数组,以便后续可以使用单个索引访问子图形。

接着,函数使用 zip() 方法将子图形列表 axes 和要显示的图片列表 imgs 配对,并逐一遍历它们。对于每一对子图形 ax 和图片 img,函数分别调用 ax.imshow() 方法将图片添加到子图形中,并将其 x 轴和 y 轴标签设置为不可见。如果提供了 titles 参数,函数还会为每张图片添加标题。

最后,函数返回子图形列表 axes,以便调用者可以进一步操作和定制化。


显示前几个样本的图像和标签

X,y=next(iter(data.DataLoader(mnist_train,batch_size=18)))
show_img(X.reshape(18,28,28),2,9,titles=get_fashion_mnist_labels(y))

在这里插入图片描述

读取小批量

batch_size = 256

def get_dataloader_workers():  #@save
    """使用4个进程来读取数据"""
    return 4

train_iter = data.DataLoader(mnist_train, batch_size, shuffle=True,
                             num_workers=get_dataloader_workers())

这段代码使用PyTorch内置的DataLoader将Fashion-MNIST训练数据集mnist_train转换为一个多线程的数据迭代器train_iter。每次迭代将返回一个批量的数据样本和对应的标签,每个批量的大小为batch_size,这里设置为256。shuffle=True表示每次迭代前打乱数据样本的顺序。num_workers=get_dataloader_workers()指定了数据加载过程中使用的线程数,这里设置为4个进程。

整合所有组件

def load_data_fashion_mnist(batch_size, resize=None):  #@save
    """下载Fashion-MNIST数据集,然后将其加载到内存中"""
    trans = [transforms.ToTensor()]
    if resize:
        trans.insert(0, transforms.Resize(resize))
    trans = transforms.Compose(trans)
    mnist_train = torchvision.datasets.FashionMNIST(
        root="../data", train=True, transform=trans, download=True)
    mnist_test = torchvision.datasets.FashionMNIST(
        root="../data", train=False, transform=trans, download=True)
    return (data.DataLoader(mnist_train, batch_size, shuffle=True,
                            num_workers=get_dataloader_workers()),
            data.DataLoader(mnist_test, batch_size, shuffle=False,
                            num_workers=get_dataloader_workers()))

这是一个函数 load_data_fashion_mnist,它会下载 Fashion-MNIST 数据集并将其加载到内存中,最终返回训练集和测试集的 DataLoader 对象。其中 DataLoader 对象是 PyTorch 中用来封装数据集的对象,可以轻松地迭代数据集。

具体而言,load_data_fashion_mnist 的输入参数为 batch_size(批量大小)和 resize(图像大小调整)。这里默认情况下不进行大小调整。返回的结果包括训练集和测试集的 DataLoader 对象,其中的参数包括:

root:数据集存放的路径;
train:表示是否为训练集;
transform:图像转换方式,这里是将图像转为 Tensor 格式;
download:表示是否需要下载。
在 PyTorch 中,我们可以使用 transforms 模块进行数据增强或数据处理。transforms.Compose() 可以把多个变换组合在一起,构成一个变换序列。
在这个代码中,trans = [transforms.ToTensor()] 声明了一个列表变量 trans,其中包含一个变换:把 PIL 图片转换成 PyTorch 张量。如果 resize 参数被提供了,那么在这个列表的最前面插入一个 transforms.Resize(resize) 变换,它可以将 PIL 图片缩放到指定的大小。最后,transforms.Compose(trans) 把 trans 列表中的变换按照顺序组合起来,构成一个变换序列,可以在数据集加载时使用。

神经网络的搭建

加载数据集

from IPython import display
batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

初始化模型参数

num_inputs = 784
num_outputs = 10

W = torch.normal(0, 0.01, size=(num_inputs, num_outputs), requires_grad=True)
b = torch.zeros(num_outputs, requires_grad=True)

784是如何来的:我们知道原始数据集中每个样本都是12828的,我们将其展开铺平,看作是长度为784的向量

定义softmax函数

def softmax(X):
    X_exp = torch.exp(X)
    partition = X_exp.sum(1, keepdim=True)
    return X_exp / partition  # 这里应用了广播机制

定义模型

def net(X):
    return softmax(torch.matmul(X.reshape((-1, W.shape[0])), W) + b)

在 PyTorch 中,当我们在调用 reshape 函数时,可以将一个维度设为 -1,这样 PyTorch 会根据张量的总元素数自动推断出这个维度的长度。例如,如果一个张量的形状为 (2, 3, 4),当我们将其形状调整为 (6, -1) 时,PyTorch 会自动计算出第二个维度的长度为 4。
在 x.reshape((-1, W.shape[0])) 中,由于第一个维度被设为 -1,PyTorch 会自动根据张量的总元素数和第二个维度的长度来计算出第一个维度的长度。这个操作的结果是,x 张量中的所有元素都被按行排列成一个二维矩阵,这个矩阵的列数等于 W 张量的行数。


pytorch的广播机制:
假设我们有一个形状为 (3, 4) 的矩阵 A A A 和一个形状为 (4,) 的向量 b b b,它们分别如下所示:

A = [ 1 2 3 4 5 6 7 8 9 10 11 12 ] A = \begin{bmatrix} 1 & 2 & 3 & 4 \\ 5 & 6 & 7 & 8 \\ 9 & 10 & 11 & 12 \end{bmatrix} A= 159261037114812

b = [ 2 3 4 5 ] b = \begin{bmatrix} 2 & 3 & 4 & 5 \end{bmatrix} b=[2345]

在这种情况下,向量 b b b 将被扩展为形状为 (3, 4) 的矩阵,以便与矩阵 A A A 进行加法运算。扩展后的 b b b 如下所示:

b ′ = [ 2 3 4 5 2 3 4 5 2 3 4 5 ] b' = \begin{bmatrix} 2 & 3 & 4 & 5 \\ 2 & 3 & 4 & 5 \\ 2 & 3 & 4 & 5 \end{bmatrix} b= 222333444555

现在我们可以通过将 A A A b ′ b' b 相加来执行广播加法:

A + b ′ = [ 1 2 3 4 5 6 7 8 9 10 11 12 ] + [ 2 3 4 5 2 3 4 5 2 3 4 5 ] = [ 3 5 7 9 7 9 11 13 11 13 15 17 ] A + b' = \begin{bmatrix} 1 & 2 & 3 & 4 \\ 5 & 6 & 7 & 8 \\ 9 & 10 & 11 & 12 \end{bmatrix} + \begin{bmatrix} 2 & 3 & 4 & 5 \\ 2 & 3 & 4 & 5 \\ 2 & 3 & 4 & 5 \end{bmatrix} = \begin{bmatrix} 3 & 5 & 7 & 9 \\ 7 & 9 & 11 & 13 \\ 11 & 13 & 15 & 17 \end{bmatrix} A+b= 159261037114812 + 222333444555 = 371159137111591317

可以看到, b b b 在加法运算中被广播为 b ′ b' b,矩阵 A A A b ′ b' b 形状相同,因此可以进行加法运算。


定义损失函数(难点)

我们使用交叉熵作为损失函数

def cross_entropy(y_hat, y):
    return - torch.log(y_hat[range(len(y_hat)), y])

首先解释:
y_hat的形状是(n, k),其中n是样本个数,k是类别数。那么y_hat[i, j]表示第i个样本属于第j个类别的概率。对于每个样本i,在标签y[i]对应的位置j上的概率值,就是y_hat[i, y[i]]。

y_hat[range(len(y_hat)), y]的意义是取出概率向量中,对应于样本标签的位置上的概率值。这里用到了Python中的高级索引(advanced indexing)。

而交叉熵损失函数的定义就是基于这些概率的,它测量的是模型预测出的概率分布与真实概率分布之间的差异。

这是一个计算交叉熵损失函数的函数。其中 y_hat 是模型的输出, y 是真实标签,两者形状相同。

这段代码实现了标准的交叉熵损失函数。我们知道,交叉熵是一种衡量概率分布之间差异的测量方法。在分类问题中,我们使用softmax函数将网络的输出转换为类别概率分布,然后将此分布与真实类别的分布进行比较。

假设 y y y 是真实类别的索引, y i y_i yi 是类别 i i i 的指示函数(如果样本属于类别 i i i,则 y i = 1 y_i = 1 yi=1,否则 y i = 0 y_i = 0 yi=0)。 y ^ \hat{y} y^ 是预测的类别概率分布,即 y ^ i = P ( y = i ∣ x ) \hat{y}_i = P(y = i \mid x) y^i=P(y=ix)。那么,交叉熵损失函数定义为:

loss ⁡ ( y , y ^ ) = − ∑ i y i log ⁡ y ^ i \operatorname{loss}(\mathbf{y}, \hat{\mathbf{y}}) = -\sum_i y_i \log \hat{y}_i loss(y,y^)=iyilogy^i

由于在 y y y 中只有一个元素是 1,所以 y i log ⁡ y ^ i = 0 y_i \log \hat{y}_i = 0 yilogy^i=0 对所有 i i i 不等于 y y y i i i 成立,所以只有真实类别对损失函数做出了贡献,即

loss ⁡ ( y , y ^ ) = − log ⁡ y ^ y \operatorname{loss}(\mathbf{y}, \hat{\mathbf{y}}) = -\log \hat{y}_{y} loss(y,y^)=logy^y

这就是上述代码中的实现。

分类精度

def accuracy(y_hat, y):  #@save
    """计算预测正确的数量"""
    if len(y_hat.shape) > 1 and y_hat.shape[1] > 1:
        y_hat = y_hat.argmax(axis=1)
    cmp = y_hat.type(y.dtype) == y
    return float(cmp.type(y.dtype).sum())

这是一个计算分类准确率的函数,它接收两个参数,分别为预测值 y_hat 和真实标签 y。在函数中,首先判断 y_hat 的形状是否为二维且第二个维度的大小大于 1,如果是,则取每个样本预测值最大的下标作为预测类别。然后,将预测值与真实标签相比较,生成一个布尔类型的张量 cmp,其中预测正确的位置为 True,否则为 False。最后,将 cmp 中为 True 的数量求和并转换为浮点数返回,即为准确率。

class Accumulator:  #@save
    """在n个变量上累加"""
    def __init__(self, n):
        self.data = [0.0] * n

    def add(self, *args):
        self.data = [a + float(b) for a, b in zip(self.data, args)]

    def reset(self):
        self.data = [0.0] * len(self.data)

    def __getitem__(self, idx):
        return self.data[idx]

这是一个累加器类,可以在n个变量上累加。累加器有以下几个方法:

__ init__(self, n):初始化一个长度为n的列表,列表的每个元素初始化为0.0。
add(self, *args):接受任意数量的参数,并将其转换为浮点数。然后,它将args和当前数据列表逐个相加。
reset(self):将数据列表中的所有元素重置为0.0。
__ getitem__(self, idx):获取数据列表中索引为idx的元素的值。

def evaluate_accuracy(net, data_iter):  #@save
    """计算在指定数据集上模型的精度"""
    if isinstance(net, torch.nn.Module):
        net.eval()  # 将模型设置为评估模式
    metric = Accumulator(2)  # 正确预测数、预测总数
    with torch.no_grad():
        for X, y in data_iter:
            metric.add(accuracy(net(X), y), y.numel())
    return metric[0] / metric[1]

这个函数用于计算模型在指定数据集上的精度(accuracy)。其中,net是待评估的模型,data_iter是数据集迭代器。函数首先判断net是否为torch.nn.Module的实例,如果是,则将模型设置为评估模式,即禁用Dropout层和BatchNormalization层等,以避免对模型性能的影响。metric是一个Accumulator类的实例,用于累加模型的正确预测数和预测总数。然后使用torch.no_grad()上下文管理器禁用梯度计算,避免占用过多的内存,同时加速模型的计算。接着,遍历数据集迭代器data_iter,对每个小批量数据进行预测,计算正确预测数和预测总数,最后返回正确预测数占预测总数的比例(即精度)。

训练

def train_epoch_ch3(net, train_iter, loss, updater):  #@save
    # 将模型设置为训练模式
    if isinstance(net, torch.nn.Module):
        net.train()
    # 训练损失总和、训练准确度总和、样本数
    metric = Accumulator(3)
    for X, y in train_iter:
        # 计算梯度并更新参数
        y_hat = net(X)
        l = loss(y_hat, y)
        if isinstance(updater, torch.optim.Optimizer):
            # 使用PyTorch内置的优化器和损失函数
            updater.zero_grad()
            l.mean().backward()
            updater.step()
        else:
            # 使用定制的优化器和损失函数
            l.sum().backward()
            updater(X.shape[0])
        metric.add(float(l.sum()), accuracy(y_hat, y), y.numel())
    # 返回训练损失和训练精度
    return metric[0] / metric[2], metric[1] / metric[2]

这段代码实现了一个训练模型的一个迭代周期的函数,具体流程如下:

将模型设置为训练模式
定义一个 metric 对象,该对象是一个 Accumulator 类型,用于累计训练损失总和、训练准确度总和、样本数
对于每一个迭代周期中的每一个 mini-batch,执行以下操作:
1.使用模型进行预测,得到预测值 y_hat
2.计算损失函数 loss(y_hat, y) 的值
如果使用的是 PyTorch 内置的优化器,执行以下操作:
1.梯度清零
2.反向传播求梯度
3.使用优化器进行参数更新
如果使用的是自定义的优化器,执行以下操作:
1.反向传播求梯度
2.使用自定义的优化器进行参数更新
将当前 mini-batch 的损失值、训练准确度和样本数添加到 metric 对象中
计算训练损失和训练精度,并返回
在展示训练函数的实现之前,我们定义一个在动画中绘制数据的实用程序类Animator,以下代码可以不了解,与本节无关

class Animator:  #@save
    """在动画中绘制数据"""
    def __init__(self, xlabel=None, ylabel=None, legend=None, xlim=None,
                 ylim=None, xscale='linear', yscale='linear',
                 fmts=('-', 'm--', 'g-.', 'r:'), nrows=1, ncols=1,
                 figsize=(3.5, 2.5)):
        # 增量地绘制多条线
        if legend is None:
            legend = []
        d2l.use_svg_display()
        self.fig, self.axes = d2l.plt.subplots(nrows, ncols, figsize=figsize)
        if nrows * ncols == 1:
            self.axes = [self.axes, ]
        # 使用lambda函数捕获参数
        self.config_axes = lambda: d2l.set_axes(
            self.axes[0], xlabel, ylabel, xlim, ylim, xscale, yscale, legend)
        self.X, self.Y, self.fmts = None, None, fmts

    def add(self, x, y):
        # 向图表中添加多个数据点
        if not hasattr(y, "__len__"):
            y = [y]
        n = len(y)
        if not hasattr(x, "__len__"):
            x = [x] * n
        if not self.X:
            self.X = [[] for _ in range(n)]
        if not self.Y:
            self.Y = [[] for _ in range(n)]
        for i, (a, b) in enumerate(zip(x, y)):
            if a is not None and b is not None:
                self.X[i].append(a)
                self.Y[i].append(b)
        self.axes[0].cla()
        for x, y, fmt in zip(self.X, self.Y, self.fmts):
            self.axes[0].plot(x, y, fmt)
        self.config_axes()
        display.display(self.fig)
        display.clear_output(wait=True)

训练函数

def train_ch3(net, train_iter, test_iter, loss, num_epochs, updater):  #@save
    animator = Animator(xlabel='epoch', xlim=[1, num_epochs], ylim=[0.3, 0.9],
                        legend=['train loss', 'train acc', 'test acc'])
    for epoch in range(num_epochs):
        train_metrics = train_epoch_ch3(net, train_iter, loss, updater)
        test_acc = evaluate_accuracy(net, test_iter)
        animator.add(epoch + 1, train_metrics + (test_acc,))
    train_loss, train_acc = train_metrics
    assert train_loss < 0.5, train_loss
    assert train_acc <= 1 and train_acc > 0.7, train_acc
    assert test_acc <= 1 and test_acc > 0.7, test_acc

这段代码定义了一个函数train_ch3,用于训练神经网络模型。

函数的参数包括:

net:神经网络模型
train_iter:训练数据集迭代器
test_iter:测试数据集迭代器
loss:损失函数
num_epochs:训练迭代周期数
updater:优化器
函数中使用了Animator类,用于可视化训练过程中的损失函数和精度变化。

在函数中,首先使用Animator类初始化一个动画器。接下来进行循环,每次循环代表一个训练迭代周期。在每个训练周期内,调用train_epoch_ch3函数进行一次训练,并计算训练损失和训练精度。然后调用evaluate_accuracy函数计算测试集的精度,并将结果添加到动画器中。最后,对训练结果进行一些断言,确保训练的结果符合预期。

定义超参数和优化器

lr = 0.1

def updater(batch_size):
    return d2l.sgd([W, b], lr, batch_size)

开始训练

num_epochs = 10
train_ch3(net, train_iter, test_iter, cross_entropy, num_epochs, updater)

结果
在这里插入图片描述
训练结束之后全局变量W和b就是最后得出的最优参数

预测

def predict_ch3(net, test_iter, n=6):  #@save
    for X, y in test_iter:
        break
    trues = get_fashion_mnist_labels(y)
    preds = get_fashion_mnist_labels(net(X).argmax(axis=1))
    titles = [true +'\n' + pred for true, pred in zip(trues, preds)]
    d2l.show_images(
        X[0:n].reshape((n, 28, 28)), 1, n, titles=titles[0:n])

predict_ch3(net, test_iter)

predict_ch3函数的作用是对测试集数据进行预测并展示预测结果。具体而言,函数从测试集中取出前n个样本,将它们输入到训练好的模型net中进行预测,并将预测结果和真实标签拼接成字符串作为图像的标题,最后通过d2l.show_images函数展示这些图像。其中,d2l.get_fashion_mnist_labels函数用于将标签从数值形式转换为文本形式。

softmax公式的优化

为什么进行优化?
因为exp()函数为指数函数,有可能出现上溢的情况
解决方法:
每个数字在计算exp前先减去最大值max
在这里插入图片描述
新的问题:
此时有可能出现较小的负值导致下溢或者在反向传播时出现梯度消失
解决方法:
通过取对数的形式减少较小数对数值稳定性的干扰
此时softmax和交叉熵是结合起来的
在这里插入图片描述

softmax的简洁实现(基于pytorch的API)

import torch
from torch import nn
from d2l import torch as d2l
from IPython import display
batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
# PyTorch不会隐式地调整输入的形状。因此,
# 我们在线性层前定义了展平层(flatten),来调整网络输入的形状
net = nn.Sequential(nn.Flatten(), nn.Linear(784, 10))

def init_weights(m):
    if type(m) == nn.Linear:
        nn.init.normal_(m.weight, std=0.01)

net.apply(init_weights);
loss = nn.CrossEntropyLoss(reduction='none')
trainer = torch.optim.SGD(net.parameters(), lr=0.1)
num_epochs = 10
train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)
  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

青云遮夜雨

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值