안경잡이개발자

728x90
반응형

  PyTorch에서는 ImageFolder 라이브러리를 제공하는데, 다음과 같이 계층적인 폴더 구조에서 데이터셋을 불러올 때 사용할 수 있다. 다음과 같이 각 이미지들이 각 클래스명(class name)을 갖는 폴더 안에 들어가 있는 구조라면, ImageFolder 라이브러리를 이용해 바로 불러올 수 있다.

 

dataset/
    class 0/
        0.jpg
        1.jpg
        ...
    class 1/
        0.jpg
        1.jpg
        ...
    ...
    class 9/
        0.jpg
        1.jpg
        ...


  이러한 ImageFolder 형식을 가지고 있지 않은 데이터셋 예제를 불러와서, ImageFolder 형식에 맞게 변형해 보겠다. 한 번 다음의 데이터셋 예제를 확인해 보자.

 

  ▶ Scene Classification Dataset 예제https://github.com/ndb796/Scene-Classification-Dataset

 

GitHub - ndb796/Scene-Classification-Dataset

Contribute to ndb796/Scene-Classification-Dataset development by creating an account on GitHub.

github.com

 

  기본적으로 Scene Classification Dataset은 25,000개가량의 다양한 자연 경치(scene) 이미지로 구성되어 있다. 이때 각 이미지에 대하여 카테고리(category) 정보가 주어진다. 먼저 다음과 같은 코드로 전체 이미지 데이터를 다운로드한다.

 

# 깃허브에서 데이터셋 다운로드하기
!git clone https://github.com/ndb796/Scene-Classification-Dataset
# 폴더 안으로 이동
%cd Scene-Classification-Dataset

 

  이때 데이터셋 폴더 구성은 다음과 같다.

 

Dataset/
    train-scene classification/
        train/ # 전체 이미지가 담긴 폴더입니다.
            0.jpg
            1.jpg
            2.jpg
            ...
            24333.jpg
            24334.jpg
        train.csv # 학습 이미지마다 클래스(class) 정보가 포함되어 있습니다.
    test_WyRytb0.csv # 최종 테스트(test) 이미지의 번호를 포함하며, 본 실습에서 사용하지 않습니다.

 

 

  장면(scene) 데이터셋은 정확히 24,335개의 다양한 자연 경치(scene) 이미지로 구성되어 있다. 이때 각 이미지는 150 X 150 크기를 가진다. 또한, 총 6개의 클래스(class)로 구성된다. 전체 클래스 목록은 다음과 같다.

 

    0. 빌딩(buildings)
    1. 숲(forests)
    2. 빙하(glacier)
    3. 산(mountains)
    4. 바다(sea)
    5. 거리(street)

 

  이때 다음과 같은 방법으로 학습 이미지의 개수를 확인할 수 있다. 구체적으로 os 라이브러리의 os.listdir()를 이용하여 이미지 폴더에 존재하는 파일 이름을 확인할 수 있다. 예를 들어 다음과 같은 코드로 이미지의 개수를 확인할 수 있다.

 

import os
import pandas as pd


path = 'train-scene classification/'

# 전체 이미지 개수 출력하기
file_list = os.listdir(path + 'train/')
print('전체 이미지의 개수:', len(file_list))

# 학습 이미지 확인하기
dataset = pd.read_csv(path + 'train.csv')
print('학습 이미지의 개수:', len(dataset))
print('학습 이미지별 클래스 정보')
dataset.head()

 

 

  실행 결과는 다음과 같다.

 

 

  이때 클래스별 학습 이미지의 개수를 구하고자 한다면 다음과 같이 할 수 있다.

 

import matplotlib.pyplot as plt
import seaborn as sns

plt.rcParams['figure.figsize'] = (8.0, 6.0) # 그림의 기본 크기 설정

# 각 클래스별 개수 출력
print('클래스 0의 개수:', len(dataset[dataset.label == 0]))
print('클래스 1의 개수:', len(dataset[dataset.label == 1]))
print('클래스 2의 개수:', len(dataset[dataset.label == 2]))
print('클래스 3의 개수:', len(dataset[dataset.label == 3]))
print('클래스 4의 개수:', len(dataset[dataset.label == 4]))
print('클래스 5의 개수:', len(dataset[dataset.label == 5]))

# 각 클래스에 따른 학습 이미지의 개수를 출력하기
fig, ax = plt.subplots(figsize = (10, 4)) # 그림 크기 설정
sns.countplot(x ='label', data=dataset)
plt.xlabel("Class Label")
plt.ylabel("Number of Samples")
plt.show()

 

  실행 결과는 다음과 같다.

 

 

  이때 특정한 이미지를 불러와 화면에 출력하고자 한다면 다음과 같이 할 수 있다. 아래 코드는 첫 번째 이미지를 불러와 화면에 출력하는 코드다.

 

from skimage.transform import resize
from PIL import Image
import numpy as np


img = Image.open(path + 'train/' + file_list[0])
img = np.asarray(img)
img = resize(img, (64, 64, 3))
print('이미지의 해상도:', img.shape)

# 이미지 출력하기
plt.imshow(img)
plt.show()

 

  실행 결과는 다음과 같다.

 

 

  이어서 학습/검증 데이터셋을 나눌 수 있다. 이때 간단히 sklearn 라이브러리 train_test_split()을 이용하면 된다. 예를 들어 학습 이미지 데이터를 8:2 비율로 학습(training)과 검증(validation)으로 나누는 예제는 다음과 같다.

 

from sklearn.model_selection import train_test_split


train_dataset, val_dataset = train_test_split(dataset, test_size=0.2)

print('학습 데이터셋 크기:', len(train_dataset))
print('검증 데이터셋 크기:', len(val_dataset))

 

  실행 결과는 다음과 같다. 이제 결과적으로 데이터셋이 나누어진 것이다.

 

 

  이제 나누어진 데이터셋을 적절한 폴더에 저장하기 위해 다음과 같이 먼저 폴더를 만든다. 클래스 개수가 6개뿐이므로, 간단히 다음과 같이 모든 폴더를 만들 수 있다.

 

