动手学深度学习之图像分类案例1-2

参考伯禹学习平台《动手学深度学习》课程内容内容撰写的学习笔记
原文链接:https://www.boyuai.com/elites/course/cZu18YmweLv10OeV/lesson/ZDRJ8BaRpFmqDwJafJAYGn
感谢伯禹平台,Datawhale,和鲸,AWS给我们提供的免费学习机会!!
总的学习感受:伯禹的课程做的很好,课程非常系统,每个较高级别的课程都会有需要掌握的前续基础知识的介绍,因此很适合本人这种基础较差的同学学习,建议基础较差的同学可以关注伯禹的其他课程:
数学基础:https://www.boyuai.com/elites/course/D91JM0bv72Zop1D3
机器学习基础:https://www.boyuai.com/elites/course/5ICEBwpbHVwwnK3C

引言

图像分类,顾名思义,是一个输入图像,输出对该图像内容分类的描述的问题。它是计算机视觉的核心,实际应用广泛。图像分类的传统方法是特征描述及检测,这类传统方法可能对于一些简单的图像分类是有效的,但由于实际情况非常复杂,传统的分类方法不堪重负。现在,我们不再试图用代码来描述每一个图像类别,决定转而使用机器学习的方法处理图像分类问题。主要任务是给定一个输入图片,将其指派到一个已知的混合类别中的某一个标签。

Kaggle上的图像分类(CIFAR-10)

现在,我们将运用在前面几节中学到的知识来参加Kaggle竞赛,该竞赛解决了CIFAR-10图像分类问题。比赛网址是https://www.kaggle.com/c/cifar-10
#本节的网络需要较长的训练时间
#可以在Kaggle访问:
#https://www.kaggle.com/boyuai/boyu-d2l-image-classification-cifar-10

import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
import torchvision
import torchvision.transforms as transforms
import torch.nn.functional as F
import torch.optim as optim
from torchvision import datasets, transforms
import os
import time

print("PyTorch Version: ",torch.version)

获取和组织数据集

比赛数据分为训练集和测试集。训练集包含 50,000 图片。测试集包含 300,000 图片。两个数据集中的图像格式均为PNG,高度和宽度均为32像素,并具有三个颜色通道(RGB)。图像涵盖10个类别:飞机,汽车,鸟类,猫,鹿,狗,青蛙,马,船和卡车。 为了更容易上手,我们提供了上述数据集的小样本。“ train_tiny.zip”包含 80 训练样本,而“ test_tiny.zip”包含100个测试样本。它们的未压缩文件夹名称分别是“ train_tiny”和“ test_tiny”。

图像增强

data_transform = transforms.Compose([
transforms.Resize(40),
transforms.RandomHorizontalFlip(),
transforms.RandomCrop(32),
transforms.ToTensor()
])
trainset = torchvision.datasets.ImageFolder(root=’/home/kesci/input/CIFAR102891/cifar-10/train’
, transform=data_transform)
trainset[0][0].shape #第一个类别的第一张图片
data = [d[0].data.cpu().numpy() for d in trainset]
np.mean(data)
np.std(data)

#图像增强
transform_train = transforms.Compose([
transforms.RandomCrop(32, padding=4), #先四周填充0,再把图像随机裁剪成32*32
transforms.RandomHorizontalFlip(), #图像一半的概率翻转,一半的概率不翻转
transforms.ToTensor(), #转换格式
transforms.Normalize((0.4731, 0.4822, 0.4465), (0.2212, 0.1994, 0.2010)), #R,G,B每层的归一化用到的均值和方差
])

transform_test = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.4731, 0.4822, 0.4465), (0.2212, 0.1994, 0.2010)),
])

导入数据集

train_dir = ‘/home/kesci/input/CIFAR102891/cifar-10/train’
test_dir = ‘/home/kesci/input/CIFAR102891/cifar-10/test’

trainset = torchvision.datasets.ImageFolder(root=train_dir, transform=transform_train)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=256, shuffle=True)

testset = torchvision.datasets.ImageFolder(root=test_dir, transform=transform_test)
testloader = torch.utils.data.DataLoader(testset, batch_size=256, shuffle=False)

