动手学深度学习(Pytorch版)代码实践 -循环神经网络-55循环神经网络的从零开始实现和简洁实现

news2024/9/25 9:40:49

55循环神经网络的实现

1.从零开始实现

在这里插入图片描述

import math
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l
import matplotlib.pyplot as plt
import liliPytorch as lp

# 读取H.G.Wells的时光机器数据集
batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

# 查看数据集
# for X, Y in train_iter:
#     print('X:', X.shape)
#     print('Y:', Y.shape)
# print(vocab.token_freqs)
# print(vocab.idx_to_token)
# print(vocab.token_to_idx)

# 独热编码
# 将每个索引映射为相互不同的单位向量: 假设词表中不同词元的数目为N(即len(vocab)), 词元索引的范围为0
# 到N-1。 如果词元的索引是整数i, 那么我们将创建一个长度为N的全0向量, 并将第i处的元素设置为1。 
# 此向量是原始词元的一个独热向量。
# print(F.one_hot(torch.tensor([0,3,6]), len(vocab)))
"""
tensor([[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
         0, 0, 0, 0],
        [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        
         0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        
         0, 0, 0, 0]])
"""

# 每次采样的小批量数据形状是二维张量: (批量大小,时间步数)。 
# one_hot函数将这样一个小批量数据转换成三维张量, 张量的最后一个维度等于词表大小(len(vocab))。
# 我们经常转换输入的维度,以便获得形状为 (时间步数,批量大小,词表大小)的输出。 
# 这将使我们能够更方便地通过最外层的维度, 一步一步地更新小批量数据的隐状态。

# X = torch.arange(10).reshape((2, 5))
# print(X)
# tensor([[0, 1, 2, 3, 4],
#         [5, 6, 7, 8, 9]])
# print(X.T)
# tensor([[0, 5],
#         [1, 6],
#         [2, 7],
#         [3, 8],
#         [4, 9]])
# print(F.one_hot(X.T, 28).shape) # torch.Size([5, 2, 28])
# print(F.one_hot(X.T, 28))
"""
tensor([[[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0],
         [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0]],

        [[0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0],
         [0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0]],

        [[0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0],
         [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0]],

        [[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0],
         [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0]],

        [[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0],
         [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
          0, 0, 0, 0, 0]]])
"""

# 初始化模型参数
def get_params(vocab_size, num_hiddens, device):
    # 设置输入和输出的数量为词汇表的大小
    num_inputs = num_outputs = vocab_size

    # 定义一个函数,用于以正态分布初始化权重
    def normal(shape):
        return torch.randn(size=shape, device=device) * 0.01

    # 初始化隐藏层参数
    W_xh = normal((num_inputs, num_hiddens))  # 输入到隐藏层的权重
    W_hh = normal((num_hiddens, num_hiddens))  # 隐藏层到隐藏层的权重(循环权重)
    b_h = torch.zeros(num_hiddens, device=device)  # 隐藏层的偏置

    # 初始化输出层参数
    W_hq = normal((num_hiddens, num_outputs))  # 隐藏层到输出层的权重
    b_q = torch.zeros(num_outputs, device=device)  # 输出层的偏置

    # 将所有参数收集到一个列表中
    params = [W_xh, W_hh, b_h, W_hq, b_q]

    # 设置每个参数的requires_grad属性为True,以便在反向传播期间计算梯度
    for param in params:
        param.requires_grad_(True)

    return params  # 返回参数列表

# 循环神经网络模型
# 初始化时返回隐状态
def init_rnn_state(batch_size, num_hiddens, device):
    # batch_size:批量的大小,即每次输入到RNN的序列数量。
    # num_hiddens:隐藏层单元的数量,即隐藏状态的维度。
    return (torch.zeros((batch_size, num_hiddens), device=device), ) # 返回一个包含一个张量的元组


def rnn(inputs, state, params):
    # inputs的形状:(时间步数量,批量大小,词表大小)
    # state:初始隐藏状态,通常是一个元组,包含隐藏层的状态。
    # params:RNN的参数,包含权重和偏置。
    W_xh, W_hh, b_h, W_hq, b_q = params
    H, = state # 当前的隐藏状态。
    outputs = []
    # X的形状:(批量大小,词表大小)
    for X in inputs:
        H = torch.tanh(torch.mm(X, W_xh) + torch.mm(H, W_hh) + b_h)
        Y = torch.mm(H, W_hq) + b_q
        outputs.append(Y)
    return torch.cat(outputs, dim=0), (H,)

