TensorFlow搭建循环神经网络:字母单步预测与多步预测、股票预测

  • 卷积神经网络:借助卷积核(kernel)提取特征后,送入后续网络(如全连接网络Dense)进行分类、目标检测等操作。CNN借助卷积核从空间维度提取信息,卷积核参数空间共享
  • 循环神经网络:借助循环核(cell)提取特征后,送入后续网络(如全连接网络Dense)进行预测等操作。RNN借助循环核从时间维度提取信息,循环核参数时间共享

循环神经网络

循环核

在这里插入图片描述
  循环核具有记忆力,通过不同时刻的参数共享,实现了对时间序列的信息提取。每个循环核有多个记忆体,对应上图中的多个小圆柱。记忆体内存储着每个时刻的状态信息 h t h_t ht,当前时刻循环核的输出特征为 y t y_t yt,相当于一层全连接层。

{ y t = s o f t m a x ( h t w h y + b y ) h t = t a n h ( x t w x h + h t − 1 w h h + b h ) \begin{cases} y_t=softmax(h_tw_{hy}+b_y)\\ h_t=tanh(x_tw_{xh}+h_{t-1}w_{hh}+b_h) \end{cases} {yt=softmax(htwhy+by)ht=tanh(xtwxh+ht1whh+bh)

  可以设定记忆体的个数从而改变记忆容量,当记忆体个数被指定、输入 x t x_t xt输出 y t y_t yt维度被指定,这些待训练参数的维度也就被限定了。在前向传播时,记忆体内存储的状态信息 h t h_t ht在每个时刻都被刷新,而三个参数矩阵 w x h w_{xh} wxh w h h w_{hh} whh w h y w_{hy} why和两个偏置项 b h b_h bh b y b_y by 自始至终都是固定不变的。在反向传播时,三个参数矩阵和两个偏置项由梯度下降法更新。

  将循环核按时间步展开,就是把循环核按照时间轴方向展开,每个时刻记忆体状态信息 h t h_t ht被刷新,记忆体周围的参数矩阵和两个偏置项是固定不变的,我们训练优化的就是这些参数矩阵。训练完成后,使用效果最好的参数矩阵执行前向传播,然后输出预测结果。

循环计算层:向输出方向生长

在这里插入图片描述
  在RNN中,每个循环核构成一层循环计算层,循环计算层的层数是向输出方向增长的。如上图所示,左图的网络有一个循环核,构成了一层循环计算层;中图的网络有两个循环核,构成了两层循环计算层;右图的网络有三个循环核,构成了三层循环计算层。其中,三个网络中每个循环核中记忆体的个数可以根据我们的需求任意指定。

TensorFlow描述循环计算层

tf.keras.layers.SimpleRNN(记忆体个数,activation=‘激活函数’,
						  return_sequences=是否每个时刻输出ℎ𝑡到下一层)
  • activation:‘激活函数’ (不写,默认使用tanh);
  • return_sequences:在输出序列中,返回最后时间步的输出值 h t h_t ht还是返回全部时间步的输出。False返回最后时刻(图右),True返回全部时刻(图左)。当下一层依然是RNN层,通常为True,反之如果后面是Dense层,通常为Fasle。
  • 输入维度:三维张量**(输入样本数, 循环核时间展开步数, 每个时间步输入特征个数)**;
  • 输出维度:当return_sequenc=True,三维张量(输入样本数, 循环核时间展开步数,本层的神经元个数);当return_sequenc=False,二维张量(输入样本数,本层的神经元个数)。

在这里插入图片描述
  如上图所示,左图一共要送入RNN层两组数据,每组数据经过一个时间步就会得到输出结果,每个时间步送入三个数值,则输入循环层的数据维度就是[2, 1, 3];右图输入只有一组数据,分四个时间步送入循环层,每个时间步送入两个数值 ,则输入循环层的数据维度就是 [1,4, 2]。

RNN训练

  得到RNN的前向传播结果之后,和其他神经网络类似,我们会定义损失函数,使用反向传播梯度下降算法训练模型。RNN唯一的区别在于:由于它每个时刻的节点都可能有一个输出,所以RNN的总损失为所有时刻(或部分时刻)上的损失和。

实践

  RNN最典型的应用就是利用历史数据预测下一时刻将发生什么,即根据以前见过的历史规律做预测。

1. 字母预测

1.1 one-hot编码

一步预测

在这里插入图片描述
  字母预测:输入a预测出b,输入b预测出c,输入c预测出d,输入d预测出e,输入e预测出a。

  第一步:import相关模块

import numpy as np
import tensorflow as tf
from tensorflow.keras.layers import Dense, SimpleRNN
import matplotlib.pyplot as plt
import os

  第二步:生成训练用的输入特征x_train和标签y_train(输入特征a对应的标签是b、输入特征b对应的标签是c、依次类推),打乱顺序后变形成RNN输入需要的维度。

input_word = "abcde"
w_to_id = {'a': 0, 'b': 1, 'c': 2, 'd': 3, 'e': 4}  # 单词映射到数值id的词典
id_to_onehot = {0: [1., 0., 0., 0., 0.], 1: [0., 1., 0., 0., 0.], 2: [0., 0., 1., 0., 0.], 3: [0., 0., 0., 1., 0.],
                4: [0., 0., 0., 0., 1.]}  # id编码为one-hot

x_train = [id_to_onehot[w_to_id['a']], id_to_onehot[w_to_id['b']], id_to_onehot[w_to_id['c']],
           id_to_onehot[w_to_id['d']], id_to_onehot[w_to_id['e']]]
y_train = [w_to_id['b'], w_to_id['c'], w_to_id['d'], w_to_id['e'], w_to_id['a']]

np.random.seed(7)
np.random.shuffle(x_train)
np.random.seed(7)
np.random.shuffle(y_train)
tf.random.set_seed(7)

# 使x_train符合SimpleRNN输入要求:[送入样本数, 循环核时间展开步数, 每个时间步输入特征个数]。
# 此处整个数据集送入,送入样本数为len(x_train);输入1个字母出结果,循环核时间展开步数为1; 表示为独热码有5个输入特征,每个时间步输入特征个数为5
x_train = np.reshape(x_train, (len(x_train), 1, 5))
y_train = np.array(y_train)

  第三步:构建模型,一个具有3个记忆体的循环层+一层全连接

model = tf.keras.Sequential([
    SimpleRNN(3),
    Dense(5, activation='softmax')
])

  剩余步骤:Compile → \rightarrow fit → \rightarrow summary。

model.compile(optimizer=tf.keras.optimizers.Adam(0.01),
              loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=False),
              metrics=['sparse_categorical_accuracy'])

checkpoint_save_path = "./checkpoint/rnn_onehot_1pre1.ckpt"

if os.path.exists(checkpoint_save_path + '.index'):
    print('-------------load the model-----------------')
    model.load_weights(checkpoint_save_path)

cp_callback = tf.keras.callbacks.ModelCheckpoint(filepath=checkpoint_save_path,
                                                 save_weights_only=True,
                                                 save_best_only=True,
                                                 monitor='loss')  # 由于fit没有给出测试集,不计算测试集准确率,根据loss,保存最优模型

history = model.fit(x_train, y_train, batch_size=32, epochs=100, callbacks=[cp_callback])

model.summary()

# print(model.trainable_variables)
file = open('./weights.txt', 'w')  # 参数提取
for v in model.trainable_variables:
    file.write(str(v.name) + '\n')
    file.write(str(v.shape) + '\n')
    file.write(str(v.numpy()) + '\n')
file.close()

  提取参数和 acc、loss可视化。

# 显示训练集和验证集的acc和loss曲线
acc = history.history['sparse_categorical_accuracy']
loss = history.history['loss']

plt.subplot(1, 2, 1)
plt.plot(acc, label='Training Accuracy')
plt.title('Training Accuracy')
plt.legend()

plt.subplot(1, 2, 2)
plt.plot(loss, label='Training Loss')
plt.title('Training Loss')
plt.legend()
plt.show()

  应用网络预测,

############### predict #############
preNum = int(input("input the number of test alphabet:"))
for i in range(preNum):
    alphabet1 = input("input test alphabet:")
    alphabet = [id_to_onehot[w_to_id[alphabet1]]]
    # 使alphabet符合SimpleRNN输入要求:[送入样本数, 循环核时间展开步数, 每个时间步输入特征个数]。此处验证效果送入了1个样本,送入样本数为1;输入1个字母出结果,所以循环核时间展开步数为1; 表示为独热码有5个输入特征,每个时间步输入特征个数为5
    alphabet = np.reshape(alphabet, (1, 1, 5))
    result = model.predict([alphabet])
    pred = tf.argmax(result, axis=1)
    pred = int(pred)
    tf.print(alphabet1 + '->' + input_word[pred])
多步预测

在这里插入图片描述
  输入连续四个字母,预测下一个字母:输入abcd输出e;输入bcde输出a;输入cdea输出b;输入deab输出c;输入eabc输出d。

  相比于一步预测,需要改变一下几点:(1)自制数据集输入及相应输出,

x_train = [
    [id_to_onehot[w_to_id['a']], id_to_onehot[w_to_id['b']], id_to_onehot[w_to_id['c']], id_to_onehot[w_to_id['d']]],
    [id_to_onehot[w_to_id['b']], id_to_onehot[w_to_id['c']], id_to_onehot[w_to_id['d']], id_to_onehot[w_to_id['e']]],
    [id_to_onehot[w_to_id['c']], id_to_onehot[w_to_id['d']], id_to_onehot[w_to_id['e']], id_to_onehot[w_to_id['a']]],
    [id_to_onehot[w_to_id['d']], id_to_onehot[w_to_id['e']], id_to_onehot[w_to_id['a']], id_to_onehot[w_to_id['b']]],
    [id_to_onehot[w_to_id['e']], id_to_onehot[w_to_id['a']], id_to_onehot[w_to_id['b']], id_to_onehot[w_to_id['c']]],
]
y_train = [w_to_id['e'], w_to_id['a'], w_to_id['b'], w_to_id['c'], w_to_id['d']]

(2)送入RNN的数据维度,

# 使x_train符合SimpleRNN输入要求:[送入样本数, 循环核时间展开步数, 每个时间步输入特征个数]。
# 此处整个数据集送入,送入样本数为len(x_train);输入4个字母出结果,循环核时间展开步数为4; 表示为独热码有5个输入特征,每个时间步输入特征个数为5
x_train = np.reshape(x_train, (len(x_train), 4, 5))
y_train = np.array(y_train)

(3)执行预测的输入,

############### predict #############
preNum = int(input("input the number of test alphabet:"))
for i in range(preNum):
    alphabet1 = input("input test alphabet:")
    alphabet = [id_to_onehot[w_to_id[a]] for a in alphabet1]
    # 使alphabet符合SimpleRNN输入要求:[送入样本数, 循环核时间展开步数, 每个时间步输入特征个数]。此处验证效果送入了1个样本,送入样本数为1;输入4个字母出结果,所以循环核时间展开步数为4; 表示为独热码有5个输入特征,每个时间步输入特征个数为5
    alphabet = np.reshape(alphabet, (1, 4, 5))
    result = model.predict([alphabet])
    pred = tf.argmax(result, axis=1)
    pred = int(pred)
    tf.print(alphabet1 + '->' + input_word[pred])

1.2 Embedding编码

  • 独热码:数据量大 过于稀疏,映射之间是独立的,没有表现出关联性
  • Embedding:是一种单词编码方法,用低维向量实现了编码,这种编码通过神经网络训练优化,能表达出单词间的相关性。

Tensorflow2中的词向量空间编码层:

tf.keras.layers.Embedding(词汇表大小,编码维度)
  • 词汇表大小:编码一共要表示多少个单词;
  • 编码维度:用几个数字表达一个单词;
  • 输入维度:二维张量 [送入样本数,循环核时间展开步数]
  • 输出维度:三维张量 [送入样本数,循环核时间展开步数,编码维度]

  例 :tf.keras.layers.Embedding(100, 3)。对数字1-100进行编码,词汇表大小就是100 ;每个自然数用三个数字表示,编码维度就是3; 所以Embedding层的参数是100和3。比如数字[4] embedding为 [0.25, 0.1, 0.11]。

一步预测

  相比使用one-hot编码的一步预测,
(1)自制数据集,

input_word = "abcde"
w_to_id = {'a': 0, 'b': 1, 'c': 2, 'd': 3, 'e': 4}  # 单词映射到数值id的词典

x_train = [w_to_id['a'], w_to_id['b'], w_to_id['c'], w_to_id['d'], w_to_id['e']]
y_train = [w_to_id['b'], w_to_id['c'], w_to_id['d'], w_to_id['e'], w_to_id['a']]

(2)把输入特征变成Embedding层期待的形状,

# 使x_train符合Embedding输入要求:[送入样本数, 循环核时间展开步数] ,
# 此处整个数据集送入所以送入,送入样本数为len(x_train);输入1个字母出结果,循环核时间展开步数为1。
x_train = np.reshape(x_train, (len(x_train), 1))
y_train = np.array(y_train)

(3)在模型部分相比于独热编码形式多了一个Embedding层对输入数据进行编码,这一层会生成一个五行两列的可训练参数矩阵,实现编码可训练。

model = tf.keras.Sequential([
    Embedding(5, 2),
    SimpleRNN(3),
    Dense(5, activation='softmax')
])

(4)预测,

############### predict #############
preNum = int(input("input the number of test alphabet:"))
for i in range(preNum):
    alphabet1 = input("input test alphabet:")
    alphabet = [w_to_id[alphabet1]]
    # 使alphabet符合Embedding输入要求:[送入样本数, 循环核时间展开步数]。
    # 此处验证效果送入了1个样本,送入样本数为1;输入1个字母出结果,循环核时间展开步数为1。
    alphabet = np.reshape(alphabet, (1, 1))
    result = model.predict(alphabet)
    pred = tf.argmax(result, axis=1)
    pred = int(pred)
    tf.print(alphabet1 + '->' + input_word[pred])
多步预测

  相比使用one-hot编码的一步预测,
(1)自制数据集,

input_word = "abcdefghijklmnopqrstuvwxyz"
w_to_id = {'a': 0, 'b': 1, 'c': 2, 'd': 3, 'e': 4,
           'f': 5, 'g': 6, 'h': 7, 'i': 8, 'j': 9,
           'k': 10, 'l': 11, 'm': 12, 'n': 13, 'o': 14,
           'p': 15, 'q': 16, 'r': 17, 's': 18, 't': 19,
           'u': 20, 'v': 21, 'w': 22, 'x': 23, 'y': 24, 'z': 25}  # 单词映射到数值id的词典

training_set_scaled = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
                       11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
                       21, 22, 23, 24, 25]