classes = [‘airplane’, ‘automobile’, ‘bird’, ‘cat’, ‘deer’, ‘dog’, ‘forg’, ‘horse’, ‘ship’, ‘truck’]

定义模型

ResNet-18网络结构:ResNet全名Residual Network残差网络。Kaiming He 的《Deep Residual Learning for Image Recognition》获得了CVPR最佳论文。他提出的深度残差网络在2015年可以说是洗刷了图像方面的各大比赛,以绝对优势取得了多个比赛的冠军。而且它在保证网络精度的前提下,将网络的深度达到了152层,后来又进一步加到1000的深度。

Image Name
class ResidualBlock(nn.Module): # 我们定义网络时一般是继承的torch.nn.Module创建新的子类

def __init__(self, inchannel, outchannel, stride=1):
    super(ResidualBlock, self).__init__()
    #torch.nn.Sequential是一个Sequential容器,模块将按照构造函数中传递的顺序添加到模块中。
    self.left = nn.Sequential(
        nn.Conv2d(inchannel, outchannel, kernel_size=3, stride=stride, padding=1, bias=False), 
        # 添加第一个卷积层,调用了nn里面的Conv2d()
        nn.BatchNorm2d(outchannel), # 进行数据的归一化处理
        nn.ReLU(inplace=True), # 修正线性单元,是一种人工神经网络中常用的激活函数
        nn.Conv2d(outchannel, outchannel, kernel_size=3, stride=1, padding=1, bias=False),
        nn.BatchNorm2d(outchannel)
    )
    self.shortcut = nn.Sequential() 
    if stride != 1 or inchannel != outchannel:
        self.shortcut = nn.Sequential(
            nn.Conv2d(inchannel, outchannel, kernel_size=1, stride=stride, bias=False),
            nn.BatchNorm2d(outchannel)
        )
    #  便于之后的联合,要判断Y = self.left(X)的形状是否与X相同

def forward(self, x): # 将两个模块的特征进行结合,并使用ReLU激活函数得到最终的特征。
    out = self.left(x)
    out += self.shortcut(x)
    out = F.relu(out)
    return out

class ResNet(nn.Module):
def init(self, ResidualBlock, num_classes=10):
super(ResNet, self).init()
self.inchannel = 64
self.conv1 = nn.Sequential( # 用3个3x3的卷积核代替7x7的卷积核,减少模型参数
nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False),
nn.BatchNorm2d(64),
nn.ReLU(),
)
self.layer1 = self.make_layer(ResidualBlock, 64, 2, stride=1)
self.layer2 = self.make_layer(ResidualBlock, 128, 2, stride=2)
self.layer3 = self.make_layer(ResidualBlock, 256, 2, stride=2)
self.layer4 = self.make_layer(ResidualBlock, 512, 2, stride=2)
self.fc = nn.Linear(512, num_classes)

def make_layer(self, block, channels, num_blocks, stride):
    strides = [stride] + [1] * (num_blocks - 1)   #第一个ResidualBlock的步幅由make_layer的函数参数stride指定
    # ,后续的num_blocks-1个ResidualBlock步幅是1
    layers = []
    for stride in strides:
        layers.append(block(self.inchannel, channels, stride))
        self.inchannel = channels
    return nn.Sequential(*layers)

def forward(self, x):
    out = 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)
    out = out.view(out.size(0), -1)
    out = self.fc(out)
    return out

def ResNet18():
return ResNet(ResidualBlock)

训练和测试

定义是否使用GPU

device = torch.device(“cuda” if torch.cuda.is_available() else “cpu”)

超参数设置

EPOCH = 20 #遍历数据集次数
pre_epoch = 0 # 定义已经遍历数据集的次数
LR = 0.1 #学习率

模型定义-ResNet

net = ResNet18().to(device)

定义损失函数和优化方式

criterion = nn.CrossEntropyLoss() #损失函数为交叉熵,多用于多分类问题
optimizer = optim.SGD(net.parameters(), lr=LR, momentum=0.9, weight_decay=5e-4)
#优化方式为mini-batch momentum-SGD,并采用L2正则化(权重衰减)

训练