# 存储从零开始实现的循环神经网络模型的参数
class RNNModelScratch: #@save
    """从零开始实现的循环神经网络模型"""
    def __init__(self, vocab_size, num_hiddens, device,
                 get_params, init_state, forward_fn):
        self.vocab_size, self.num_hiddens = vocab_size, num_hiddens
        self.params = get_params(vocab_size, num_hiddens, device)
        self.init_state, self.forward_fn = init_state, forward_fn

    def __call__(self, X, state): # 前向传播方法
        X = F.one_hot(X.T, self.vocab_size).type(torch.float32)
        return self.forward_fn(X, state, self.params)

    def begin_state(self, batch_size, device): # 初始化隐藏状态
        return self.init_state(batch_size, self.num_hiddens, device)


# X = torch.arange(10).reshape((2, 5))
num_hiddens = 512
# net = RNNModelScratch(len(vocab), num_hiddens, d2l.try_gpu(), get_params,
#                       init_rnn_state, rnn)
# state = net.begin_state(X.shape[0], d2l.try_gpu()) # 初始化隐藏状态
# 调用模型实例的 __call__ 方法执行前向传播。
# Y, new_state = net(X.to(d2l.try_gpu()), state)
# Y:模型输出。
# new_state:更新后的隐藏状态。

# print(Y.shape, len(new_state), new_state[0].shape)
# torch.Size([10, 28]) 1 torch.Size([2, 512])
# 输出形状是(时间步数 X 批量大小,词表大小), 而隐状态形状保持不变,即(批量大小,隐藏单元数)


def predict_ch8(prefix, num_preds, net, vocab, device):  #@save
    """在prefix后面生成新字符
        prefix:生成文本的前缀,即初始输入字符序列。
        num_preds:要预测的字符数。
        net:训练好的循环神经网络模型。
        vocab:词汇表,包含字符到索引和索引到字符的映射。
    """
    state = net.begin_state(batch_size=1, device=device)
    outputs = [vocab[prefix[0]]] # outputs:用于存储生成字符的索引列表。
    get_input = lambda: torch.tensor([outputs[-1]], device=device).reshape((1, 1))
    for y in prefix[1:]:  # 预热期,遍历前缀中的剩余字符(从第二个字符开始)。
        _, state = net(get_input(), state) # 调用 net 进行前向传播,更新隐藏状态 state。
        outputs.append(vocab[y]) # 将当前字符的索引添加到 outputs 中。
        
    for _ in range(num_preds):  # 预测num_preds步
        # 调用 net 进行前向传播,获取预测结果 y 和更新后的隐藏状态 state。
        y, state = net(get_input(), state)
        # 使用 y.argmax(dim=1) 获取预测的字符索引,并将其添加到 outputs 中。
        outputs.append(int(y.argmax(dim=1).reshape(1)))
    return ''.join([vocab.idx_to_token[i] for i in outputs])

# print(predict_ch8('time traveller ', 10, net, vocab, d2l.try_gpu()))
# time traveller cfjwsthaqc

# 梯度裁剪
"""
在训练深层神经网络(特别是循环神经网络)时,梯度爆炸(gradients exploding)问题会导致梯度值变得非常大,
从而导致模型不稳定甚至训练失败。为了防止梯度爆炸,可以对梯度进行裁剪,使得梯度的范数不超过某个预设的阈值。
"""
def grad_clipping(net, theta):  #@save
    """裁剪梯度
        net:神经网络模型。
        theta:梯度裁剪的阈值。
    """
    if isinstance(net, nn.Module):
        params = [p for p in net.parameters() if p.requires_grad]
    else:
        params = net.params
    # 计算梯度范数, L2 范数
    norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
    if norm > theta:
        for param in params:
            param.grad[:] *= theta / norm
            # 将每个参数的梯度按比例缩放,使得新的梯度范数等于 theta。