x_train = []
y_train = []
for i in range(4, 26):
    x_train.append(training_set_scaled[i - 4:i])
    y_train.append(training_set_scaled[i])

(2)把输入特征变成Embedding层期待的形状,

# 使x_train符合Embedding输入要求:[送入样本数, 循环核时间展开步数] ,
# 此处整个数据集送入所以送入,送入样本数为len(x_train);输入4个字母出结果,循环核时间展开步数为4。
x_train = np.reshape(x_train, (len(x_train), 4))
y_train = np.array(y_train)

(3)模型构建

model = tf.keras.Sequential([
    Embedding(26, 2),
    SimpleRNN(10),
    Dense(26, activation='softmax')
])

(4)预测,

################# predict ##################
preNum = int(input("input the number of test alphabet:"))
for i in range(preNum):
    alphabet1 = input("input test alphabet:")
    alphabet = [w_to_id[a] for a in alphabet1]
    # 使alphabet符合Embedding输入要求:[送入样本数, 时间展开步数]。
    # 此处验证效果送入了1个样本,送入样本数为1;输入4个字母出结果,循环核时间展开步数为4。
    alphabet = np.reshape(alphabet, (1, 4))
    result = model.predict([alphabet])
    pred = tf.argmax(result, axis=1)
    pred = int(pred)
    tf.print(alphabet1 + '->' + input_word[pred])