if name == “main”:
print(“Start Training, Resnet-18!”)
num_iters = 0
for epoch in range(pre_epoch, EPOCH):
print(’\nEpoch: %d’ % (epoch + 1))
net.train() #训练前加上
sum_loss = 0.0
correct = 0.0
total = 0
for i, data in enumerate(trainloader, 0):
#用于将一个可遍历的数据对象(如列表、元组或字符串)组合为一个索引序列,同时列出数据和数据下标,
#下标起始位置为0,返回 enumerate(枚举) 对象。

        num_iters += 1
        inputs, labels = data
        inputs, labels = inputs.to(device), labels.to(device)
        optimizer.zero_grad()  # 清空梯度

        # forward + backward
        outputs = net(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        #每训练1个batch打印一次loss验准确率
        sum_loss += loss.item() * labels.size(0)
        _, predicted = torch.max(outputs, 1) #选出每一列中最大的值作为预测结果
        total += labels.size(0)
        #print(predicted.numpy())
        #print(labels.numpy()
        correct += (predicted == labels).sum().item()
        # 每20个batch打印一次loss和准确率
        if (i + 1) % 20 == 0:
            print('[epoch:%d, iter:%d] Loss: %.03f | Acc: %.3f%% '
                    % (epoch + 1, num_iters, sum_loss / (i + 1), 100. * correct / total))
    # 每训练完一个epoch测试一下准确率
    print("Walking Test")
    with torch.no.grad():
        correct = 0
        total = 0
        for data in testloader:
            net.eval()
            images,labels = data
            images,labels = images.to(device).labels.toldevice)
            outputs = net(images)
            #取得分最高的那个类(outputs,data的索引号)
            _.predicted = torch.max(outputs.data,1)
            total += correct/total
            print("EPOCH ACC = %.3f%%" % acc)
            #记录最佳测试分辨准确率
            if acc > best_acc:
                best_acc =acc
print("best_acc = %.3f%%" % acc)
print("Training Finished, TotalEPOCH=%d" % EPOCH)

我们可以参考ResNet的作者孙剑在北大「深度学习实践」这门课上对ResNet的介绍。
在ResNet之前,深度学习在模型的深度上就有了一定的探索了。AlexNet,有 8 层 (ILSVRC 2012) ,VGG有19层(ILSVRC 2014)。 其实google,VGG都想要把层数推的更高,但是从技术上来说,在20层之后,再加层的话,就没有什么用了,甚至结果更差。原因主要是没有办法去优化这么一个深度的非线性系统。
而孙剑他们微软的团队在2015年的时候提出了ResNet(ILSVRC 2015),把层数提升到了152层,主要是从原理上解决了这个优化很大程度上缓解了优化问题。
理论上来说,层数越深的话,那么每一层之间的变化就会越小,这样直接去学习它的一个变化的信号就比较困难。而残差学习的方式则是直接去学习不同层之间的残差量,即学习不同层的Delta,这样的话学习就更加容易。而他的实现只需要在网络中添加一些跳转分支就好了。

评论图片

ResNet的核心观点主要有两个:
• skip connection = “residual function”
• the shortest path contains only a few layers
更多原理性讲解可参考:https://zhuanlan.zhihu.com/p/91385516
而对于ResNet为什么work的解释也有很多,孙剑他们团队自己从 “shallow-to-deep” dynamics in training 上给出了解释。而残差网路使得梯度足够大,从而一定程度上使得优化更加方便了。还有很多其他大佬在这项工作发布后也给出了它之所以work的解释,可以参照这个:https://zhuanlan.zhihu.com/p/80226180
而ResNet和CNN结合,也产出了很多有意思且有效的模型,比如
Bottleneck (He et al. 2015)

评论图片
ResNeXt ( Xie et al. 2017 )
评论图片
Xception/MobileNets (Francois Chollet. 2017)(Howard et al. 2017)
评论图片
ShuffleNet(Zhang et al. 2017)
评论图片
等等。

Kaggle上的狗品种识别(ImageNet Dogs)

在本节中,我们将解决Kaggle竞赛中的犬种识别挑战,比赛的网址是https://www.kaggle.com/c/dog-breed-identification 在这项比赛中,我们尝试确定120种不同的狗。该比赛中使用的数据集实际上是著名的ImageNet数据集的子集。