# 训练
def train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter):
    """训练网络一个迭代周期(定义见第8章)"""
    state, timer = None, d2l.Timer()
    metric = lp.Accumulator(2)  # 训练损失之和,词元数量
    for X, Y in train_iter:
        if state is None or use_random_iter:
            # 在第一次迭代或使用随机抽样时初始化state
            state = net.begin_state(batch_size=X.shape[0], device=device)
        else:
            if isinstance(net, nn.Module) and not isinstance(state, tuple):
                # state对于nn.GRU是个张量
                state.detach_()
            else:
                # state对于nn.LSTM或对于我们从零开始实现的模型是个张量
                for s in state:
                    s.detach_()
        y = Y.T.reshape(-1)
        X, y = X.to(device), y.to(device)
        y_hat, state = net(X, state)
        l = loss(y_hat, y.long()).mean()
        if isinstance(updater, torch.optim.Optimizer):
            updater.zero_grad()
            l.backward()
            grad_clipping(net, 1)
            updater.step()
        else:
            l.backward()
            grad_clipping(net, 1)
            # 因为已经调用了mean函数
            updater(batch_size=1)
        metric.add(l * y.numel(), y.numel())
    return math.exp(metric[0] / metric[1]), metric[1] / timer.stop()

#@save
def train_ch8(net, train_iter, vocab, lr, num_epochs, device,
              use_random_iter=False):
    """训练模型(定义见第8章)"""
    loss = nn.CrossEntropyLoss()
    animator = lp.Animator(xlabel='epoch', ylabel='perplexity',
                            legend=['train'], xlim=[10, num_epochs])
    # 初始化
    if isinstance(net, nn.Module):
        updater = torch.optim.SGD(net.parameters(), lr)
    else:
        updater = lambda batch_size: d2l.sgd(net.params, lr, batch_size)

    predict = lambda prefix: predict_ch8(prefix, 50, net, vocab, device)
    # 训练和预测
    for epoch in range(num_epochs):
        ppl, speed = train_epoch_ch8(
            net, train_iter, loss, updater, device, use_random_iter)
        if (epoch + 1) % 10 == 0:
            print(predict('time traveller'))
            animator.add(epoch + 1, [ppl])
    print(f'困惑度 {ppl:.1f}, {speed:.1f} 词元/秒 {str(device)}')
    print(predict('time traveller '))
    print(predict('traveller '))

# 顺序抽样方法
num_epochs, lr = 500, 1
# train_ch8(net, train_iter, vocab, lr, num_epochs, d2l.try_gpu())
# plt.show()
"""
困惑度 1.0, 95138.3 词元/秒 cuda:0
time traveller you can show black is white by argument said filby
traveller you can show black is white by argument said filby
"""

# 随机抽样方法
net = RNNModelScratch(len(vocab), num_hiddens, d2l.try_gpu(), get_params,
                      init_rnn_state, rnn)
train_ch8(net, train_iter, vocab, lr, num_epochs, d2l.try_gpu(),
          use_random_iter=True)
plt.show()
"""
困惑度 1.3, 109268.9 词元/秒 cuda:0
time traveller held in his hand was a glitteringmetallic framewor
traveller held in his hand was a glitteringmetallic framewor
"""

顺序抽样:
在这里插入图片描述

随机抽样:
在这里插入图片描述

2.简洁实现
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l
import matplotlib.pyplot as plt

# 加载时光机器数据集并设置批量大小和序列长度
batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

# 定义RNN模型
num_hiddens = 256
rnn_layer = nn.RNN(len(vocab), num_hiddens)

# 用零张量初始化隐藏状态
state = torch.zeros((1, batch_size, num_hiddens))
# print(state.shape) # torch.Size([1, 32, 256])

# X = torch.rand(size=(num_steps, batch_size, len(vocab)))
# Y, state_new = rnn_layer(X, state)
# print(Y.shape, state_new.shape, X.shape)
# torch.Size([35, 32, 256]) torch.Size([1, 32, 256]) torch.Size([35, 32, 28])

# 完整的循环神经网络模型定义了一个RNNModel类
#@save
class RNNModel(nn.Module):
    """循环神经网络模型"""
    def __init__(self, rnn_layer, vocab_size, **kwargs):
        super(RNNModel, self).__init__(**kwargs)
        self.rnn = rnn_layer
        self.vocab_size = vocab_size
        self.num_hiddens = self.rnn.hidden_size
        # 如果RNN是双向的,num_directions应该是2,否则应该是1
        if not self.rnn.bidirectional:
            self.num_directions = 1
            self.linear = nn.Linear(self.num_hiddens, self.vocab_size)
        else:
            self.num_directions = 2
            self.linear = nn.Linear(self.num_hiddens * 2, self.vocab_size)

    def forward(self, inputs, state):
        X = F.one_hot(inputs.T.long(), self.vocab_size)
        X = X.to(torch.float32)
        Y, state = self.rnn(X, state)
        # 全连接层首先将Y的形状改为(时间步数*批量大小,隐藏单元数)
        # 它的输出形状是(时间步数*批量大小,词表大小)。
        output = self.linear(Y.reshape((-1, Y.shape[-1])))
        return output, state

    def begin_state(self, device, batch_size=1):
        if not isinstance(self.rnn, nn.LSTM):
            # nn.GRU以张量作为隐状态
            return  torch.zeros((self.num_directions * self.rnn.num_layers,
                                 batch_size, self.num_hiddens),
                                device=device)
        else:
            # nn.LSTM以元组作为隐状态
            return (torch.zeros((
                self.num_directions * self.rnn.num_layers,
                batch_size, self.num_hiddens), device=device),
                    torch.zeros((
                        self.num_directions * self.rnn.num_layers,
                        batch_size, self.num_hiddens), device=device))