2. 股票预测

在这里插入图片描述
  SH600519.csv是用tushare模块下载的SH600519贵州茅台的日k线数据,本次例子中只用它的C列数据(如图1.2.26所示):用连续60天的开盘价,预测第61天的开盘价。下载数据的代码:

import tushare as ts
import matplotlib.pyplot as plt
df1 = ts.get_k_data('600519', ktype='D', start='2010-04-26', end='2020-04-26')
datapath1 = "./SH600519.csv"
df1.to_csv(datapath1)

2.1 RNN1982

#-------------------------
import numpy as np
import tensorflow as tf
from tensorflow.keras.layers import Dropout, Dense, SimpleRNN
import matplotlib.pyplot as plt
import os
import pandas as pd
from sklearn.preprocessing import MinMaxScaler
from sklearn.metrics import mean_squared_error, mean_absolute_error
import math
#-------------------------
maotai = pd.read_csv('./SH600519.csv')  # 读取股票文件

training_set = maotai.iloc[0:2426 - 300, 2:3].values  # 前(2426-300=2126)天的开盘价作为训练集,表格从0开始计数,2:3 是提取[2:3)列,前闭后开,故提取出C列开盘价
test_set = maotai.iloc[2426 - 300:, 2:3].values  # 后300天的开盘价作为测试集