#在本节notebook中,使用后续设置的参数在完整训练集上训练模型,大致需要40-50分钟
#请大家合理安排GPU时长,尽量只在训练时切换到GPU资源
#也可以在Kaggle上访问本节notebook:
#https://www.kaggle.com/boyuai/boyu-d2l-dog-breed-identification-imagenet-dogs

import torch
import torch.nn as nn
import torch.optim as optim
import torchvision
import torchvision.transforms as transforms
import torchvision.models as models
import os
import shutil
import time
import pandas as pd
import random

#设置随机数种子
random.seed(0)
torch.manual_seed(0)
torch.cuda.manual_seed(0)

整理数据集

我们可以从比赛网址上下载数据集,其目录结构为:

| Dog Breed Identification
    | train
    |   | 000bec180eb18c7604dcecc8fe0dba07.jpg
    |   | 00a338a92e4e7bf543340dc849230e75.jpg
    |   | ...
    | test
    |   | 00a3edd22dc7859c487a64777fc8d093.jpg
    |   | 00a6892e5c7f92c1f465e213fd904582.jpg
    |   | ...
    | labels.csv
    | sample_submission.csv

train和test目录下分别是训练集和测试集的图像,训练集包含10,222张图像,测试集包含10,357张图像,图像格式都是JPEG,每张图像的文件名是一个唯一的id。labels.csv包含训练集图像的标签,文件包含10,222行,每行包含两列,第一列是图像id第二列是狗的类别。狗的类别一共有120种。

我们希望对数据进行整理,方便后续的读取,我们的主要目标是:

  • 从训练集中划分出验证数据集,用于调整超参数。划分之后,数据集应该包含4个部分:划分后的训练集、划分后的验证集、完整训练集、完整测试集
  • 对于4个部分,建立4个文件夹:train, valid, train_valid, test。在上述文件夹中,对每个类别都建立一个文件夹,在其中存放属于该类别的图像。前三个部分的标签已知,所以各有120个子文件夹,而测试集的标签未知,所以仅建立一个名为unknown的子文件夹,存放所有测试数据。

我们希望整理后的数据集目录结构为:

| train_valid_test
    | train
    |   | affenpinscher
    |   |   | 00ca18751837cd6a22813f8e221f7819.jpg
    |   |   | ...
    |   | afghan_hound
    |   |   | 0a4f1e17d720cdff35814651402b7cf4.jpg
    |   |   | ...
    |   | ...
    | valid
    |   | affenpinscher
    |   |   | 56af8255b46eb1fa5722f37729525405.jpg
    |   |   | ...
    |   | afghan_hound
    |   |   | 0df400016a7e7ab4abff824bf2743f02.jpg
    |   |   | ...
    |   | ...
    | train_valid
    |   | affenpinscher
    |   |   | 00ca18751837cd6a22813f8e221f7819.jpg
    |   |   | ...
    |   | afghan_hound
    |   |   | 0a4f1e17d720cdff35814651402b7cf4.jpg
    |   |   | ...
    |   | ...
    | test
    |   | unknown
    |   |   | 00a3edd22dc7859c487a64777fc8d093.jpg
    |   |   | ...

data_dir = ‘/home/kesci/input/Kaggle_Dog6357/dog-breed-identification’ # 数据集目录
label_file, train_dir, test_dir = ‘labels.csv’, ‘train’, ‘test’ # data_dir中的文件夹、文件
new_data_dir = ‘./train_valid_test’ # 整理之后的数据存放的目录
valid_ratio = 0.1 # 验证集所占比例

def mkdir_if_not_exist(path):
# 若目录path不存在,则创建目录
if not os.path.exists(os.path.join(*path)):
os.makedirs(os.path.join(*path))

def reorg_dog_data(data_dir, label_file, train_dir, test_dir, new_data_dir, valid_ratio):
# 读取训练数据标签
labels = pd.read_csv(os.path.join(data_dir, label_file))
id2label = {Id: label for Id, label in labels.values} # (key: value): (id: label)

# 随机打乱训练数据
train_files = os.listdir(os.path.join(data_dir, train_dir))
random.shuffle(train_files)    

