• Pytorch 浅显入门


    张量

            张量如同数组和矩阵一样, 是一种特殊的数据结构。在PyTorch中, 神经网络的输入、输出以及网络的参数等数据, 都是使用张量来进行描述。

            张量的使用和Numpy中的ndarrays很类似, 区别在于张量可以在GPU或其它专用硬件上运行, 这样可以得到更快的加速效果。如果你对ndarrays很熟悉的话, 张量的使用对你来说就很容易了。

    库的导入:

    1. import torch
    2. import numpy as np

    张量初始化

    张量有很多种不同的初始化方法, 先来看看四个简单的例子:

    1. 直接生成张量

    1. # 由原始数据直接生成张量, 张量类型由原始数据类型决定
    2. data = [[1, 2], [3, 4]]
    3. x_data = torch.tensor(data)

    2. 通过Numpy数组来生成张量

    1. # 由已有的Numpy数组来生成张量(反过来也可以由张量来生成Numpy数组
    2. np_array = np.array(data)
    3. x_np = torch.from_numpy(np_array)

    3. 通过已有的张量来生成新的张量

    1. # 新的张量将继承已有张量的数据属性(结构、类型), 也可以重新指定新的数据类型
    2. x_ones = torch.ones_like(x_data) # 保留 x_data 的属性
    3. print(f"Ones Tensor: \n {x_ones} \n")
    4. x_rand = torch.rand_like(x_data, dtype=torch.float) # 重写 x_data 的数据类型:int -> float
    5. print(f"Random Tensor: \n {x_rand} \n")

    4. 通过指定数据维度来生成张量

    1. # shape是元组类型, 用来描述张量的维数, 下面3个函数通过传入shape来指定生成张量的维数
    2. shape = (2,3,)
    3. rand_tensor = torch.rand(shape)
    4. ones_tensor = torch.ones(shape)
    5. zeros_tensor = torch.zeros(shape)
    6. print(f"Random Tensor: \n {rand_tensor} \n")
    7. print(f"Ones Tensor: \n {ones_tensor} \n")
    8. print(f"Zeros Tensor: \n {zeros_tensor}")

    张量的属性

            从张量属性我们可以得到张量的维数、数据类型以及它们所存储的设备(CPU或GPU)。

    来看一个简单的例子:

    1. tensor = torch.rand(3,4)
    2. print(f"Shape of tensor: {tensor.shape}")
    3. print(f"Datatype of tensor: {tensor.dtype}")
    4. print(f"Device tensor is stored on: {tensor.device}")

    张量运算

            有超过100种张量相关的运算操作, 例如转置、索引、切片、数学运算、线性代数、随机采样等。更多的运算可以在这里查看

    torch.autograd的简要介绍

      torch.autograd是 PyTorch 的自动差分引擎,可为神经网络训练提供支持。

    背景

            神经网络(NN)是在某些输入数据上执行的嵌套函数的集合。 这些函数由参数(由权重和偏差组成)定义,这些参数在 PyTorch 中存储在张量中。

    训练 NN 分为两个步骤:

            正向传播:在正向传播中,NN 对正确的输出进行最佳猜测。 它通过其每个函数运行输入数据以进行猜测。

            反向传播:在反向传播中,NN 根据其猜测中的误差调整其参数。 它通过从输出向后遍历,收集有关函数参数(梯度)的误差导数并使用梯度下降来优化参数来实现。

    在pytorch中的用法

    看一个简单的具备了训练神经网络所需的一切的例子:

    1. import torch
    2. import torchvision
    3. import numpy as np
    4. # 从torchvision加载了经过预训练的 resnet18 模型
    5. # 创建一个随机数据张量来表示具有 3 个通道的单个图像,高度&宽度为 64
    6. # 其对应的label初始化为一些随机值
    7. model = torchvision.models.resnet18(pretrained = True)
    8. data = torch.rand(1,3,64,64)
    9. labels = torch.rand(1,1000)
    10. # 通过模型的每一层运行输入数据以进行预测
    11. prediction = model(data) # forward pass
    12. # 使用模型的预测和相应的标签来计算误差(loss) 下一步是通过网络反向传播此误差
    13. # 当在误差张量上调用.backward()时开始反向传播
    14. # 然后,Autograd 会为每个模型参数计算梯度并将其存储在参数的.grad属性中
    15. loss = (prediction - labels).sum()
    16. loss.backward() # backward pass
    17. # 加载一个优化器,在本例中为 SGD,学习率为 0.01,动量为 0.9
    18. # 在优化器中注册模型的所有参数
    19. optim = torch.optim.SGD(model.parameters(), lr = 1e-2, momentum = 0.9)
    20. # 调用.step()启动梯度下降, 优化器通过.grad中存储的梯度来调整每个参数
    21. optim.step()

    Autograd的微分

            让我们来看看autograd如何收集梯度。 我们用requires_grad=True创建两个张量ab。 这向autograd发出信号,应跟踪对它们的所有操作:

    1. a = torch.tensor([2., 3.], requires_grad=True)
    2. b = torch.tensor([6., 4.], requires_grad=True)

    我们从ab创建另一个张量Q:

    Q = 3*a**3 - b**2
    

            同样,我们也可以将Q聚合为一个标量,然后隐式地向后调用,例如Q.sum().backward()

    使用Autograd的向量微积分

    计算图 

            从概念上讲,Autograd 在由函数对象组成的有向无环图(DAG)中记录数据(张量)和所有已执行的操作(以及由此产生的新张量)。 在此 DAG 中,叶子是输入张量,根是输出张量。 通过从根到叶跟踪此图,可以使用链式规则自动计算梯度

            在正向传播中,Autograd 同时执行两项操作:

    • 运行请求的操作以计算结果张量,并且
    • 在 DAG 中维护操作的梯度函数

            当在 DAG 根目录上调用.backward()时,反向传递开始。 autograd然后:

    • 从每个.grad_fn计算梯度
    • 将它们累积在各自的张量的.grad属性中,然后
    • 使用链式规则,一直传播到叶子张量

            下面是我们示例中 DAG 的直观表示。 在图中,箭头指向前进的方向。 节点代表正向传播中每个操作的反向函数。 蓝色的叶节点代表我们的叶张量ab

            DAG 在 PyTorch 中是动态的。要注意的重要一点是,图是从头开始重新创建的; 在每个.backward()调用之后,Autograd 开始填充新图。 这正是允许您在模型中使用控制流语句的原因。 您可以根据需要在每次迭代中更改形状,大小和操作

    从DAG中排除

      torch.autograd跟踪所有将其requires_grad标志设置为True的张量的操作。 对于不需要梯度的张量,将此属性设置为False会将其从梯度计算 DAG 中排除。

           即使只有一个输入张量具有requires_grad=True,操作的输出张量也将需要梯度。

            

            在 NN 中,不计算梯度的参数通常称为冻结参数。 如果事先知道您不需要这些参数的梯度,则“冻结”模型的一部分很有用(通过减少自动梯度计算,这会带来一些性能优势)。

            从 DAG 中排除很重要的另一个常见用例是调整预训练网络。

            在微调中,我们冻结了大部分模型,通常仅修改分类器层以对新标签进行预测。 让我们来看一个小例子来说明这一点。 和以前一样,我们加载一个预训练的 resnet18 模型,并冻结所有参数。

    1. import torch
    2. import torchvision
    3. import numpy as np
    4. from torch import nn, optim
    5. model = torchvision.models.resnet18(pretrained = True)
    6. # Freeze all the parameters in the network
    7. for param in model.parameters():
    8. param.requires_grad = False
    9. # 假设我们要在具有 10 个标签的新数据集中微调模型
    10. # 在 resnet 中,分类器是最后一个线性层model.fc
    11. # 我们可以简单地将其替换为充当我们的分类器的新线性层(默认情况下未冻结)
    12. model.fc = nn.Linear(512,10)
    13. # 现在,除了model.fc的参数外,模型中的所有参数都将冻结
    14. # 计算梯度的唯一参数是model.fc的权重和偏差
    15. # Optimize only the classifier
    16. optimizer = optim.SGD(model.fc.parameters(), lr=1e-2, momentum=0.9)

            请注意,尽管我们在优化器中注册了所有参数,但唯一可计算梯度的参数(因此会在梯度下降中进行更新)是分类器的权重和偏差。

    神经网络

    可以使用torch.nn包构建神经网络。

            现在您已经了解了autogradnn依赖于autograd来定义模型并对其进行微分。 nn.Module包含层,以及返回output的方法forward(input)

    例如,查看以下对数字图像进行分类的网络:

    卷积网

    这是一个简单的前馈网络。 它获取输入,将其一层又一层地馈入,然后最终给出输出。

    神经网络的典型训练过程如下:

    • 定义具有一些可学习参数(或权重)的神经网络
    • 遍历输入数据集
    • 通过网络处理输入
    • 计算损失(输出正确的距离有多远)
    • 将梯度传播回网络参数
    • 通常使用简单的更新规则来更新网络的权重:weight = weight - learning_rate * gradient

    定义网络的例子

    1. import torch
    2. import torch.nn as nn
    3. import torch.nn.functional as F
    4. class Net(nn.Module):
    5. def __init__(self):
    6. super(Net, self).__init__()
    7. # 1 input image channel, 6 output channels, 5 x 5 square convolution
    8. # kernel
    9. self.conv1 = nn.Conv2d(1, 6, 5)
    10. self.conv2 = nn.Conv2d(6, 16, 5)
    11. # an affine operation: y = Wx + b
    12. self.fc1 = nn.Linear(16 * 5 * 5, 120) # 5 * 5 from image dimension
    13. self.fc2 = nn.Linear(120, 84)
    14. self.fc3 = nn.Linear(84, 10)
    15. def forward(self, x):
    16. # Max pooling over a (2, 2) window
    17. x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2))
    18. # If the size is a square, you can specify with a single number
    19. x = F.max_pool2d(F.relu(self.conv2(x)), 2)
    20. x = torch.flatten(x, 1) # flatten all dimensions except the batch dimension
    21. x = F.relu(self.fc1(x))
    22. x = F.relu(self.fc2(x))
    23. x = self.fc3(x)
    24. return x
    25. net = Net()
    26. print(net)

            只需要定义forward函数,就可以使用autograd自动定义backward函数(计算梯度)。 可以在forward函数中使用任何张量操作。

            模型的可学习参数由net.parameters()返回

    1. params = list(net.parameters())
    2. print(len(params))
    3. print(params[0].size()) # conv1's .weight

            尝试一个32x32随机输入。 注意:该网络的预期输入大小(LeNet)为32x32。 要在 MNIST 数据集上使用此网络,请将图像从数据集中调整为32x32

    1. input = torch.randn(1, 1, 32, 32)
    2. out = net(input)
    3. print(out)

            使用随机梯度将所有参数和反向传播的梯度缓冲区归零:

    1. net.zero_grad()
    2. out.backward(torch.randn(1, 10))

    注意

    torch.nn仅支持小批量。 整个torch.nn包仅支持作为微型样本而不是单个样本的输入。

    例如,nn.Conv2d将采用nSamples x nChannels x Height x Width的 4D 张量。

    如果您只有一个样本,只需使用input.unsqueeze(0)添加一个假批量尺寸。

            在继续之前,让我们回顾一下到目前为止所看到的所有类:

    • torch.Tensor-一个多维数组,支持诸如backward()的自动微分操作。 同样,保持相对于张量的梯度。
    • nn.Module-神经网络模块。 封装参数的便捷方法,并带有将其移动到 GPU,导出,加载等的帮助器。
    • nn.Parameter-一种张量,即将其分配为Module的属性时,自动注册为参数。
    • autograd.Function-实现自动微分操作的正向和反向定义。 每个Tensor操作都会创建至少一个Function节点,该节点连接到创建Tensor的函数,并且编码其历史记录。

    损失函数

            损失函数采用一对(输出,目标)输入,并计算一个值,该值估计输出与目标之间的距离。

    nn包下有几种不同的损失函数。 一个简单的损失是:nn.MSELoss,它计算输入和目标之间的均方误差。

    例如:

    1. output = net(input)
    2. target = torch.randn(10) # a dummy target, for example
    3. target = target.view(1, -1) # make it the same shape as output
    4. criterion = nn.MSELoss()
    5. loss = criterion(output, target)
    6. print(loss)

    执行结果:

    tensor(1.1649, grad_fn=)
    

            现在,如果使用.grad_fn属性向后跟随loss,您将看到一个计算图,如下所示:

    1. input -> conv2d -> relu -> maxpool2d -> conv2d -> relu -> maxpool2d
    2. -> view -> linear -> relu -> linear -> relu -> linear
    3. -> MSELoss
    4. -> loss

            因此,当我们调用loss.backward()时,整个图将被微分。 损失,并且图中具有requires_grad=True的所有张量将随梯度累积其.grad张量。

    为了说明,让我们向后走几步:

    1. print(loss.grad_fn) # MSELoss
    2. print(loss.grad_fn.next_functions[0][0]) # Linear
    3. print(loss.grad_fn.next_functions[0][0].next_functions[0][0]) # ReLU

    执行结果:

    1. object at 0x7f71283dd048>
    2. object at 0x7f71283dd7f0>
    3. object at 0x7f71283dd7f0>

    反向传播

            要反向传播误差,我们要做的只是对loss.backward()。 不过,您需要清除现有的梯度,否则梯度将累积到现有的梯度中。

    现在,我们将其称为loss.backward(),然后看一下向后前后conv1的偏差梯度。

    1. net.zero_grad() # zeroes the gradient buffers of all parameters
    2. print('conv1.bias.grad before backward')
    3. print(net.conv1.bias.grad)
    4. loss.backward()
    5. print('conv1.bias.grad after backward')
    6. print(net.conv1.bias.grad)

    执行结果:

    1. conv1.bias.grad before backward
    2. tensor([0., 0., 0., 0., 0., 0.])
    3. conv1.bias.grad after backward
    4. tensor([ 0.0188, 0.0172, -0.0044, -0.0141, -0.0058, -0.0013])

    更新权重

    实践中使用的最简单的更新规则是随机梯度下降(SGD):

    weight = weight - learning_rate * gradient

    使用简单的 Python 代码实现此目标:

    1. learning_rate = 0.01
    2. for f in net.parameters():
    3. f.data.sub_(f.grad.data * learning_rate)

            但是,在使用神经网络时,您希望使用各种不同的更新规则,例如 SGD,Nesterov-SGD,Adam,RMSProp 等。为实现此目的,我们构建了一个小包装:torch.optim,可实现所有这些方法。 使用它非常简单:

    1. import torch.optim as optim
    2. # create your optimizer
    3. optimizer = optim.SGD(net.parameters(), lr=0.01)
    4. # in your training loop:
    5. optimizer.zero_grad() # zero the gradient buffers
    6. output = net(input)
    7. loss = criterion(output, target)
    8. loss.backward()
    9. optimizer.step() # Does the update

    训练分类器

            通常,当您必须处理图像,文本,音频或视频数据时,可以使用将数据加载到 NumPy 数组中的标准 Python 包。 然后,您可以将该数组转换为torch.*Tensor

    • 对于图像,Pillow,OpenCV 等包很有用
    • 对于音频,请使用 SciPy 和 librosa 等包
    • 对于文本,基于 Python 或 Cython 的原始加载,或者 NLTK 和 SpaCy 很有用

            专门针对视觉,Pytorch 创建了一个名为torchvision的包,其中包含用于常见数据集(例如 Imagenet,CIFAR10,MNIST 等)的数据加载器,以及用于图像(即torchvision.datasetstorch.utils.data.DataLoader)的数据转换器。这提供了极大的便利,并且避免了编写样板代码。

            使用 CIFAR10 数据集。 它具有以下类别:“飞机”,“汽车”,“鸟”,“猫”,“鹿”,“狗”,“青蛙”,“马”,“船”,“卡车”。 CIFAR-10 中的图像尺寸为3x32x32,即尺寸为32x32像素的 3 通道彩色图像。

    训练图像分类器

    按顺序执行以下步骤:

    1. 使用torchvision加载并标准化 CIFAR10 训练和测试数据集
    2. 定义卷积神经网络
    3. 定义损失函数
    4. 根据训练数据训练网络
    5. 在测试数据上测试网络

    加载并标准化CIFAR10

    1. import torch
    2. import torchvision
    3. import torchvision.transforms as transforms
    4. # TorchVision 数据集的输出是[0, 1]范围的PILImage图像
    5. # 我们将它们转换为归一化范围[-1, 1]的张量
    6. # If running on Windows and you get a BrokenPipeError, try setting
    7. # the num_worker of torch.utils.data.DataLoader() to 0.
    8. transform = transforms.Compose(
    9. [transforms.ToTensor(),
    10. transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])
    11. trainset = torchvision.datasets.CIFAR10(root='./data', train=True,
    12. download=True, transform=transform)
    13. trainloader = torch.utils.data.DataLoader(trainset, batch_size=4,
    14. shuffle=True, num_workers=2)
    15. testset = torchvision.datasets.CIFAR10(root='./data', train=False,
    16. download=True, transform=transform)
    17. testloader = torch.utils.data.DataLoader(testset, batch_size=4,
    18. shuffle=False, num_workers=2)
    19. classes = ('plane', 'car', 'bird', 'cat',
    20. 'deer', 'dog', 'frog', 'horse', 'ship', 'truck')

    执行结果:

    1. Downloading https://www.cs.toronto.edu/~kriz/cifar-10-python.tar.gz to ./data/cifar-10-python.tar.gz
    2. Extracting ./data/cifar-10-python.tar.gz to ./data
    3. Files already downloaded and verified

    展示一些训练图像:

    1. import matplotlib.pyplot as plt
    2. import numpy as np
    3. # functions to show an image
    4. def imshow(img):
    5. img = img / 2 + 0.5 # unnormalize
    6. npimg = img.numpy()
    7. plt.imshow(np.transpose(npimg, (1, 2, 0)))
    8. plt.show()
    9. # get some random training images
    10. dataiter = iter(trainloader)
    11. images, labels = dataiter.next()
    12. # show images
    13. imshow(torchvision.utils.make_grid(images))
    14. # print labels
    15. print(' '.join('%5s' % classes[labels[j]] for j in range(4)))

    执行结果:

    dog truck  frog horse
    

     

    定义卷积神经网络

            之前从“神经网络”部分复制神经网络,然后对其进行修改以获取 3 通道图像(而不是定义的 1 通道图像)。

    1. import torch.nn as nn
    2. import torch.nn.functional as F
    3. class Net(nn.Module):
    4. def __init__(self):
    5. super(Net, self).__init__()
    6. self.conv1 = nn.Conv2d(3, 6, 5)
    7. self.pool = nn.MaxPool2d(2, 2)
    8. self.conv2 = nn.Conv2d(6, 16, 5)
    9. self.fc1 = nn.Linear(16 * 5 * 5, 120)
    10. self.fc2 = nn.Linear(120, 84)
    11. self.fc3 = nn.Linear(84, 10)
    12. def forward(self, x):
    13. x = self.pool(F.relu(self.conv1(x)))
    14. x = self.pool(F.relu(self.conv2(x)))
    15. x = x.view(-1, 16 * 5 * 5)
    16. x = F.relu(self.fc1(x))
    17. x = F.relu(self.fc2(x))
    18. x = self.fc3(x)
    19. return x
    20. net = Net()

    定义损失函数和优化器

    使用分类交叉熵损失和带有动量的 SGD

    1. import torch.optim as optim
    2. criterion = nn.CrossEntropyLoss()
    3. optimizer = optim.SGD(net.parameters(), lr=0.001, momentum=0.9)

    训练网络

            这是事情开始变得有趣的时候。 我们只需要遍历数据迭代器,然后将输入馈送到网络并进行优化即可。

    1. for epoch in range(2): # loop over the dataset multiple times
    2. running_loss = 0.0
    3. for i, data in enumerate(trainloader, 0):
    4. # get the inputs; data is a list of [inputs, labels]
    5. inputs, labels = data
    6. # zero the parameter gradients
    7. optimizer.zero_grad()
    8. # forward + backward + optimize
    9. outputs = net(inputs)
    10. loss = criterion(outputs, labels)
    11. loss.backward()
    12. optimizer.step()
    13. # print statistics
    14. running_loss += loss.item()
    15. if i % 2000 == 1999: # print every 2000 mini-batches
    16. print('[%d, %5d] loss: %.3f' %
    17. (epoch + 1, i + 1, running_loss / 2000))
    18. running_loss = 0.0
    19. print('Finished Training')

    执行结果:

    1. [1, 2000] loss: 2.196
    2. [1, 4000] loss: 1.849
    3. [1, 6000] loss: 1.671
    4. [1, 8000] loss: 1.589
    5. [1, 10000] loss: 1.547
    6. [1, 12000] loss: 1.462
    7. [2, 2000] loss: 1.382
    8. [2, 4000] loss: 1.389
    9. [2, 6000] loss: 1.369
    10. [2, 8000] loss: 1.332
    11. [2, 10000] loss: 1.304
    12. [2, 12000] loss: 1.288
    13. Finished Training

    保存训练过的模型:

    1. PATH = './cifar_net.pth'
    2. torch.save(net.state_dict(), PATH)

    根据测试数据测试网络

            我们已经在训练数据集中对网络进行了 2 次训练。 但是我们需要检查网络是否学到了什么。

            我们将通过预测神经网络输出的类别标签并根据实际情况进行检查来进行检查。 如果预测正确,则将样本添加到正确预测列表中。

            好的,第一步。 让我们显示测试集中的图像以使其熟悉:

    1. dataiter = iter(testloader)
    2. images, labels = dataiter.next()
    3. # print images
    4. imshow(torchvision.utils.make_grid(images))
    5. print('GroundTruth: ', ' '.join('%5s' % classes[labels[j]] for j in range(4)))

    GroundTruth:    cat  ship  ship plane
    

     

    1. outputs = net(images)
    2. # 输出是 10 类的能量。 一个类别的能量越高,网络就认为该图像属于特定类别
    3. # 因此,让我们获取最高能量的指数:
    4. _, predicted = torch.max(outputs, 1)
    5. print('Predicted: ', ' '.join('%5s' % classes[predicted[j]]
    6. for j in range(4)))
    1. # 网络在整个数据集上的表现
    2. correct = 0
    3. total = 0
    4. with torch.no_grad():
    5. for data in testloader:
    6. images, labels = data
    7. outputs = net(images)
    8. _, predicted = torch.max(outputs.data, 1)
    9. total += labels.size(0)
    10. correct += (predicted == labels).sum().item()
    11. print('Accuracy of the network on the 10000 test images: %d %%' % (
    12. 100 * correct / total))

    执行结果

    Accuracy of the network on the 10000 test images: 53 %
    

    1. # 看看所有分类的准确率
    2. class_correct = list(0. for i in range(10))
    3. class_total = list(0. for i in range(10))
    4. with torch.no_grad():
    5. for data in testloader:
    6. images, labels = data
    7. outputs = net(images)
    8. _, predicted = torch.max(outputs, 1)
    9. c = (predicted == labels).squeeze()
    10. for i in range(4):
    11. label = labels[i]
    12. class_correct[label] += c[i].item()
    13. class_total[label] += 1
    14. for i in range(10):
    15. print('Accuracy of %5s : %2d %%' % (
    16. classes[i], 100 * class_correct[i] / class_total[i]))

    执行结果

    1. Accuracy of plane : 50 %
    2. Accuracy of car : 62 %
    3. Accuracy of bird : 51 %
    4. Accuracy of cat : 32 %
    5. Accuracy of deer : 31 %
    6. Accuracy of dog : 35 %
    7. Accuracy of frog : 77 %
    8. Accuracy of horse : 70 %
    9. Accuracy of ship : 71 %
    10. Accuracy of truck : 52 %

    如何在GPU上进行训练

            就像将张量转移到 GPU 上一样,您也将神经网络转移到 GPU 上。

    如果可以使用 CUDA,首先将我们的设备定义为第一个可见的 cuda 设备:

    1. device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
    2. # Assuming that we are on a CUDA machine, this should print a CUDA device:
    3. print(device)

    出:

    cuda:0
    
    

    本节的其余部分假定device是 CUDA 设备。

    然后,这些方法将递归遍历所有模块,并将其参数和缓冲区转换为 CUDA 张量:

    net.to(device)
    
    

    请记住,您还必须将每一步的输入和目标也发送到 GPU:

    inputs, labels = data[0].to(device), data[1].to(device)

  • 相关阅读:
    SQL注入详解
    图解LeetCode——面试题 01.08. 零矩阵(难度:中等)
    数据结构和算法之如何建立图
    多线程经典代码案例及手动实现
    Android系统10 RK3399 init进程启动(四十) 开机自启动脚本init.rc执行逻辑框架
    【考研】数据结构考点——堆排序(含408真题)
    信创操作系统--麒麟Kylin桌面操作系统 (项目十一 使用终端及查看硬件信息)
    PHP 用户注册和登录表单 – PDO。
    JavaScript——基础知识
    解决Vue的router-link路由跳转但页面没有刷新问题
  • 原文地址:https://blog.csdn.net/m0_61897853/article/details/126962548