# 归一化
sc = MinMaxScaler(feature_range=(0, 1))  # 定义归一化:归一化到(0,1)之间
training_set_scaled = sc.fit_transform(training_set)  # 求得训练集的最大值,最小值这些训练集固有的属性,并在训练集上进行归一化
test_set = sc.transform(test_set)  # 利用训练集的属性对测试集进行归一化

x_train = []
y_train = []

x_test = []
y_test = []

# 测试集:csv表格中前2426-300=2126天数据
# 利用for循环,遍历整个训练集,提取训练集中连续60天的开盘价作为输入特征x_train,第61天的数据作为标签,for循环共构建2426-300-60=2066组数据。
for i in range(60, len(training_set_scaled)):
    x_train.append(training_set_scaled[i - 60:i, 0])
    y_train.append(training_set_scaled[i, 0])
# 对训练集进行打乱
np.random.seed(7)
np.random.shuffle(x_train)
np.random.seed(7)
np.random.shuffle(y_train)
tf.random.set_seed(7)
# 将训练集由list格式变为array格式
x_train, y_train = np.array(x_train), np.array(y_train)

# 使x_train符合RNN输入要求:[送入样本数, 循环核时间展开步数, 每个时间步输入特征个数]。
# 此处整个数据集送入,送入样本数为x_train.shape[0]即2066组数据;输入60个开盘价,预测出第61天的开盘价,循环核时间展开步数为60; 每个时间步送入的特征是某一天的开盘价,只有1个数据,故每个时间步输入特征个数为1
x_train = np.reshape(x_train, (x_train.shape[0], 60, 1))
# 测试集:csv表格中后300天数据
# 利用for循环,遍历整个测试集,提取测试集中连续60天的开盘价作为输入特征x_train,第61天的数据作为标签,for循环共构建300-60=240组数据。
for i in range(60, len(test_set)):
    x_test.append(test_set[i - 60:i, 0])
    y_test.append(test_set[i, 0])