# 原训练集
valid_ds_size = int(len(train_files) * valid_ratio)  # 验证集大小
for i, file in enumerate(train_files):
    img_id = file.split('.')[0]  # file是形式为id.jpg的字符串
    img_label = id2label[img_id]
    if i < valid_ds_size:
        mkdir_if_not_exist([new_data_dir, 'valid', img_label])
        shutil.copy(os.path.join(data_dir, train_dir, file),
                    os.path.join(new_data_dir, 'valid', img_label))
    else:
        mkdir_if_not_exist([new_data_dir, 'train', img_label])
        shutil.copy(os.path.join(data_dir, train_dir, file),
                    os.path.join(new_data_dir, 'train', img_label))
    mkdir_if_not_exist([new_data_dir, 'train_valid', img_label])
    shutil.copy(os.path.join(data_dir, train_dir, file),
                os.path.join(new_data_dir, 'train_valid', img_label))

# 测试集
mkdir_if_not_exist([new_data_dir, 'test', 'unknown'])
for test_file in os.listdir(os.path.join(data_dir, test_dir)):
    shutil.copy(os.path.join(data_dir, test_dir, test_file),
                os.path.join(new_data_dir, 'test', 'unknown'))

reorg_dog_data(data_dir, label_file, train_dir, test_dir, new_data_dir, valid_ratio)

图像增强

transform_train = transforms.Compose([
# 随机对图像裁剪出面积为原图像面积0.081倍、且高和宽之比在3/44/3的图像,再放缩为高和宽均为224像素的新图像
transforms.RandomResizedCrop(224, scale=(0.08, 1.0),
ratio=(3.0/4.0, 4.0/3.0)),
# 以0.5的概率随机水平翻转
transforms.RandomHorizontalFlip(),
# 随机更改亮度、对比度和饱和度
transforms.ColorJitter(brightness=0.4, contrast=0.4, saturation=0.4),
transforms.ToTensor(),
# 对各个通道做标准化,(0.485, 0.456, 0.406)和(0.229, 0.224, 0.225)是在ImageNet上计算得的各通道均值与方差
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) # ImageNet上的均值和方差
])

#在测试集上的图像增强只做确定性的操作
transform_test = transforms.Compose([
transforms.Resize(256),
# 将图像中央的高和宽均为224的正方形区域裁剪出来
transforms.CenterCrop(224),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])

读取数据

#new_data_dir目录下有train, valid, train_valid, test四个目录
#这四个目录中,每个子目录表示一种类别,目录中是属于该类别的所有图像
train_ds = torchvision.datasets.ImageFolder(root=os.path.join(new_data_dir, ‘train’),
transform=transform_train)
valid_ds = torchvision.datasets.ImageFolder(root=os.path.join(new_data_dir, ‘valid’),
transform=transform_test)
train_valid_ds = torchvision.datasets.ImageFolder(root=os.path.join(new_data_dir, ‘train_valid’),
transform=transform_train)
test_ds = torchvision.datasets.ImageFolder(root=os.path.join(new_data_dir, ‘test’),
transform=transform_test)

batch_size = 128
train_iter = torch.utils.data.DataLoader(train_ds, batch_size=batch_size, shuffle=True)
valid_iter = torch.utils.data.DataLoader(valid_ds, batch_size=batch_size, shuffle=True)
train_valid_iter = torch.utils.data.DataLoader(train_valid_ds, batch_size=batch_size, shuffle=True)
test_iter = torch.utils.data.DataLoader(test_ds, batch_size=batch_size, shuffle=False) # shuffle=False

定义模型

这个比赛的数据属于ImageNet数据集的子集,我们使用微调的方法,选用在ImageNet完整数据集上预训练的模型来抽取图像特征,以作为自定义小规模输出网络的输入。

此处我们使用与训练的ResNet-34模型,直接复用预训练模型在输出层的输入,即抽取的特征,然后我们重新定义输出层,本次我们仅对重定义的输出层的参数进行训练,而对于用于抽取特征的部分,我们保留预训练模型的参数。