!mkdir Scene-Classification-Dataset-Split
!mkdir Scene-Classification-Dataset-Split/train
!mkdir Scene-Classification-Dataset-Split/train/buildings
!mkdir Scene-Classification-Dataset-Split/train/forests
!mkdir Scene-Classification-Dataset-Split/train/glacier
!mkdir Scene-Classification-Dataset-Split/train/mountains
!mkdir Scene-Classification-Dataset-Split/train/sea
!mkdir Scene-Classification-Dataset-Split/train/street
!mkdir Scene-Classification-Dataset-Split/val
!mkdir Scene-Classification-Dataset-Split/val/buildings
!mkdir Scene-Classification-Dataset-Split/val/forests
!mkdir Scene-Classification-Dataset-Split/val/glacier
!mkdir Scene-Classification-Dataset-Split/val/mountains
!mkdir Scene-Classification-Dataset-Split/val/sea
!mkdir Scene-Classification-Dataset-Split/val/street

 

  실행하면 다음과 같이 전체 폴더가 구성되는 것을 확인할 수 있다.

 

 

  이제 ImageFolder 형식에 맞게 이미지를 저장해보자. 모든 이미지는 (64 X 64 X 3) 형식으로 resize하여 저장한다.

 

import time


classes = ['buildings', 'forests', 'glacier', 'mountains', 'sea', 'street']

######### 학습 데이터셋 #########
start_time = time.time() # 시작 시간

# 데이터 정보를 하나씩 확인하며
for index, row in train_dataset.iterrows():
    # 이미지 정보를 배열에 담기
    img = Image.open(path + 'train/' + row['image_name'])
    img = np.asarray(img)
    img = resize(img, (64, 64, 3))
    img = Image.fromarray((img * 255).astype(np.uint8))
    saved = 'Scene-Classification-Dataset-Split/train/' + classes[row['label']] + '/' + str(index) + '.png'
    img.save(saved, 'PNG')

print("소요된 시간(초 단위):", time.time() - start_time) # 실행 시간

######### 검증 데이터셋 #########
start_time = time.time() # 시작 시간

# 데이터 정보를 하나씩 확인하며
for index, row in val_dataset.iterrows():
    # 이미지 정보를 배열에 담기
    img = Image.open(path + 'train/' + row['image_name'])
    img = np.asarray(img)
    img = resize(img, (64, 64, 3))
    img = Image.fromarray((img * 255).astype(np.uint8))
    saved = 'Scene-Classification-Dataset-Split/val/' + classes[row['label']] + '/' + str(index) + '.png'
    img.save(saved, 'PNG')

print("소요된 시간(초 단위):", time.time() - start_time) # 실행 시간

 

  이후에 만들어진 폴더를 압축(.zip)하여 저장한다.

 