# 测试集变array并reshape为符合RNN输入要求:[送入样本数, 循环核时间展开步数, 每个时间步输入特征个数]
x_test, y_test = np.array(x_test), np.array(y_test)
x_test = np.reshape(x_test, (x_test.shape[0], 60, 1))
#-------------------------
model = tf.keras.Sequential([
    SimpleRNN(80, return_sequences=True),
    Dropout(0.2),
    SimpleRNN(100),
    Dropout(0.2),
    Dense(1)#预测第61天开盘价,输出一个值
])
#-------------------------
model.compile(optimizer=tf.keras.optimizers.Adam(0.001),
              loss='mean_squared_error')  # 损失函数用均方误差
# 该应用只观测loss数值,不观测准确率,所以删去metrics选项,一会在每个epoch迭代显示时只显示loss值
#-------------------------
checkpoint_save_path = "./checkpoint/rnn_stock.ckpt"

if os.path.exists(checkpoint_save_path + '.index'):
    print('-------------load the model-----------------')
    model.load_weights(checkpoint_save_path)

cp_callback = tf.keras.callbacks.ModelCheckpoint(filepath=checkpoint_save_path,
                                                 save_weights_only=True,
                                                 save_best_only=True,
                                                 monitor='val_loss')

history = model.fit(x_train, y_train, batch_size=64, epochs=50, validation_data=(x_test, y_test), validation_freq=1,
                    callbacks=[cp_callback])