# 训练与预测

device = d2l.try_gpu()
net = RNNModel(rnn_layer, vocab_size=len(vocab))
net = net.to(device)
num_epochs, lr = 500, 1
d2l.train_ch8(net, train_iter, vocab, lr, num_epochs, device)
"""
perplexity 1.3, 236379.1 tokens/sec on cuda:0
time traveller held in his hand was a glitteringmetallic framewo
traveller fith a slan but move anotle bothe thon st stagee 
"""
plt.show()
print(d2l.predict_ch8('time traveller', 10, net, vocab, device))
# time traveller held in h

在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1910093.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

若依 ruoyi-vue SpringBoot highlight-textarea 输入框敏感词关键词高亮标红(二)

参考文章,非常感谢大佬的分享 实现可高亮的输入框 — HighlightTextarea GitHub:highlight-textarea 可看作者上一篇文章 若依 ruoyi-vue SpringBoot聊天敏感词过滤sensitive-word(一) 效果图 审核时,输入框高亮敏感词&#xff…

由于找不到emp.dll无法运行游戏的多个有效解决方法分享

在玩游戏时候是否遇到过找不到emp.dll,无法继续执行代码问题无法打开游戏?那么这个emp.dll是什么呢?为什么会丢失,emp.dll丢失要怎么办?今天就给大家详细介绍一下emp.dll文件与emp.dll丢失的多个解决方法! 一、emp.dll…

详解[USACO07OPEN] Cheapest Palindrome G(洛谷PP2890)(区间DP经典题)

题目 思路 考虑区间DP。 设dp[i][j]为从i到j这段区间被修正为回文串的最小花费 c[cc][1]为添加字符cc的花费 c[cc][2]为删去字符cc的花费 s为题目给出的字符串。 用[i 1,j]区间转移:这种转移相当于在[i1,j]区间的左边加入一个字符,让[i,j]变为回文的方…

一款专业的 Windows 恶意程序分析与清理工具

大家好,今天给大家分享一款专业的 Windows 恶意程序分析与清理工具OpenArk,它能够帮助用户发现系统中隐藏的恶意软件。 OpenArk是一款Windows平台上的开源Ark工具. Ark是Anti-Rootkit(对抗恶意程序)的简写, OpenArk目标成为逆向工…

农牧行业CRM洞察:打造营、销、服一体化数字营销平台

01、行业应用背景 保持企业活力,支撑业务单元协调发展,稳定核心产品竞争力,将成为农牧行业企业数字化、数智化建设的指导方向。 积极发挥数据在生产、流通、消费各个环节的决策支撑,为农牧企业特别是多业态集团型企业&#xff0…

(附源码)c#+winform实现远程开机(广域网可用)

实现逻辑 利用UDP协议发送特定格式的魔术包,以远程唤醒具有特定MAC地址的目标计算机。目标计算机的BIOS和网络配置需要支持Wake-on-LAN(WOL)功能,并且需要在目标计算机上配置正确的网络唤醒设置。 源码在最后 准备工作 进入Bio…

从混乱到有序:三品产品生命周期管理PLM系统改善工艺管理

在当今竞争激烈的市场环境中,企业必须不断寻求提高效率和降低成本的方法。工艺管理作为产品开发和制造过程中的关键环节,对产品的成本和质量有着决定性的影响。随着信息化和并行化的发展,工艺管理的复杂性日益增加,传统的管理方式…

MATLAB数据统计描述和分析

描述性统计就是搜集、整理、加工和分析统计数据, 使之系统化、条理化,以显示出数据资料的趋势、特征和数量关系。它是统计推断的基础,实用性较强,在数学建模的数据描述部分经常使用。 目录 1.频数表和直方图 2 .统计量 3.统计…