def get_net(device):
finetune_net = models.resnet34(pretrained=False) # 预训练的resnet34网络
finetune_net.load_state_dict(torch.load(’/home/kesci/input/resnet347742/resnet34-333f7ec4.pth’))
for param in finetune_net.parameters(): # 冻结参数
param.requires_grad = False
# 原finetune_net.fc是一个输入单元数为512,输出单元数为1000的全连接层
# 替换掉原finetune_net.fc,新finetuen_net.fc中的模型参数会记录梯度
finetune_net.fc = nn.Sequential(
nn.Linear(in_features=512, out_features=256),
nn.ReLU(),
nn.Linear(in_features=256, out_features=120) # 120是输出类别数
)
return finetune_net

定义训练函数

def evaluate_loss_acc(data_iter, net, device):
# 计算data_iter上的平均损失与准确率
loss = nn.CrossEntropyLoss() #交叉熵损失函数
is_training = net.training # Bool net是否处于train模式
net.eval()
l_sum, acc_sum, n = 0, 0, 0
with torch.no_grad():
for X, y in data_iter:
X, y = X.to(device), y.to(device)
y_hat = net(X)
l = loss(y_hat, y)
l_sum += l.item() * y.shape[0]
acc_sum += (y_hat.argmax(dim=1) == y).sum().item()
n += y.shape[0]
net.train(is_training) # 恢复net的train/eval状态
return l_sum / n, acc_sum / n

def train(net, train_iter, valid_iter, num_epochs, lr, wd, device, lr_period,
lr_decay):
loss = nn.CrossEntropyLoss()
optimizer = optim.SGD(net.fc.parameters(), lr=lr, momentum=0.9, weight_decay=wd)
net = net.to(device)
for epoch in range(num_epochs):
train_l_sum, n, start = 0.0, 0, time.time()
if epoch > 0 and epoch % lr_period == 0: # 每lr_period个epoch,学习率衰减一次
lr = lr * lr_decay
for param_group in optimizer.param_groups:
param_group[‘lr’] = lr
for X, y in train_iter:
X, y = X.to(device), y.to(device)
optimizer.zero_grad()
y_hat = net(X)
l = loss(y_hat, y)
l.backward()
optimizer.step()
train_l_sum += l.item() * y.shape[0]
n += y.shape[0]
time_s = “time %.2f sec” % (time.time() - start)
if valid_iter is not None:
valid_loss, valid_acc = evaluate_loss_acc(valid_iter, net, device)
epoch_s = ("epoch %d, train loss %f, valid loss %f, valid acc %f, "
% (epoch + 1, train_l_sum / n, valid_loss, valid_acc))
else:
epoch_s = ("epoch %d, train loss %f, "
% (epoch + 1, train_l_sum / n))
print(epoch_s + time_s + ', lr ’ + str(lr))

调参

#调参过程:将训练集,验证集代入上面的训练函数,根据输出结果,不断对参数
#进行调整得到好的参数之后,再将完整的训练集代入进行训练,最后将测试集分
#类,将结果输出。
num_epochs, lr_period, lr_decay = 20, 10, 0.1
lr, wd = 0.03, 1e-4
device = torch.device(‘cuda’ if torch.cuda.is_available() else ‘cpu’)

net = get_net(device)
train(net, train_iter, valid_iter, num_epochs, lr, wd, device, lr_period, lr_decay)

在完整数据集上训练模型

#使用上面的参数设置,在完整数据集上训练模型大致需要40-50分钟的时间
net = get_net(device)
train(net, train_valid_iter, None, num_epochs, lr, wd, device, lr_period, lr_decay)

对测试集分类并提交结果

用训练好的模型对测试数据进行预测。比赛要求对测试集中的每张图片,都要预测其属于各个类别的概率。

preds = []
for X, _ in test_iter:
X = X.to(device)
output = net(X)
output = torch.softmax(output, dim=1)
preds += output.tolist()
ids = sorted(os.listdir(os.path.join(new_data_dir, ‘test/unknown’)))
with open(‘submission.csv’, ‘w’) as f:
f.write(‘id,’ + ‘,’.join(train_valid_ds.classes) + ‘\n’)
for i, output in zip(ids, preds):
f.write(i.split(’.’)[0] + ‘,’ + ‘,’.join(
[str(num) for num in output]) + ‘\n’)

发布了17 篇原创文章 · 获赞 1 · 访问量 599

猜你喜欢

转载自blog.csdn.net/water19111213/article/details/104494485