#-------------------------
model.summary()

file = open('./weights.txt', 'w')  # 参数提取
for v in model.trainable_variables:
    file.write(str(v.name) + '\n')
    file.write(str(v.shape) + '\n')
    file.write(str(v.numpy()) + '\n')
file.close()

loss = history.history['loss']
val_loss = history.history['val_loss']

plt.plot(loss, label='Training Loss')
plt.plot(val_loss, label='Validation Loss')
plt.title('Training and Validation Loss')
plt.legend()
plt.show()
################## predict ######################
# 测试集输入模型进行预测
predicted_stock_price = model.predict(x_test)
# 对预测数据还原---从(0,1)反归一化到原始范围
predicted_stock_price = sc.inverse_transform(predicted_stock_price)
# 对真实数据还原---从(0,1)反归一化到原始范围
real_stock_price = sc.inverse_transform(test_set[60:])
# 画出真实数据和预测数据的对比曲线
plt.plot(real_stock_price, color='red', label='MaoTai Stock Price')
plt.plot(predicted_stock_price, color='blue', label='Predicted MaoTai Stock Price')
plt.title('MaoTai Stock Price Prediction')
plt.xlabel('Time')
plt.ylabel('MaoTai Stock Price')
plt.legend()
plt.show()
##########evaluate##############
# calculate MSE 均方误差 ---> E[(预测值-真实值)^2] (预测值减真实值求平方后求均值)
mse = mean_squared_error(predicted_stock_price, real_stock_price)
# calculate RMSE 均方根误差--->sqrt[MSE]    (对均方误差开方)
rmse = math.sqrt(mean_squared_error(predicted_stock_price, real_stock_price))
# calculate MAE 平均绝对误差----->E[|预测值-真实值|](预测值减真实值求绝对值后求均值)
mae = mean_absolute_error(predicted_stock_price, real_stock_price)
print('均方误差: %.6f' % mse)
print('均方根误差: %.6f' % rmse)
print('平均绝对误差: %.6f' % mae)

2.2 LSTM1997

  RNN面临的较大问题是无法解决长跨度依赖问题,即后面节点相对于跨度很大的前面时间节点的信息感知能力太弱。长跨度依赖的根本问题在于,多阶段的反向传播后会导致梯度消失、梯度爆炸。可以使用梯度截断去解决梯度爆炸问题,但无法轻易解决梯度消失问题。
在这里插入图片描述

  • 输入门:决定了多少例的信息会被存入当前细胞态;
  • 遗忘门:将细胞态中的信息选择性的遗忘;
  • 输出门:将细胞中的信息选择性的进行输出。
  • 记忆体:表征短期记忆,是当前细胞态经输出门得到;
  • 候选态:表示归纳出的待存入细胞态的新知识,是当前输入特征 x t x_t xt和上一时刻的短期记忆 g t − 1 g_{t-1} gt1的函数;
  • 细胞态:表示长期记忆,等于上一时刻的长期记忆通过遗忘门的值和当前时刻归纳出的新知识通过输入门的值之和。

Tensorflow2中描述LSTM层:

tf.keras.layers.LSTM(神经元个数,return_sequences=是否返回输出)

神经元个数和return_sequences的含义与SimpleRNN相同。

  只需要将RNN预测股票中的模型更换为

model = tf.keras.Sequential([
    LSTM(80, return_sequences=True),
    Dropout(0.2),
    LSTM(100),
    Dropout(0.2),
    Dense(1)
])

2.3 GRU2014

在这里插入图片描述
  门控循环单元(Gated Recurrent Unit,GRU)是LSTM的一种变体,将LSTM中遗忘门与输入门合二为一为更新门,模型比LSTM模型更简单。

Tensorflow2中描述GRU层:

tf.keras.layers.GRU(神经元个数, return_sequences=是否返回输出)

神经元个数和return_sequences的含义与SimpleRNN相同。

  只需要将RNN预测股票中的模型更换为

model = tf.keras.Sequential([
    GRU(80, return_sequences=True),
    Dropout(0.2),
    GRU(100),
    Dropout(0.2),
    Dense(1)
])

北大人工智能实践:Tensorflow笔记

  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值