!zip -r Scene-Classification-Dataset-Split.zip ./Scene-Classification-Dataset-Split/*
728x90
반응형

728x90
반응형

  ResNet과 같이 배치 정규화(batch normalization)를 포함하고 있는 네트워크를 특징 추출기(feature extractor)로 사용할 때 유의해야 할 점이 있다. 별로 중요하지 않은 것처럼 보여도, 실제로 모델을 만드는 입장에서 제대로 이해하고 있지 않으면 많이 헤맬 수 있는 부분이다.

 

  1. 학습 모드(training mode)와 평가 모드(evaluation mode)일 때 추출되는 특징 맵(feature map)이 다르다는 점

 

  기본적으로 배치 정규화는 학습 시 사용하는 파라미터와 평가 시 사용하는 파라미터가 다르게 적용된다. 따라서 만약 batch normalization을 포함한 네트워크를 feature extractor로 사용하고자 한다면, 같은 이미지에 대해 매번 동일한 특징 맵이 추출될 수 있도록 하기 위해 항상 train() 모드로 사용하거나 항상 eval() 모드로 사용하는 식으로 일관적일 필요가 있다. 일반적인 목적의 특징 추출기로 사용한다면 eval() 모드로만 사용하는 것을 추천한다. (학습할 때 train(), 평가할 때 eval()을 사용하는 것도 일반적인 분류 모델 학습 목적이라면 성능에 문제가 없다.)

 

  전이 학습(transfer learning)을 수행할 때 ResNet 기반의 고정된 특징 추출기가 필요하다면, conv 레이어 부분을 eval() 모드로 사용하면 된다. 만약 이를 지키지 않고 일관적이지 않게 train()과 eval()을 번갈아 호출하게 된다면, 동일한 이미지에 대하여 추출되는 feature maps이 매번 다른 값을 가질 수 있다. 앞서 언급했듯이 일반적인 분류 문제에서는 이게 큰 이슈가 되지 않는다. 다만 동일 이미지에 대한 feature maps가 항상 같아야 되는 경우에는 문제가 된다.

 

  2. 다른 네트워크를 포함한 네트워크에서 train()이나 eval()을 사용하는 경우

 

  필요한 경우 특징 추출기(feature extractor)를 포함한 하나의 네트워크 자체를 새롭게 정의할 수 있다. 이는 학습 코드 구현상의 편리성을 주는 경우가 많기 때문에, 종종 볼 수 있는 코드 유형이다. 예를 들어 ResNet18 모델의 앞부분은 고정한(fixed) 상태로 뒤쪽 FC 레이어만 새롭게 교체하여 학습하는 전이 학습(transfer learning) 방법을 사용할 수 있다. 소스코드 예시는 다음과 같다.

 

class StudentNetwork(nn.Module):
    def __init__(self):
        super(StudentNetwork, self).__init__()

        self.feature_extractor = nn.Sequential(*list(models.resnet18(pretrained=True).children())[:-1]).eval()
        self.fc = nn.Linear(512, 2) # binary classification (num_of_class == 2)

        # fix the pre-trained network
        for param in self.feature_extractor.parameters():
            param.requires_grad = False

    def forward(self, images):
        features = self.feature_extractor(images)
        x = torch.flatten(features, 1)
        outputs = self.fc(x)
        return features, outputs

 

  만약 위와 같이 모델을 정의했다면, 특징 추출기(feature extractor)는 항상 eval() 모드로 수행되는 것이다. 따라서 StudentNetwork 클래스의 인스턴스에 대하여 별도로 train()이나 eval()을 호출할 필요가 없다. 그냥 학습 단계든 평가 단계든 그 단계(phase)에 상관없이 그대로 사용하면 된다. 그러면 동일한 이미지에 대하여 항상 같은 features가 반환된다.

 

  위와 같이 ResNet 기반의 특징 추출기를 내부적으로 포함하고 있는 하나의 모델 인스턴스 model을 초기화한 경우를 생각해보자. 만약에 이때 model.train()을 호출하면 내부에 포함된 ResNet에 대해서도 학습 모드(train mode)가 적용되기 때문에, 의도치 않게 특징 추출기에 영향을 미칠 수 있다. 이 경우 동일한 이미지에 대하여 forward()의 결과인 features 텐서 값이 변경될 수 있는 것이다. 이 부분은 특히나 실수하기 쉬운 부분이므로 유의하자. 

 

  [참고] PyTorch에서 모델을 초기화하면 기본 설정으로 requires_grad 값이 True가 된다. (이는 사전 학습된 네트워크를 불러올 때에도 마찬가지다!) 따라서 별도로 명시하지 않는다면 자동으로 기울기(gradient)를 추적하기 때문에, 학습하지 않고자 하는 레이어에 대해서는 requires_grad 값을 명시적으로 False로 설정할 필요가 있다.

728x90
반응형

728x90
반응형

  파이토치(PyTorch)의 공식 문서에서 전이 학습(transfer learning)에 관해 설명하고 있는 문서는 다음과 같다.

 

  ▶ PyTorch Transfer Learningpytorch.org/tutorials/beginner/transfer_learning_tutorial.html

 

Transfer Learning for Computer Vision Tutorial — PyTorch Tutorials 1.7.1 documentation

Note Click here to download the full example code Transfer Learning for Computer Vision Tutorial Author: Sasank Chilamkurthy In this tutorial, you will learn how to train a convolutional neural network for image classification using transfer learning. You

pytorch.org

  공식 문서에서는 전이 학습(transfer learning)의 대표적인 두 가지 시나리오를 언급한다.

 

  1. 전체 네트워크를 fine-tuning 하는 방식

  2. 사전학습된(pre-trained) 네트워크를 고정된 특징 추출기(fixed feature extractor)로 사용하는 방식

 

  여기에서 1번과 2번의 실제 구현상의 차이점은 사전학습된 네트워크에 대하여 다음의 코드 부분을 넣느냐 마느냐이다. 아래 코드는 사전학습된(pre-trained) 네트워크의 가중치를 고정할 때 사용하는 코드이다.

 

for param in model_conv.parameters():
    param.requires_grad = False

 

  만약 2번 방식(fixed feature extractor)대로 사전학습된 네트워크의 가중치를 특징 추출기로 고정한다면, 뒤쪽에 있는 FC 레이어만 업데이트가 될 것이다. 또한 이 경우에는 앞쪽 레이어에 대한 기울기(gradient)를 계산하지 않아도 되기 때문에 학습 속도가 빨라진다. 참고로 2번의 방식을 사용하는 경우 optimizer에서는 FC 레이어의 파라미터에 대해서만 업데이트한다고 명시해야 한다. (optimizer는 계산된 기울기(gradient)를 이용해 업데이트(update)를 수행한다.)

 

  [참고 1] requires_grad를 False로 설정한 레이어에 대하여 optimizer에서 업데이트를 하겠다고 명시하더라도, 어차피 구해진 gradient 값 자체가 없기 때문에 업데이트가 수행되지 않기는 한다.

 

  [참고 2] PyTorch에서 모델을 초기화하면 기본 설정으로 requires_grad 값이 True가 된다. (이는 사전 학습된 네트워크를 불러올 때에도 마찬가지다!) 따라서 별도로 명시하지 않는다면 자동으로 기울기(gradient)를 추적하기 때문에, 학습하지 않고자 하는 레이어에 대해서는 requires_grad 값을 명시적으로 False로 설정할 필요가 있다.

 

  다만 공식 문서에서는 간단한 이진 분류(binary classification) 예시를 들고 있기 때문에 1번과 2번의 성능 차이가 크게 나지 않으며, 디테일한 하이퍼 파라미터 세팅을 하지 않아도 높은 성능이 나온다. 하지만 실제로 CIFAR-10과 같이 클래스의 개수가 많은 데이터셋을 이용하는 경우에는 성능 차이가 크게 날 수 있다.

 

  필자의 경우 클래스가 3개인 경우, 클래스가 10개인 경우에 대하여 학습을 진행해 보았다. 이때 2번 방법대로 마지막 FC 레이어만 학습하도록 한 경우에는 학습이 정상적으로 수행되지 않았다. 하이퍼 파라미터 세팅에 많은 신경을 써야 하는 것으로 보인다. 따라서 클래스의 개수가 많은 상황에서 빠르게 높은 정확도(high accuracy)를 얻고 싶다면 1번의 방법대로 앞쪽 네트워크를 고정하지 않고 전체 네트워크를 fine-tuning 하는 것이 유리할 수 있다.

 

  예를 들어 필자의 경우 CIFAR-10에 대하여 전이 학습(transfer learning)을 수행한 경험이 있는데, 다른 코드 부분은 완전히 동일하게 유지한 상태로 한 번은 다음과 같은 코드를 사용했다.

 

net = torchvision.models.resnet18(pretrained=True)

# 마지막 레이어의 차원을 10차원으로 조절
num_features = net.fc.in_features
net.fc = nn.Linear(num_features, 10)
net = net.to(device)

 

  그리고 한 번은 다음과 같은 코드를 사용했다.

 

net = torchvision.models.resnet18(pretrained=True)
for param in net.parameters():
    param.requires_grad = False

# 마지막 레이어의 차원을 10차원으로 조절
num_features = net.fc.in_features
net.fc = nn.Linear(num_features, 10)
net = net.to(device)

 

  첫째 경우(fine-tuning)에는 한 번의 epoch만으로 순식간에 94% 정도의 test accuracy를 얻을 수 있었지만, 둘째 경우(fixed feature extractor)에는 여러 번의 epoch을 반복해도 90% 이상의 성능은 얻을 수 없었다. 다시 말해 2번 방법이 학습 속도 측면에서 유리할 수 있으나, 클래스가 많은 상황에서는 성능이 낮게 나오는 문제가 발생할 수 있다.

 

  [참고] 이 문제는 해외 기술 블로그에서도 자주 다루어지고 있는 내용이다. 정리하자면 다음과 같다.

 

  ① Frozen: 앞쪽의 특징 추출기(feature extractor)에 대하여 역전파를 수행하지 않는 방법이다. 일반적으로 목표 작업(target task)의 레이블(label) 수가 적고 오버피팅(overfitting)을 예방하기 위한 목적으로 사용된다.

  ② Fine-tuning: 앞쪽의 특징 추출기(feature extractor)에 대하여 역전파를 수행하는 방법이다. 일반적으로 목표 작업(target task)의 레이블(label) 수가 많을 때 사용한다.

728x90
반응형

728x90
반응형

본 글은 cs231n 강의 노트를 정리한 것입니다.

 

※ CNN을 왜 사용할까? ※


  CNN을 이용한 분류기(classification)도 일반 뉴럴 네트워크처럼 이미지를 입력으로 받아 분류 결과를 출력한다. 다만 CNN은 이미지 데이터가 가지는 특성을 활용하여 더 적은 파라미터를 사용하며, 높은 성능을 보인다.

 

  일반적인 신경망(MLP)을 이용해 이미지를 처리할 때는 매우 많은 가중치가 필요하다. MNIST 데이터셋을 넘어서 CIFAR-10 데이터셋만 되어도 MLP를 이용하면 해결하기 어렵다. 32 x 32 크기의 색상 이미지라면 32 x 32 x 3 = 3,072개의 가중치(weight)가 필요하다. 은닉층의 크기가 1,000이라면 순식간에 3,072,000개의 파라미터가 필요하게 된다. 반면에 CNN은 커널(kernel)을 공유하기 때문에 훨씬 적은 가중치만 있어도 된다.

 

  ▶ 일반 신경망의 특징: 많은 가중치 필요, fully-connection 사용
   CNN의 특징: 적은 가중치 필요 (파라미터 공유), 이미지 처리에 적합

 

  예를 들어 컨볼루션 연산에서 커널의 개수 K = 12, 수용 영역 크기 F = 4, 이전 레이어의 채널 = 3이라고 해보자. 그러면 실제로 커널에 필요한 파라미터의 개수는 12 X 4 X 4 X 3 = 576이 된다. 이처럼 커널을 이용해 파라미터를 공유하면 필요한 가중치의 개수가 획기적으로 줄어들게 된다. 이런 아이디어가 사용될 수 있는 이유는, 하나의 패치 특징(patch feature)은 이미지의 전반적인 위치에서 나타날 수 있기 때문이다. 예를 들어 일반적인 이미지 데이터셋을 확인해 보면 대각선 엣지(edge) 형태의 특징은 이미지 전반에서 나올 수 있다.

 

※ CNN의 동작 ※

 

  CNN의 각 레이어는 너비(width), 높이(height), 깊이(depth)로 구성된다. 일반적인 MLP와 비교했을 때 깊이(depth)가 추가되었다.

 


CNN는 다음과 같은 레이어로 구성된다.

  ▶ 입력 레이어: 입력 이미지가 들어오는 레이어
  ▶ 컨볼루션 레이어: 입력 이미지의 일부 수용 영역(receptive field)에 대하여 컨볼루션 연산을 수행
  ▶ ReLU 레이어: 일반적으로 사용되는 액티베이션 함수(activation function)

  ▶ Pool 레이어: 너비(width)와 높이(height)에 대해 다운샘플링(downsampling) 수행

  ▶ FC 레이어: 네트워크의 마지막 부분에서 실질적으로 클래스 분류 수행

 

  컨볼루션 연산은 다음과 같이 수행된다.

 

 

  조금 더 자세한 예시로 확인하면 다음과 같다. indoml 사이트에서 정리한 글이 매우 보기 편하므로 참고해보자.

 

 

  이때 커널은 스트라이드(stride)의 크기만큼 슬라이딩하며 컨볼루션 연산을 수행한다.

 

 

  또한 처음에 가장 헷갈리는 부분은 컨볼루션 각 레이어의 차원이다. 커널(kernel)을 이용해 컨볼루션 연산을 수행하는데, 이때 이전 레이어의 채널(channel) 크기와 커널의 채널 크기가 동일하다. 예를 들어 입력 크기가 32 x 32 x 3이면, 커널에서도 5 x 5 x 3과 같이 채널의 크기로 3을 사용한다.

 

 

  또한 컨볼루션 연산을 수행한 뒤에는 커널의 개수가 레이어의 채널 크기가 된다. 예를 들어 32 x 32 x 3 입력에 대하여 5 x 5 x 3짜리 커널을 6개 사용했다면, 출력으로 나온 레이어는 28 x 28 x 6 크기를 가진다.

 

 

  일반적으로 CNN에서는 레이어가 깊어지면 너비와 높이는 감소하고 깊이(채널)는 증가한다. 이때 각 채널은 서로 다른 특징(feature)을 적절히 추출하도록 학습되므로, 다양한 특징들을 조합하여 적절히 분류를 수행하게 된다. 예를 들어 가장 기본적인 형태의 LeNet을 확인해보자. 최신 네트워크들은 이것보다 훨씬 깊은 레이어를 가지고 있지만, LeNet은 CNN의 전형적인 형태를 잘 보여주고 있다.

 

  레이어가 깊어질수록 너비와 높이는 감소하고 채널은 증가하는 것을 확인할 수 있다.

 

 

  또한 다운샘플링(downsampling) 목적으로 풀링 레이어(pooling layer)를 사용한다. 일반적으로 분류 네트워크에서는 max pooling과 average pooling이 사용된다. 예를 들어 2 x 2 풀링을 수행하는 경우 액티베이션(activation)의 25%만 남게 되므로 참고로 풀링을 수행해도 차원(dimension)은 변하지 않는다.

 

 

※ CNN의 일반적인 구조 ※

 

  가장 일반적인 CNN의 패턴은 다음과 같다. 결과적으로 마지막 FC 레이어를 거쳐 클래스 분류 결과가 나오게 된다. LeNet 이후에도 굉장히 다양한 CNN이 등장했으나 대부분 아래와 같은 패턴을 따른다는 점이 특징이다.

 

  INPUT → [[CONV → RELU] * N POOL] * M [FC → RELU] * K → FC

 

  예시 1) INPUT → FC

  예시 2) INPUT → CONV → RELU → FC

  예시 3) INPUT → [CONV → RELU → POOL] * 2 → FC → RELU → FC

 

※ 다양한 CNN 아키텍처 ※

 

1) AlexNet

 

  이미지 분류(Image Classification)에서 CNN이 큰 주목을 받게 해 준 네트워크다. ILSVRC 2012에 출전하여 압도적인 성능 차이를 보였다. CONV 레이어 이후에 바로 풀링 레이어를 넣지 않고, CONV를 중첩해서 사용했다는 점이 특징이다.

 

 

2) VGGNet

 

  ILSVRC 2014에서 2등을 한 네트워크다. VGG에서는 레이어를 깊게 쌓았을 때 더 좋은 성능이 나올 수 있다는 것을 보여주었다. 16개의 레이어로 구성되는 VGG-16이 대표적이다.

 

 

3) ResNet

 

  ILSVRC 2015에서 1등을 한 네트워크인 ResNet는 skip connection과 배치 정규화(batch normalization)를 사용했다는 점이 특징이다. 지금은 매우 많은 네트워크에서 skip connection과 배치 정규화를 사용하고 있으며, 2020년까지도 ResNet은 많은 논문에서 베이스라인(baseline)으로 성능 비교 대상이다.

 

728x90
반응형

728x90
반응형

  최근에 데이터셋을 직접 구축하여, 내가 만든 데이터셋으로 학습(Training)을 해야 하는 일이 생겼다.

 

  PyTorch에서는 ImageFolder라는 라이브러리를 제공한다. 이는 다음과 같은 계층적인 폴더 구조를 가지고 있는 데이터셋을 불러올 때 사용할 수 있다. 다시 말해 다음과 같이 각 이미지들이 자신의 레이블(Label) 이름으로 된 폴더 안에 들어가 있는 구조라면, ImageFolder 라이브러리를 이용하여 이를 바로 불러와 객체로 만들면 된다.

 

dataset/
	0/
		0.jpg
		1.jpg
        	...
	1/
		0.jpg
		1.jpg
		...
	...
	9/
		0.jpg
		1.jpg
		...

 

  한 번 연습을 위해서 기존에 존재하는 CIFAR-10 데이터셋을 불러와서, 이를 계층적인 폴더 구조가 되도록 이미지를 저장하는 소스코드를 만들어 보자. 그 다음에 다시 ImageFolder 라이브러리로 동일한 CIFAR-10 데이터셋을 불러오면 성공이다.

 

  먼저 다음과 같이 기본적으로 PyTorch에서 제공하고 있는 CIFAR-10 데이터셋을 불러와보자.

 

import torch
import torchvision
import torchvision.transforms as transforms
import matplotlib.pyplot as plt
import matplotlib.image as image
import numpy as np

transform_train = transforms.Compose([
    transforms.ToTensor(),
])

train_dataset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_train)
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=4)

 

  CIFAR-10의 경우 10개의 레이블로 구성된 데이터셋이므로, 각 레이블의 이미지가 몇 번 등장했는지를 기록해주는 변수를 선언하자.

 

import os

num_classes = 10
number_per_class = {}

for i in range(num_classes):
    number_per_class[i] = 0

 

  이후에 이미지 Torch 객체레이블 정수 값이 들어왔을 때, 이를 실제 폴더에 저장해주는 함수를 작성하자.

 

def custom_imsave(img, label):
    path = 'dataset/' + str(label) + '/'
    if not os.path.exists(path):
        os.makedirs(path)
    
    img = img.numpy()
    img = np.transpose(img, (1, 2, 0))
    image.imsave(path + str(number_per_class[label]) + '.jpg', img)
    number_per_class[label] += 1

 

  이제 만들어진 도구들을 이용하여 CIFAR-10 데이터셋에서 데이터를 배치 단위로 읽으며, 배치에 포함된 각 이미지를 하나씩 정확한 폴더에 저장될 수 있도록 하자.

 

def process():
    for batch_idx, (inputs, targets) in enumerate(train_loader):
        print("[ Current Batch Index: " + str(batch_idx) + " ]")
        for i in range(inputs.size(0)):
            custom_imsave(inputs[i], targets[i].item())

process()

 

  이후에 한 번 0번 레이블(비행기)의 첫 번째 이미지를 출력하도록 해보자. 정상적으로 잘 출력된다.

 

from PIL import Image
from matplotlib.pyplot import imshow

img = Image.open('dataset/0/0.jpg')
imshow(np.asarray(img))

 

 

  이제 ImageFolder 라이브러리를 이용해서, 우리가 저장한 이미지들을 이용해 다시 PyTorch 데이터셋 객체로 불러올 수 있는지 확인해보도록 하자.

 

from torchvision.datasets import ImageFolder

train_dataset = ImageFolder(root='./dataset', transform=transform_train)
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=4)

 

  이미지 출력용 함수는 다음과 같다. PyTorch의 경우 [Batch Size, Channel, Width, Height]의 구조를 가지고 있어서, 이를 matplotlib로 출력하기 위해서는 [Width, Height, Channel]의 순서로 변경해주어야 한다.

 

def custom_imshow(img):
    img = img.numpy()
    plt.imshow(np.transpose(img, (1, 2, 0)))
    plt.show()

 

  이제 이미지를 하나씩 출력하도록 해보자.

 

def process():
    for batch_idx, (inputs, targets) in enumerate(train_loader):
        custom_imshow(inputs[0])

process()

 

  실행 결과, 다음과 같이 정상적으로 데이터셋이 구성되었다는 사실을 알 수 있다.

 

728x90
반응형

728x90
반응형

  실험을 하면서 자주 쓰는 코드인데, 따로 정리를 해놓지 않아서 매 번 입력을 하고 있다. 그래서 정리하려고 한다. 일단 Dataset 객체를 불러올 때는 데이터를 전처리하는 부분이 들어간다. PyTorch의 경우 ToTenser() 함수를 불러오면, 이미지가 자동으로 [0, 1]의 값으로 변경된다. 예를 들어 CIFAR-10 학습용 데이터셋을 불러오는 코드는 다음과 같다.

 

import torch
import torchvision
import torchvision.transforms as transforms
import matplotlib.pyplot as plt
import numpy as np

transform_train = transforms.Compose([
    transforms.ToTensor(),
])

train_dataset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_train)
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=4)

 

  이렇게 불러온 이미지는 실제로 Tensor 객체로 존재하며, 각 원소의 값이 0부터 1 사이의 값이다. 그렇기 때문에 이를 화면에 출력하고자 한다면, 이 값을 다시 0부터 255 사이의 값으로 늘려야 하는 건지 궁금할 수 있다. 다행히도 파이썬의 matplotlib는 기본적으로 0부터 1사이의 값이라고 해도 알아서 인식하여 정상적인 이미지로 출력해준다. 하지만 별도로 OpenCV 등에서 활용하고자 한다면, 추가적인 전처리가 필요할 수 있다.

 

  또한 기본적으로 PyTorch는 이미지 데이터셋을 [Batch Size, Channel, Width, Height] 순서대로 저장하기 때문에, 이를 matplotlib로 출력하기 위해서는 각 이미지를 [Width, Height, Channel] 형태로 변경해 줄 필요가 있다. 그것은 numpy 라이브러리의 transpose() 함수를 이용하여 해결할 수 있다.

 

def custom_imshow(img):
    img = img.numpy()
    plt.imshow(np.transpose(img, (1, 2, 0)))
    plt.show()

 

  그럼 이제 이렇게 정의된 imshow() 함수를 이용하여 데이터셋의 이미지를 배치당 하나씩 출력해보자.

 

def process():
    for batch_idx, (inputs, targets) in enumerate(train_loader):
        custom_imshow(inputs[0])

process()

 

  실행 결과는 다음과 같다.

 

728x90
반응형

728x90
반응형

※ Google Colaboratory란? ※

 

  Google Colaboratory를 이용하게 되면, 기존에 주피터 노트북(Jupyter Notebook) 환경에서 인공지능을 공부하던 사람들은 이제 더욱 편하게 공부할 수 있게 됩니다. 자신의 컴퓨터 성능이 매우 좋은 것이 아니라면, 일반적인 프로그래밍을 공부하는 학생들은 CoLab을 이용하는 것을 추천합니다.

 

  CoLab은 주피터 노트북 환경과 Google Drive를 합친 것과 같습니다. 주피터 노트북만큼이나 편하게 소스코드를 작성하고, 그에 대한 설명을 작성할 수 있습니다. 뿐만 아니라 Google Drive에 문서를 저장하고, 이를 다시 불러올 수 있습니다. 더불어 구글 계정만 있으면 무료로 이용할 수 있다는 점에서 매우 간단히 이용하며 머신 러닝을 공부할 수 있습니다.

 

  CoLab은 2017년 10월에 공개되었고, 2019년 1월에 걸쳐 업데이트 되어 PyTorch를 포함해 상당수 유명 라이브러리들을 웹 환경에서 곧 바로 이용할 수 있는 상태입니다. 그래서 교육 목적, 팀 내에서 함께 연구할 목적으로 CoLab을 이용하면 매우 효과적입니다. 같이 소스코드를 작업하면, 곧바로 변경사항이 상대방에게 표시되기 때문에 사실상 뛰어난 공유 환경이라고 할 수 있습니다.

 

  이러한 CoLab은 무료 서비스일 뿐만 아니라 GPU까지 지원합니다. GPU는 한 번에 12시간 동안만 무료로 이용할 수 있지만, 공부를 하면서 웬만한 모델을 돌리는 목적으로 매우 충분하기 때문에 아주 강력하고 좋은 협업 개발환경이라고 할 수 있습니다.

 

  ▶ Google CoLab: https://colab.research.google.com/

 

Google Colaboratory

 

colab.research.google.com

 

  지금까지 다양한 머신러닝 프로젝트를 진행하기에 앞서, 항상 자신의 컴퓨터에 Jupyter 개발환경을 구축해야 했습니다. 하지만 CoLab은 그 자체로 웹 브라우저만 있어도 실행할 수 있는 개발환경입니다. 따라서 매우 편리한 겁니다.

 

※ 파이썬(Python) 소스코드 작성해보기 ※

 

  Google Colaboratory에 접속하면 다음과 같은 [Google Colaboratory] 사용 방법에 대해서 안내가 나올 수 있습니다. 이 때는 [취소] 버튼을 눌러서 안내 문구를 꺼주시면 됩니다. 사실 안내 문구를 다 읽지 않아도, 사용하기에 직관적이고 편리하게 구성되어 있기 때문에 쉽게 이용할 수 있을 것입니다.

 

 

  파이썬(Python) 소스코드를 작성하기 위해서는 다음과 같이 [파일] - [새 Python 3 노트]를 열어주시면 됩니다.

 

 

  이후에 다음과 같이 파이썬(Python) 소스코드를 작성하여 구동시켜 볼 수 있습니다.

 

 

※ 소스코드 공유하기 ※

 

  Google Colaboratory의 가장 큰 장점 중 하나는 소스코드를 손쉽게 공유할 수 있다는 점입니다. 특히 [파일] - [GitHub에 사본 저장...] 버튼을 눌러서 GitHub의 특정한 Repository에 해당 파일을 업로드할 수 있습니다.

 

 

  저는 사전에 GitHub에 [Google-CoLab]이라는 이름의 Repository를 만들어 주었으며, 다음과 같이 Google-CoLab이라는 Repository에 특정한 CoLab 소스코드 파일을 업로드할 수 있도록 설정했습니다.

 

 

  결과적으로 소스코드가 업로드 되면, 다음과 같이 Repository에서 확인할 수 있게 됩니다.

 

※ 유의 사항 ※

 

  Google CoLab은 소스코드는 Drive에 영구적으로 저장이 된다는 장점이 있습니다. 하지만 소스코드를 실행함에 있어서 임시적으로 저장되는 변수 값, 데이터 등은 영구적이지 않다는 점을 유의하셔야 합니다. 그래서 학습 목적의 데이터를 다운로드 받아서 사용하고자 할 때는 Drive에 저장을 한 뒤에, 이를 불러오는 방식을 이용하셔야 합니다.

 

  이러한 현상이 발생하는 이유는 CoLab이 Docker 기반의 컨테이너 개발환경이기 때문입니다. 따라서 추가적으로 pip 등의 명령어를 이용해  다른 라이브러리를 설치했다고 하더라도, 몇 시간 뒤에는 해당 라이브러리가 설치 이전 상태로 돌아가 있을 수 있습니다. 그래서 다시 주기적으로 설치해야 하는 과정이 필요할 수 있습니다.

 

※ CoLab 성능 확인 ※

 

  파이썬 버전을 확인해 봅시다.

import sys 
sys.version_info 

  디스크 용량도 확인해보겠습니다.

!df -h

  메모리 정보는 다음과 같이 확인할 수 있습니다.

!cat /proc/meminfo 

  CPU 정보는 다음과 같이 확인할 수 있습니다.

!cat /proc/cpuinfo

  이후에 GPU를 사용할 수 있도록 설정해보겠습니다.

  이제 GPU를 사용할 수 있는 상태인지 확인해보겠습니다.

!nvidia-smi 

 


※ CoLab 파일 업로드 및 다운로드 ※

  파일을 업로드 할 때는 다음과 같이 할 수 있습니다.

from google.colab import files 
uploaded = files.upload() 

  위 소스코드를 실행하면 파일 업로드 버튼이 등장하게 되고, 이를 이용해 업로드 할 수 있습니다. 저는 한 번 하나의 문서 파일을 업로드 해보았습니다.

  업로드가 된 파일을 다운로드 받을 때는 다음과 같이 할 수 있습니다.

files.download('serverless.yml')

※ 라이브러리 사용해보기 ※


  최신 버전의 CoLab에는 처음부터 TensorFlow 및 PyTorch 등의 라이브러리가 설치되어 있습니다. 원래 PyTorch는 기본적으로 설치가 되어있지 않았지만, 2019년에 들어와서 사용할 수 있게 되었습니다.

import torch
print(torch.__version__)

 

728x90
반응형

728x90
반응형

  파이썬(Python)의 Matplotlib 라이브러리는 선 그래프 말고도 정말 다양한 형태의 그래프를 지원합니다. 


※ 막대 그래프 ※


  막대 그래프틑 bar() 함수를 이용해서 그릴 수 있습니다.


import matplotlib.pyplot as plt
import numpy as np

x = np.arange(-9, 10)
plt.bar(x, x ** 2)
plt.show()


  누적 막대 그래프 형태도 사용할 수 있습니다.


import matplotlib.pyplot as plt
import numpy as np

x = np.random.rand(10) # 아래 막대
y = np.random.rand(10) # 중간 막대
z = np.random.rand(10) # 위 막대
data = [x, y, z]
x_array = np.arange(10)
for i in range(0, 3): # 누적 막대의 종류가 3개
    plt.bar(
        x_array, # 0부터 10까지의 X 위치에서
        data[i], # 각 높이(10개)만큼 쌓음
        bottom=np.sum(data[:i], axis=0)
    )
plt.show()


※ 스캐터(Scatter) 그래프 ※


  이제 스캐터(Scatter) 그래프를 먼저 알아보도록 하겠습니다. 그래프를 그릴 때 3번째 인자로 마커의 종류를 입력하면, 알아서 스캐터 그래프로 인식합니다.


import matplotlib.pyplot as plt
import numpy as np

x = np.arange(-9, 10)
y1 = x ** 2
y2 = -x
plt.plot(
    x, y1, "*",
    markersize=10,
    markerfacecolor="blue",
    markeredgecolor="red"
)
plt.show()


  혹은 바로 scatter() 함수를 이용해서 스캐터를 그릴 수 있습니다.


import matplotlib.pyplot as plt
import numpy as np

x = np.random.rand(10)
y = np.random.rand(10)
colors = np.random.randint(0, 100, 10)
sizes = np.pi * 1000 * np.random.rand(10)
plt.scatter(x, y, c=colors, s=sizes, alpha=0.7)
plt.show()


※ 히스토그램 그래프 ※


  히스토그램 그래프 또한 그릴 수 있습니다. 정규분포 그래프를 나타낼 때에도 많이 사용됩니다.


import matplotlib.pyplot as plt
import numpy as np

data = np.random.randn(10000)
plt.hist(data, bins=1000)
plt.show()


  이 때 bins 속성은 어느 정도 X 간격으로 히스토그램을 출력할 지를 설정하도록 해줍니다. 1000을 넘어서면 다소 그래픽 처리에 시간이 소요될 수 있습니다.

728x90
반응형

728x90
반응형

  파이썬(Python)에서 Matplotlib 라이브러리는 다양한 데이터를 시각화할 수 있도록 도와주는 라이브러리입니다.


  가장 간단한 예제로 (1, 1), (2, 2), (3, 3)을 선으로 잇는 그래프를 만들어보도록 하겠습니다.

import matplotlib.pyplot as plt x = [1, 2, 3] y = [1, 2, 3] plt.plot(x, y) plt.title("My Plot") plt.xlabel("X") plt.ylabel("Y") plt.show()


  그래프를 그림 형태로 저장하기 위해서는 plt.savefig(그림 파일) 명령어를 이용하여 저장할 수 있습니다. 또한 하나의 그림 파일에 여러 개의 그래프가 들어가도록 코딩을 할 수도 있습니다.


import matplotlib.pyplot as plt import numpy as np x = np.linspace(0, np.pi * 10, 500) # PI * 10 넓이에, 500개의 점을 찍기 fig, axes = plt.subplots(2, 1) # 2개의 그래프가 들어가는 Figure 생성 axes[0].plot(x, np.sin(x)) # 첫 번째 그래프는 사인(Sin) 그래프 axes[1].plot(x, np.cos(x)) # 두 번째 그래프는 코사인(Cos) 그래프 fig.savefig("sin&cos.png")


※ 선 그래프 ※


  이제 선 그래프를 그리는 방법에 대해서 알아보도록 하겠습니다. 가장 간단한 예제부터 알아봅시다.


import matplotlib.pyplot as plt import numpy as np x = np.arange(-9, 10) y = x ** 2 plt.plot(x, y, linestyle=":", marker="*") plt.show()


  라인 스타일로는 '-', ':', '-.', '--' 등이 사용될 수 있습니다. 또한 X축 및 Y축에서 특정 범위를 자를 수도 있습니다.


import matplotlib.pyplot as plt import numpy as np x = np.arange(-9, 10) y = x ** 2 plt.plot(x, y, linestyle="-.", marker="*") plt.xlim(-5, 5) plt.show()


  하나의 사진에 두 개의 라인 그래프를 그려 보겠습니다.


import matplotlib.pyplot as plt import numpy as np x = np.arange(-9, 10) y1 = x ** 2 y2 = -x plt.plot(x, y1, linestyle="-.", marker="*", color="red", label="y = x * x") plt.plot(x, y2, linestyle=":", marker="o", color="blue", label="y = -x") plt.xlabel("X") plt.ylabel("Y") plt.legend( shadow=True, borderpad=1 ) plt.show()




728x90
반응형

728x90
반응형

※ Data Frame의 마스킹 ※


  Pandas의 Data Frame 라이브러리는 Numpy처럼 마스킹 연산이 가능합니다.


import numpy as np
import pandas as pd

df = pd.DataFrame(np.random.randint(1, 10, (2, 2)), index=[0, 1], columns=["A", "B"])
print(df) # 데이터 프레임 출력하기
print(df["A"] <= 5) # 컬럼 A의 원소가 5보다 작거나 같은 데이터 추출
print(df.query("A <= 5 and B <= 8")) # 컬럼 A의 원소가 5보다 작고, 컬럼 B의 원소가 8보다 작은 데이터 추출


※ 함수로 데이터 변경하기 ※


  apply()라는 함수를 이용하면 특정한 함수를 공통적으로 적용할 수 있습니다.


import numpy as np
import pandas as pd

df = pd.DataFrame([[1, 2, 3, 4], [1, 2, 3, 4]], index=[0, 1], columns=["A", "B", "C", "D"])
print(df)
df = df.apply(lambda x: x + 1)
print(df)

def addOne(x):
    return x + 1

df = df.apply(addOne)
print(df)


  또한 replace() 함수도 원소의 값을 바꿀 때 매우 많이 사용됩니다.


import pandas as pd

df = pd.DataFrame([
    ['Apple', 'Apple', 'Carrot', 'Banana'],
    ['Durian', 'Banana', 'Apple', 'Carrot']],
    index=[0, 1],
    columns=["A", "B", "C", "D"])
print(df)
df = df.replace({"Apple": "Airport"})
print(df)


※ 데이터 그룹화 ※


  Pandas는 데이터를 그룹화할 수 있도록 groupby() 함수를 제공하고 있습니다.


import pandas as pd

df = pd.DataFrame([
    ['Apple', 7, 'Fruit'],
    ['Banana', 3, 'Fruit'],
    ['Beef', 5, 'Meal'],
    ['Kimchi', 4, 'Meal']],
    columns=["Name", "Frequency", "Type"])
print(df)
print(df.groupby(['Type']).sum())


  더불어 aggregate() 함수를 이용하면 보다 자유자재로 그룹화를 다룰 수 있습니다.


import numpy as np
import pandas as pd

df = pd.DataFrame([
    ['Apple', 7, 5, 'Fruit'],
    ['Banana', 3, 6, 'Fruit'],
    ['Beef', 5, 2, 'Meal'],
    ['Kimchi', 4, 8, 'Meal']],
    columns=["Name", "Frequency", "Importance", "Type"])
print(df)
print(df.groupby(["Type"]).aggregate([min, max, np.average]))


  그룹에 대한 필터링(Filtering)을 걸고자 한다면 다음과 같이 filter() 함수를 이용할 수 있습니다. 저는 빈도수(Frequency) 평균이 5 이상인 타입(Type)만 추출하도록 소스코드를 작성해보았습니다.


import pandas as pd

df = pd.DataFrame([
    ['Apple', 7, 5, 'Fruit'],
    ['Banana', 3, 6, 'Fruit'],
    ['Beef', 5, 2, 'Meal'],
    ['Kimchi', 4, 8, 'Meal']],
    columns=["Name", "Frequency", "Importance", "Type"])

def my_filter(data):
    return data["Frequency"].mean() >= 5

df = df.groupby("Type").filter(my_filter)
print(df)


  또한 가장 단순한 형태의 필터링은 다음과 같이 임의의 속성으로 데이터를 추출하는 것입니다.


import pandas as pd

df = pd.DataFrame([
    ['Apple', 7, 5, 'Fruit'],
    ['Banana', 3, 6, 'Fruit'],
    ['Beef', 5, 2, 'Meal'],
    ['Kimchi', 4, 8, 'Meal']],
    columns=["Name", "Frequency", "Importance", "Type"])

df = df.groupby("Type").get_group("Fruit")
print(df)


  더불어 apply() 함수를 이용해 임의의 함수를 적용할 수도 있습니다. 저는 각 데이터가 평균 값에 비해서 어느 정도 격차가 나오는지 그룹별로 확인해보는 소스코드를 작성했습니다.


import pandas as pd df = pd.DataFrame([ ['Apple', 7, 5, 'Fruit'], ['Banana', 3, 6, 'Fruit'], ['Beef', 5, 2, 'Meal'], ['Kimchi', 4, 8, 'Meal']], columns=["Name", "Frequency", "Importance", "Type"]) df["Gap"] = df.groupby("Type")["Frequency"].apply(lambda x: x - x.mean()) print(df)


※ 다중화 ※


  Pandas에서는 데이터를 다중화할 수도 있습니다. 인덱스를 여러 개로 구성하는 방법은 다음과 같습니다. 열 또한 마찬가지의 방법을 이용해서 다중 인덱스를 구성할 수 있습니다.


import numpy as np
import pandas as pd

df = pd.DataFrame(
    np.random.randint(1, 10, (4, 4)),
    index=[['1차', '1차', '2차', '2차'], ['공격', '수비', '공격', '수비']],
    columns=['1회', '2회', '3회', '4회']
)

print(df)
print(df[["1회", "2회"]].loc["2차"])


※ 피벗 테이블 ※


  피벗 테이블(Pivot Table)은 흔히 엑셀에서 표 데이터를 재구성할 때 사용하는 기능입니다. Pandas는 이러한 피벗 테이블 기능을 제공합니다. 저는 기존의 데이터에서 인덱스로 'Importance'를, 열로 'Type'을 두고 빈도수를 출력하는 프로그램을 작성하고자 합니다. 동일한 Importance 값이 있다면 가장 큰 빈도수가 출력되도록 합니다.


import numpy as np
import pandas as pd

df = pd.DataFrame([
    ['Apple', 7, 5, 'Fruit'],
    ['Banana', 3, 6, 'Fruit'],
    ['Coconut', 2, 6, 'Fruit'],
    ['Rice', 8, 2, 'Meal'],
    ['Beef', 5, 2, 'Meal'],
    ['Kimchi', 4, 8, 'Meal']],
    columns=["Name", "Frequency", "Importance", "Type"])

df = df.pivot_table(
    index="Importance", columns="Type", values="Frequency",
    aggfunc=np.max
)
print(df)



728x90
반응형