基于智能座舱视觉DMS/OMS/RMS的简介

基于智能座舱视觉DMS/OMS/RMS的简介 引言 随着智能驾驶技术的迅猛发展,智能座舱逐渐成为汽车科技领域的热点话题。在智能座舱系统中,驾驶员监控系统(DMS)、乘员监控系统(OMS)以及舱室监控系统(…

可视化作品集(10):智慧楼宇大屏,美学效果杠杠的。

追求颜值在智慧楼宇大屏设计中是非常重要的,可以帮助提升用户体验、品牌形象和信息传递效果,为大屏的实际应用和效果带来更多的积极影响。 1. 吸引眼球: 精美的设计和视觉效果可以吸引用户的注意力,让用户更愿意去关注和了解大屏…

scratch绘制四个三角形 2024年6月中国电子学会 图形化编程 scratch编程等级考试二级真题和答案解析

scratch绘制四个三角形 一、题目要求 2024年6月电子学会图形化编程Scratch等级考试二级真题 1、准备工作 1.保留默认角色小猫; 2.添加背景Stars。 2、功能实现 1 .隐藏角色小猫,设置画笔裙始位置为(0,0),画笔颜色为黄色,画笔的粗细为5…

土壤品质检测仪:守护大地之母的科技卫士

土壤,作为地球生命之源,承载着万物的生长与繁衍。然而,随着现代农业的快速发展,土壤品质问题日益凸显,对农作物的生长和人们的健康构成了潜在威胁。 随着环保意识的增强和农业可持续发展的需求,土壤品质检测…

FreeRTOS——任务通知

一、任务通知 1.1 任务通知简介 任务通知:用来通知任务的,任务控制块中的结构体成员变量 ulNotifiedValue就是这个通知值 (1)使用队列、信号量、事件标志组时都需另外 创建一个结构体 ,通过中间的结构体进行间接通信…

文华财经多空波段均线交易黄金分割线指标公式源码

文华财经多空波段均线交易黄金分割线指标公式源码: 多:EMA(C,3),COLORYELLOW; 空:EMA(C,5),COLOR00FF00; 均衡:EMA(空,5),COLORWHITE; VARF1:COUNT(CROSS(多,均衡),2)1; VARF2:COUNT(CROSS(空,均衡),2)1; ZAI:FILTER(VARF1 AND VARF2,2); DRAWTEXT(ZAI,均衡*…

浅谈VPS主机上的数据库性能优化

如何提高网站性能?一个显而易见的解决方案是升级托管账户。您的网站将拥有更多硬件资源,因此可以同时处理更多请求并更快地传递数据。 无论如何,人们都是这么认为的。但事实总是不一样。 现代网站是一个复杂的系统,包含许多必须…

迅狐抖音机构号授权矩阵系统源码

在数字化营销的浪潮中,抖音以其独特的短视频形式迅速崛起,成为品牌传播和用户互动的重要平台。迅狐抖音机构号授权矩阵系统源码作为一项创新技术,为品牌在抖音上的深度运营提供了强大支持。 迅狐抖音机构号授权矩阵系统源码简介 迅狐抖音机…

7/8 复盘

后端数据传输? 后端代码的耦合:打点调用、方法调用、接口、继承。 Dao、Service、servlet(controller)各层的作用? Dao负责与数据库交互,执行SQL语句,例如简单的增删改查等等。(要创建对应的接口和实现类…

从RL的专业角度解惑 instruct GPT的目标函数

作为早期chatGPT背后的核心技术,instruct GPT一直被业界奉为里程碑式的著作。但是这篇论文关于RL的部分确写的非常模糊,几乎一笔带过。当我们去仔细审查它的目标函数的时候,心中不免有诸多困惑。特别是作者提到用PPO来做强化学习,…

企业为什么需要透明加密软件?5款透明加密软件推荐

透明加密技术(Transparent Encryption)是一种自动化的数据加密方法,旨在保护敏感数据不受未经授权的访问,而无需用户进行任何显式操作。 透明加密技术通常在操作系统或文件系统级别工作,实时加密和解密数据。这意味着…

RK3568------Openharmony 4.0-Release 浏览器部署安装

RK3568------Openharmony 4.0-Release 浏览器部署安装 文章目录 RK3568------Openharmony 4.0-Release 浏览器部署安装前言一、DevEco Studio开发工具安装与使用二、浏览器(Browser)样例代码编译三 、浏览器(Browser)部署四、遇到的问题五、效果展示总结 前言 上一篇文章讲解了…