by museonghwang

Git으로 버전 관리하기 기본 흐름 정리

|

image


현재 VSCode로 Git을 자주 사용하는데 기계적으로 사용하며, 기본적인 CLI 명령어를 자주 쓰지 않아 쉽게 잊어버린다. 이번 기회에 Git을 이용한 버전 관리에 대한 기본 흐름을 정리하려한다. 글 구성은 다음과 같다.

  1. 깃 저장소 만들기
  2. 버전 만들기
    • 2.1 스테이지와 커밋 이해하기
    • 2.2 작업 트리에서 문서 수정하기
    • 2.3 수정한 파일을 스테이지에 올리기 - git add
    • 2.4 스테이징한 파일 커밋하기 - git commit
    • 2.5 스테이징과 커밋 한꺼번에 처리하기 - git commit -am
  3. 커밋 내용 확인하기
    • 3.1 커밋 기록 자세히 살펴보기 - git log
    • 3.2 변경 사항 확인하기 - git diff
  4. 버전 만드는 단계마다 파일 상태 알아보기
    • 4.1 tracked 파일과 untracked 파일
    • 4.2 unmodified, modified, stage 상태
  5. 작업 되돌리기
    • 5.1 작업 트리에서 수정한 파일 되돌리기 - git restore
    • 5.2 스테이징 되돌리기 - git restore –staged
    • 5.3 최신 커밋 되돌리기 - git reset HEAD^
    • 5.4 특정 커밋으로 되돌리기 - git reset 해시
    • 5.5 커밋 변경 이력 취소하기 - git revert




1. 깃 저장소 만들기

깃으로 버전 관리를 하려면 폴더 안에 버전이 저장되는 공간이 필요한데 이것을 저장소(repository) 라고 합니다. 깃 저장소를 만들기 전에 git-practice 라는 새로운 디렉터리를 만들겠습니다.

$ mkdir git-practice
$ cd git-practice


git-practice 디렉터리 안의 내용을 살펴보겠습니다.

$ ls -la

image


화면에 두 줄짜리 결과가 나타날 것입니다. 아직 아무 파일도 만들지 않았으므로 파일이 하나도 없습니다. 이 디렉터리(git-practice)에 저장소를 만들기 위해 터미널에 다음과 같이 입력합니다. 현재 디렉터리에서 깃을 사용할 수 있도록 초기화하는 것 입니다.

$ git init


‘Initialized empty Git repository…’ 라는 메시지가 나타난다면 이제부터 git-practice 에서 깃을 사용할 수 있습니다. 터미널에 있는 파일 경로 끝에 master 라고 표시되어있는데, 이제 git-practice 디렉터리에는 깃을 위한 저장소가 생겼다는 의미 입니다.

image


ls 명령을 사용해서 다시 한번 디렉터리 안의 내용을 확인해 보겠습니다. 다음과 같이 입력하세요.

$ ls -la

image


.git 이라는 디렉터리가 생겼습니다. 이제 이 폴더는 깃을 사용하면서 버전이 저장될 저장소(repository) 입니다.




2. 버전 만들기

프로그램 개발에서 수정 내용이 쌓이면 새로 번호를 붙여서 이전 상태와 구별하는데, 이렇게 번호 등을 통해 구별하는 것을 버전이라고 합니다. 깃에서도 문서를 수정할 때마다 버전을 만들면서 이전 상태와 구별하는데, 깃에서 버전 이란 문서를 수정하고 저장할 때마다 생기는 것 입니다. 깃에서 버전을 관리하면 원래 파일 이름은 그대로 유지하면서 파일에서 무엇을 변경했는지를 변경 시점마다 저장할 수 있으며, 버전마다 작업한 내용을 확인할 수 있고, 그 버전으로 되돌릴 수도 있습니다.


2.1 스테이지와 커밋 이해하기

깃에서 버전을 만드는 단계를 살펴보겠습니다.

image


  • 작업 트리(working tree)
    • 작업 트리는 파일 수정, 저장 등의 작업을 하는 디렉터리
    • 작업 디렉터리(working directory)
    • 즉, 우리 눈에 보이는 디렉터리가 바로 작업 트리
  • 스테이지(stage)
    • 스테이지는 버전으로 만들 파일이 대기하는 곳
    • 스테이징 영역(staging area)
    • 예를 들어 작업 트리에서 파일 10개를 수정했는데 4개만 버전으로 만들려면 이 파일 4개만 스테이지로 넘겨주면 됩니다.
  • 저장소(repository)
    • 저장소는 스테이지에서 대기하고 있던 파일들을 버전으로 만들어 저장하는 곳


스테이지와 저장소는 눈에 보이지 않습니다. 깃을 초기화했을 때 만들어지는 .git 디렉터리 안에 숨은 파일 형태로 존재하는 영역이기 때문입니다. .git 안에 숨어 있는 스테이지와 저장소 영역을 상상하며 깃이 버전을 만드는 과정을 살펴보겠습니다.

hello.txt 파일 문서를 수정하고 저장하면 그 파일은 작업 트리 에 있게 됩니다. 그리고 수정한 hello.txt 파일을 스테이지 에 넣습니다.

image


파일 수정을 끝내고 스테이지에 다 넣었다면 버전을 만들기 위해 깃에게 커밋(commit) 명령을 내리면 새로운 버전이 생성되면서 스테이지에 대기하던 파일이 모두 저장소 에 저장됩니다.

image


정리하면, 먼저 작업 트리에서 문서를 수정하면, 수정한 파일 가운데 버전으로 만들고 싶은 것을 스테이징 영역, 즉 스테이지에 저장합니다. 그리고 스테이지에 있던 파일을 저장소로 커밋하면 버전이 만들어집니다.


2.2 작업 트리에서 문서 수정하기

앞에서 만든 git-practice 디렉터리에 새로운 파일을 만들고 수정해 보겠습니다. 앞에서 git-practice 디렉터리에서 깃을 초기화했으므로 이제부터 git-practice 디렉터리에서 버전 관리를 할 수 있습니다.

$ git status

image


깃의 상태를 보여 주는 메시지가 나타나는데 어떤 의미인지 간단히 살펴보겠습니다.

  • On branch master : 현재 master 브랜치에 있습니다.
  • No commits yet : 아직 커밋한 파일이 없습니다.
  • nothing to commit : 현재 커밋할 파일이 없습니다.


git-practice 디렉터리에 hello.txt 라는 새로운 파일을 만들어 보겠습니다. Vim 편집기가 열리면 또는 I 또는 A 를 눌러 입력 모드로 바꿉니다. 간단하게 숫자 1 을 입력한 후 Esc 를 눌러 ex 모드로 바꾼 후 :wq 를 입력하고 Enter 를 누릅니다. 문서가 저장되면서 편집기가 종료됩니다.

$ vim hello.txt

image


터미널 창으로 돌아와서 ls -la 명령을 입력하면 방금 만든 hello.txt 파일이 디렉터리 안에 만들어집니다.

image


깃의 상태를 다시 한번 확인해 보겠습니다. branch masterhello.txt 라는 untracked files 가 있다고 합니다. 깃에서는 버전을 아직 한 번도 관리하지 않은 파일을 untracked files 라고 합니다.

$ git status

image


지금까지 작업 트리에서 문서 파일을 만들어 봤습니다. 그림으로 나타내면 다음과 같습니다.

image


2.3 수정한 파일을 스테이지에 올리기 - git add

작업 트리에서 파일을 만들거나 수정했다면 스테이지에 수정한 파일을 추가합니다. 이렇게 깃에게 버전 만들 준비를 하라고 알려 주는 것스테이징(staging) 또는 스테이지에 올린다 라고 표현합니다.

깃에서 스테이징할 때 사용하는 명령git add 입니다. 터미널에 다음과 같이 입력해도 아무 내용도 나타나지 않지만, 그렇다고 아무 일도 안 한 것은 아닙니다.

$ git add hello.txt


그렇다면 무엇이 바뀌었는지 깃의 상태를 확인해 보겠습니다. 볼까요?

$ git status

image


untracked files: 이라는 문구가 changes to be committed: 으로 바뀌었습니다. 그리고 hello.txt 파일 앞에 new file: 이라는 수식어가 추가로 나타납니다. '새 파일 hello.txt를 (앞으로)커밋할 것이다.' 라는 뜻 입니다.

수정한 파일 hello.txt스테이지에 추가되었고, 이제 버전을 만들 준비를 마쳤습니다. 그림으로 나타내면 다음과 같습니다.

image


2.4 스테이징한 파일 커밋하기 - git commit

파일이 스테이징 영역에 있다면 이제 버전을 만들 수 있습니다. 버전 만드는 것을 깃에서는 커밋(commit)한다 라고 합니다. 커밋할 때는 버전의 변경 사항을 확인할 수 있도록 메시지를 함께 기록해 두어야 합니다.

깃에서 파일을 커밋하는 명령git commit 입니다. 그리고 한 칸 띄운 후에 -m 옵션을 붙이고 커밋과 함께 저장할 메시지, 즉 커밋 메시지를 작성합니다.

$ git commit -m "message1"


커밋한 후에 결과 메시지를 보면 파일 1개가 변경되었고(1 file changed), 파일에 1개의 내용이 추가되었다(1 insertion(+)) 고 나타납니다. 스테이지에 있던 hello.txt 파일이 저장소에 추가된 것입니다.

image


커밋한 후 깃의 상태를 확인하겠습니다.

$ git status

image


버전으로 만들 파일이 없고(nothing to commit), 작업 트리도 수정 사항 없이 깨끗하다(working tree clean)고 나타납니다. 버전이 제대로 만들어졌는지 확인하겠습니다. 저장소에 저장된 버전을 확인할 때log 명령을 사용합니다.

$ git log

image


방금 커밋한 버전을 설명하는 정보가 나타납니다. 커밋을 만든 사람과 시간, 커밋 메시지가 함께 보입니다. 수정한 파일을 커밋하면 이렇게 수정과 관련된 여러 정보를 함께 저장할 수 있고 필요할 때 확인할 수도 있습니다. 이렇게 스테이지에 있던 hello.txt 파일의 버전이 만들어졌습니다. 이 개념을 그림으로 나타내면 다음과 같습니다.

image


2.5 스테이징과 커밋 한꺼번에 처리하기 - git commit -am

수정한 파일을 하나씩 스테이지에 올려 두었다가 한꺼번에 커밋할 수도 있지만, 수정한 내용을 스테이지에 올리는 동시에 커밋까지 처리할 수도 있습니다. commit 명령에 -am 옵션을 사용하면 스테이징과 커밋을 한꺼번에 처리할 수 있습니다. 단, 이 방법은 한 번이라도 커밋한 적이 있는 파일을 다시 커밋할 때만 사용할 수 있습니다.

앞에서 만들었던 hello.txt 파일을 다시 수정해서 스테이징과 커밋을 한꺼번에 해보겠습니다. Vim이 열리면 입력 모드로 바꾸어 숫자 ‘2’ 를 추가한 후 Esc 를 누르고 :wq 를 입력해 문서를 저장하면서 편집기를 종료합니다.

$ vim hello.txt

image


앞에서는 수정한 파일을 스테이지에 올리고 커밋하는 것을 git add 명령과 git commit 명령을 사용해서 처리했습니다. hello.txt 파일은 이전에 커밋한 적이 있으므로 git commit 명령에 -am 옵션을 붙여서 스테이징과 커밋을 한꺼번에 처리할 수 있습니다. 스테이징과 커밋 과정이 한꺼번에 보일 것입니다.

$ git commit -am "message2"

image


방금 커밋한 버전에 어떤 정보가 들어 있는지 확인해보겠습니다.

$ git log

image




3. 커밋 내용 확인하기

지금까지 버전 만드는 방법을 알아보았습니다. 이제는 만든 버전을 확인하고, 버전마다 어떤차이가 있는지 파악하면서 버전 관리하는 방법을 알아보겠습니다.


3.1 커밋 기록 자세히 살펴보기 - git log

커밋했던 기록을 살펴보는 git log 명령을 입력하면 지금까지 만든 버전이 화면에 보이고 버전마다 설명도 함께 나타납니다. 앞에서 git log 명령을 입력했을 때 나타난 화면을 더 자세히 살펴보겠습니다.

image


  • 커밋 로그 : git log 명령을 입력했을 때 나오는 정보
  • commit 항목 옆 영문과 숫자로 된 긴 문자열
    • 커밋 해시(commit hash), 또는 깃 해시(git hash)
    • 커밋을 구별하는 아이디
  • (HEAD -> master) : 이 버전이 가장 최신이라는 표시
  • Author : 버전 작성자
  • Date : 버전 생성 일자


3.2 변경 사항 확인하기 - git diff

git diff 명령을 사용하면 작업 트리에 있는 파일과 스테이지에 있는 파일을 비교 하거나, 스테이지에 있는 파일과 저장소에 있는 최신 커밋을 비교해서 수정한 파일을 커밋하기 전에 최종 검토 할 수 있습니다.

Vim 에서 hello.txt 파일을 열고 기존 내용 중에서 ‘2’ 를 지우고 ‘two’ 를 추가한 후 저장해보겠습니다.

$ vim hello.txt

image


git status 명령을 사용해 깃의 상태를 확인해 보면 hello.txt 파일이 수정되었고, 아직 스테이징 상태가 아니라고 나옵니다.

$ git status

image


방금 수정한 hello.txt 파일이 저장소에 있는 최신 버전의 hello.txt 와 어떻게 다른지 확인해 보겠습니다. 이때 git diff 명령을 사용합니다. ‘−2’ 는 최신 버전과 비교할 때 hello.txt 파일에서 ‘2’ 가 삭제되었다는 뜻이고, ‘+two’hello.txt 파일에 ‘two’ 라는 내용이 추가되었다는 뜻입니다.

$ git diff

image


이렇게 작업 트리에서 수정한 파일과 최신 버전을 비교한 후 수정한 내용으로 다시 버전을 만들려면 스테이지에 올린 후 커밋하고, 수정한 내용을 버리려면 git restore 명령을 사용해 취소합니다. 우선 다시 hello.txt 를 원래대로 되돌려 놓겠습니다. Vim 에서 hello.txt 파일을 열고 ‘two’ 부분을 숫자 ‘2’ 로 수정한 후 저장합니다.

image




4. 버전 만드는 단계마다 파일 상태 알아보기

깃에서는 버전을 만드는 단계마다 파일 상태가 달라집니다. 그래서 파일 상태를 이해 하면 이 파일이 버전 관리의 여러 단계 중 어디에 해당하는지, 그 상태에서 어떤 일을 할 수 있는지 알 수 있습니다.


4.1 tracked 파일과 untracked 파일

git status 명령을 사용하면 화면에 파일 상태와 관련된 여러 메시지가 나타납니다. 작업 트리에 있는 파일 은 크게 tracked 상태untracked 상태 로 나뉘는데 각각 무엇을 의미하는지 알아보겠습니다.

Vim 에서 hello.txt 파일을 열고 숫자 ‘3’ 을 추가한 후 저장합니다.

$ vim hello.txt

image


Vim 에 다음과 같이 hello2.txt 라는 새로운 파일을 만들어 보겠습니다.

$ vim hello2.txt

image


hello.txt 파일과 hello2.txt 파일 모두 작업 트리에 있습니다. git status 명령을 사용해 어떤 상태인지 확인해보겠습니다.

$ git status

image


앞에서 커밋했던 hello.txt 파일은 ‘Changes not staged for commit:’ 이라고 되어 있는데, 변경된 파일이 아직 스테이지에 올라가지 않았다는 뜻입니다. 그리고 파일 이름 앞에 modified: 라고 되어 있어 hello.txt 가 수정되었다는 것을 알 수 있습니다. 이렇게 깃은 한 번이라도 커밋한 파일은 계속해서 수정 사항이 있는지 추적합니다. 깃이 추적하고 있다는 뜻에서 tracked 파일 이라고 합니다.

반면에 hello2.txt 파일 앞에는 아무것도 없고 바로 위에는 ‘untracked files:’ 라고 되어 있는데, hello2.txt 파일은 한 번도 커밋하지 않았으므로 수정 내역을 추적하지 않으므로 untracked 파일 이라고 표시합니다.

수정한 hello.txt 파일과 수정하지 않은 hello2.txt 파일 모두 git add 명령을 사용해서 스테이지에 올릴 수 있습니다.

$ git add hello.txt
$ git add hello2.txt


git status 를 사용해 상태를 확인해 보겠습니다. 마지막 버전 이후에 수정한 hello.txtmodified: 라고 표시되고, 한 번도 버전 관리를 하지 않은 hello2.txtnew file: 로 표시됩니다.

image


커밋을 해보겠습니다. 이 커밋에는 hello.txt 를 수정한 내용과 새로 만든 hello2.txt 내용이 전부 포함됩니다. 커밋 메시지는 다음과 같이 작성하고, 커밋이 성공적으로 되었다면 로그를 확인해 보겠습니다.

$ git commit -m "message3"
$ git log

image


‘message3’ 라는 메시지를 붙인 커밋이 보입니다. 그런데 각 커밋에 어떤 파일이 관련되었는지 알 수 없습니다. 커밋과 관련된 파일까지 함께 살펴보려면 git log 명령에 –stat 옵션을 사용합니다.

$ git log --stat

image


가장 최근 커밋부터 순서대로 커밋 메시지와 관련 파일이 나열됩니다. message3 커밋은 hello.txt, hello2.txt 파일과 관련되어 있고, message2hello.txt 파일과 관련되었다는 것을 알 수 있습니다.


4.2 unmodified, modified, stage 상태

버전을 한 번이라도 만들었던 파일은 tracked 상태가 되는데, 파일이 tracked 상태라면 깃 명령으로 현재 작업 트리에 있는지, 스테이지에 있는지 등 더 구체적으로 알 수 있습니다. 깃의 커밋 과정에서 tracked 파일의 상태가 어떻게 바뀌는지 확인해 보겠습니다.

ls -la 명령을 사용해 git-practice 디렉터리를 살펴보면 앞에서 버전을 저장한 hello.txthello2.txt 파일이 있습니다.

image


hello2.txt 파일의 상태를 따라가 보겠습니다. 앞에서 버전을 저장한 뒤로 아직 아무 파일도 수정하지 않은 상태입니다. git status 명령을 사용해 깃의 상태와 파일의 상태를 확인해보면 작업 트리에 아무 변경 사항도 없습니다. ‘working tree clean’ 은 현재 작업 트리에 있는 모든 파일의 상태는 unmodified 라는 뜻으로 수정되지 않은 상태를 말합니다.

image


hello2.txt 파일을 수정해 보겠습니다. hello2.txt 에서 a 만 남기고 나머지 내용을 삭제한 후 파일을 저장하고 편집기를 종료합니다.

$ vim hello2.txt

image


다시 git status 명령을 실행하면 hello2.txt 파일이 수정되었고 아직 스테이지에 올라가지 않았다고 나타납니다. ‘Changes not stage for commit’ 라는 메시지가 나타나면 파일이 수정만 된 modified 상태를 뜻합니다.

image


git add 명령을 사용해 스테이지에 올리고 git status 명령을 실행하면 커밋할 변경사항이 있다고 합니다. ‘Changes to be committed:’ 라는 메시지가 나타나면 커밋 직전 단계, 즉 staged 상태입니다.

$ git add hello2.txt
$ git status

image


스테이지에 있는 hello2.txt 파일을 커밋하고 git status 명령을 실행합니다. 커밋을 끝내고 나면 hello2.txt 파일의 상태는 수정하기 직전인 unmodified 상태로 돌아갑니다.

$ git commit -m "delete b, c, d"
$ git status

image


지금까지 살펴본 것처럼 같은 파일이더라도 깃에서 버전을 만들 때 어느 단계에 있는지에 따라 파일의 상태가 바뀝니다. 파일의 상태 변화는 다음과 같이 간단하게 정리할 수 있습니다.

image




5. 작업 되돌리기

앞에서 수정한 파일을 스테이지에 올리고 커밋하는 방법까지 살펴보았습니다. 이제부터는 스테이지에 올렸던 파일을 내리거나 커밋을 취소하는 등 각 단계로 돌아가는 방법을 알아보겠습니다. 여기까지 익히고 나면 버전 관리를 훨씬 능숙하게 할 수 있습니다.


5.1 작업 트리에서 수정한 파일 되돌리기 - git restore

수천 줄이 넘는 소스를 수정했다고 가정했을때 수정한 코드가 정상으로 동작하지 않거나 다른 이유로 수정한 것을 취소하고 가장 최신 버전 상태로 되돌려야 할 때가 있습니다. 이때 작업 디렉터리에서 수정한 내용을 되돌리려면 restore 명령을 사용합니다.

먼저 Vim 편집기에서 hello.txt 파일을 열어 숫자 ‘3’‘three’ 로 수정한 후 저장하고 편집기를 종료합니다.

$ vim hello.txt

image


git status 명령을 통해 hello.txt 파일의 상태를 살펴보면 hello.txt 가 수정되었지만 아직 스테이지에 올라가 있지 않습니다. 그리고 두 번째 괄호 안의 메시지를 보면, 작업 디렉터리의 변경 사항을 취소하려면 restore 명령을 사용하라고 되어있습니다.

image


조금 전에 수정했던 hello.txt 의 수정 사항을 git restore 명령 을 통해 취소해보겠습니다. restore 명령이 정상으로 처리되면 화면에는 아무것도 나타나지 않습니다.

$ git restore hello.txt


hello.txt 파일에서 수정했던 것이 정말 취소되었는지 확인해보기 위해 cat 명령을 사용해 파일 내용을 확인해보겠습니다. 앞에서 ‘3’ 을 지우고 ‘three’ 를 추가했던 수정 내용이 사라지고 ‘3’ 이 그대로 남은 것을 확인할 수 있습니다.

$ cat hello.txt

image


5.2 스테이징 되돌리기 - git restore –staged

앞에서 파일을 수정했을 때 스테이징하지 않은 상태에서 수정을 취소하고 원래대로 되돌리는 방법을 알아봤습니다. 이번에는 수정된 파일을 스테이징까지 했을 때 스테이징을 취소하는 방법 을 살펴보겠습니다. 스테이징을 취소할 때도 restore 명령을 사용합니다.

Vim 을 사용해서 hello2.txt 를 수정해 보겠습니다. 기존 내용을 삭제하고 대문자 A, B, C, D 를 입력한 후 저장하고 편집기를 종료합니다.

$ vim hello2.txt

image


git add 명령으로 hello2.txt 파일을 스테이지에 올린 후 git status 명령으로 파일 상태를 살펴보겠습니다.

$ git add hello2.txt
$ git status

image


상태 메시지를 보면, 스테이지에서 내리려면(to unstage) **git restore –staged ** 명령을 사용하라고 되어 있습니다. 스테이징을 취소할 때는 **restore** 명령 뒤에 **--staged** 옵션을 붙이는데, 이 옵션만 사용하면 스테이지에 있는 모든 파일을 한꺼번에 되돌릴 수 있고, **--staged** 옵션 뒤에 파일 이름을 넣으면 해당 파일만 골라서 되돌릴 수 있습니다. 여기에서는 스테이지에 있는 **hello2.txt** 를 내려 보겠습니다.

$ git restore --staged hello2.txt


git status 를 사용해 파일 상태를 확인해 보면, 파일이 아직 스테이지에 올라가기 전(not staged)으로 돌아온 것을 확인할 수 있습니다.

image


5.3 최신 커밋 되돌리기 - git reset HEAD^

이번에는 수정된 파일을 스테이징하고 커밋까지 했을 때 가장 마지막에 한 커밋을 취소하는 방법을 알아보겠습니다. 커밋을 취소하면 커밋과 스테이징이 함께 취소됩니다. Vim 으로 hello2.txt 를 열어 대문자 E 를 끝에 추가하겠습니다.

$ vim hello2.txt

image


git commit 명령을 사용해 스테이징과 커밋을 함께 실행합니다. 커밋 메시지는 message4 로 하겠습니다. git log 명령을 사용해서 제대로 커밋되었는지 확인해 보겠습니다.

$ git commit -am "message4"

image


최신 커밋을 되돌리려면 git reset HEAD^ 명령을 해야합니다. HEAD^ 는 현재 HEAD가 가리키는 브랜치의 최신 커밋을 가리킵니다. git log 명령을 실행했을 때 가장 최신커밋에 (HEAD-> master) 표시가 있는데, 이렇게 되돌리면 master의 최신 커밋도 취소되고 스테이지에서도 내려집니다. 작업 트리에만 파일이 남습니다.

$ git reset HEAD^


참고로 스테이징만 취소할 때는 restore 명령을 사용하지만 커밋을 취소할 때는 reset 명령을 사용합니다. hello2.txt 파일의 커밋이 취소되고 스테이지에서도 내려졌다는 메시지가 나타납니다.

image


git log 명령으로 확인해보면 메시지가 message4 인 커밋이 사라진 것을 볼 수 있습니다. 이 방법으로 커밋을 취소하면 커밋 전에 했던 스테이징도 함께 취소됩니다.

image


5.4 특정 커밋으로 되돌리기 - git reset 해시

깃에는 파일을 수정하고 커밋할 때마다 저장된 버전들이 쌓여 있습니다. 앞에서 살펴본 git reset HEAD^ 명령으로 최신 커밋을 되돌릴 수도 있지만 특정 버전으로 되돌려 놓고 그 이후 버전을 삭제할 수도 있습니다. 특정 커밋으로 되돌릴 때git reset 명령 다음에 커밋 해시 를 사용합니다.

git reset 명령을 연습해 보기 위해 커밋을 몇 개 만들어 보겠습니다. Vim 을 사용해 rev.txt 를 만듭니다. 간단하게 영문자 ‘a’ 를 입력한 후 저장하겠습니다.

$ vim rev.txt

image


rev.txt 를 스테이지에 올린 후 커밋 메시지 R1 을 붙여 커밋합니다.

$ git add rev.txt
$ git commit -m "R1"

image


rev.txt 를 한 번 더 수정해서 영문자 ‘b’ 를 추가하고, R2 메시지와 함께 커밋합니다.

$ vim rev.txt

image


$ git commit -am "R2"

image


같은 방법으로 rev.txt 에 영문자 ‘c’ 를 추가한 후 R3 메시지와 함께 커밋하고, rev.txt 에 영문자 ‘d’ 를 추가한 후 R4 메시지와 함께 커밋합니다. 지금까지 커밋을 모두 4번 했습니다.

image


git log 명령을 사용해 지금까지 만든 커밋을 확인하면, 커밋이 4개 있고 커밋마다 커밋 해시가 함께 나타나 있습니다.

image


커밋 4개 가운데 R2 라는 메시지가 붙은 R2 커밋으로 되돌려 보겠습니다. 즉, R3 커밋과 R4 커밋을 삭제하고 R2 커밋을 최신 커밋으로 만들 것입니다.

reset 에서 커밋 해시를 사용해 되돌릴 때 주의할 점이 있습니다. 예를 들어 reset A 를 입력한다면 이 명령은 A 커밋을 리셋하는 것이 아니라 최근 커밋을 A 로 리셋합니다. 즉, A 커밋을 삭제하는 것이 아니라 A 커밋 이후에 만들었던 커밋을 삭제하고 A 커밋으로 이동하겠다는 의미입니다. 그래서 R3 커밋과 R4 커밋을 삭제하려면 그 이전 커밋인 R2 커밋을 최신 커밋으로 만들어야 합니다. 최신 커밋을 가리키는 HEADA 로 리셋한다고 생각하면 됩니다.

git reset 명령 다음에 –hard 옵션까지 입력한 후 복사한 커밋 해시를 붙여 넣습니다.

$ git reset --hard 복사한 커밋 해시

image


방금 복사해서 붙인 커밋 해시 위치로 HEAD 가 옮겨졌다고 나옵니다. 즉, 방금 복사해서 붙인 커밋이 가장 최신 커밋이 되었습니다. git log 명령을 사용해서 로그 목록을 살펴보면 의도했던 대로 R4 커밋과 R3 커밋은 삭제되고 커밋 해시를 복사했던 커밋, 즉 R2 커밋이 최신 커밋이 됐습니다.

image


하나 더 확인해보면, R4 커밋은 rev.txt‘d’ 를 추가한 것이고, R3 커밋은 ‘c’ 를 추가한 것, 그리고 R2 커밋은 ‘b’ 를 추가한 것이었습니다. R2 커밋으로 되돌렸으니 cat 명령을 사용해서 rev.txt 파일을 확인해 보면 내용에 ‘b’ 까지만 있을 것입니다. ‘c’‘d’ 를 추가했던 R4 커밋과 R3 커밋이 사라지고, R2 커밋이 최신 커밋이 되었기 때문에 ‘b’ 까지만 남은 것입니다.

image


5.5 커밋 변경 이력 취소하기 - git revert

특정 커밋으로 되돌릴 때 수정했던 것을 삭제해도 된다면 git reset 명령을 사용하면 되지만, 나중에 사용할 것을 대비해서 커밋을 취소하더라도 커밋했던 기록은 남겨 두어야 할 때가 있습니다. 즉, 변경 사항만 취소하고 커밋은 남겨 두는 것 입니다. 이럴 경우에는 git revert 라는 명령을 사용합니다.

rev.txt 파일을 한 번 더 수정해서 영문자 ‘e’ 를 추가하고, 수정한 rev.txtR5 라는 메시지와 함께 커밋하겠습니다.

image


$ git commit -am "R5"

image


git log 를 입력해 버전을 확인하면, rev.txt 파일에 대해 R1R2, R5 라는 버전 3개가 만들어졌습니다.

image


가장 최근에 커밋한 R5 버전을 취소하고, R5 직전 커밋인 R2 로 되돌아가려고 합니다. 여기에서는 취소하는 R5 버전을 삭제하지 않고 남겨 두려고 합니다. revert 명령을 사용하고 명령 뒤에 취소할 버전, 즉 R5 의 커밋 해시를 지정합니다. 먼저 revertR5 커밋 해시를 복사합니다. revert 명령을 사용해서 R5 커밋을 취소하겠습니다.

$ git revert 복사한 커밋 해시


기본 편집기가 자동으로 나타나면서 다음과 같이 커밋 메시지를 입력할 수 있습니다. 커밋 메시지 맨 위에는 어떤 버전을 revert 했는지 나타납니다. 커밋을 취소하면서 남겨 둘 내용이 있다면 문서 맨 위에 입력하고 저장합니다.

image


R5 버전이 revert 되었다는 간단한 메시지가 나타나는데, 실제로 버전이 어떻게 바뀌었는지 git log 를 통해 확인해보겠습니다.

$ git log


로그에 R5revert 한 새로운 커밋이 생겼습니다. 그리고 기존의 R5 역시 사라지지 않았습니다. R5 버전을 지우는 대신 R5에서 변경했던 내용만 취소하고, R5를 취소했다는 커밋을 새로만든 것 입니다.

image


방금 취소한 R5 커밋은 rev.txt 문서에 영문자 ‘e’ 를 추가한 것이었습니다. R5 커밋을 취소한 것이 문서에도 반영되었는지 확인해 보겠습니다.

$ cat rev.txt


앞에서 추가한 ‘e’ 가 없어진 것을 볼 수 있습니다. 이렇게 revert 명령을 사용하면 커밋 자체를 삭제하지 않으면서도 변경한 이력을 취소할 수 있습니다.

image

Read more

pytorch project workflow 구성하기

|

주피터 노트북을 활용하면 실행 결과를 바로 확인할 수 있기 때문에 데이터 분석과 같이 각 셀의 결과에 따라 해야 하는 일이 바뀌는 경우에 적합합니다. 하지만 머신러닝 프로젝트는 해야 할 작업이 명확하고 반복되므로 py 확장자를 가진 파이썬 스크립트로 제작하여 CLI(command line interface) 환경에서 작업을 수행하는 것이 좀 더 바람직합니다.

따라서 데이터 분석 과정을 제외한 머신러닝 프로젝트 대부분의 과정은 CLI 환경에서 수행됩니다. 특히 모델링 및 하이퍼파라미터 튜닝 작업 시에는 반복적인 실험이 수행되기 때문에 코드를 수정하여 실험을 수행하는 것이 아니라 CLI 환경에서 파이썬 스크립트 실행과 함께 실행 파라미터를 넣어주어 실험을 수행하도록 하는 것이 더 낫습니다. 그러므로 주피터 노트북을 활용한 실습을 벗어나 실무 환경에서 머신러닝 프로젝트를 수행하는 것처럼 프로젝트 또는 솔루션을 설계하고 구현할 수 있어야 합니다.


머신러닝 프로젝트 파일 구조 예시

가장 간단한 형태의 머신러닝 프로젝트를 구현하면 다음과 같은 구조를 지닐 것입니다. 역할에 따른 파일 이름은 예시로 든 것입니다.

파일명 설명
model.py 모델 클래스가 정의된 코드
trainer.py 데이터를 받아와 모델 객체를 학습하기 위한 trainer가 정의된 코드
dataloader.py 데이터 파일을 읽어와 전처리를 수행하고 신경망에 넣기 좋은 형태로 변환하는 코드
train.py 사용자로부터 하이퍼파라미터를 입력받아 필요한 객체들을 준비하여 학습을 진행
predict.py 사용자로부터 학습된 모델과 추론을 위한 샘플을 입력받아 추론을 수행


기능에 따라 각 모듈을 나누어 클래스를 정의하고 다른 프로젝트에 재활용하기도 하며, 또한 모델 개선이나 기타 수정 작업이 필요할 때 코드 전체를 바꾸거나 할 필요 없이 필요한 최소한의 부분만 수정하여 사용할 수 있습니다. 실제로 이에 따라 하나의 템플릿을 구성해 놓으면 계속해서 일부만 수정해서 사용할 수 있습니다. 다음 그림은 앞에서 소개한 파일들이 어떤 식으로 상호작용하는지 나타낸 것입니다.

image


train.py 는 사용자가 학습을 진행할 때 직접 실행할 파이썬 스크립트 파일로, 해당 파일을 실행하면 사용자로부터 필요한 하이퍼파라미터를 입력받아 각 클래스의 객체를 생성하고 학습을 진행합니다. 사용자는 이 train.py 를 통해서 코드 수정 없이 다양한 하이퍼파라미터들을 변경해가며 반복적인 실험을 수행 할 수 있습니다.

예를 들어 CLI 환경에서 다음과 같이 train.py 를 실행하고 하이퍼파라미터를 argument 를 통해 전달합니다. 아래의 실행 명령은 모델 가중치 파일이 저장될 파일 경로와 모델의 깊이 그리고 드롭아웃의 확률을 train.py 에 넘겨줍니다. 그러면 프로그램은 이러한 하이퍼파라미터를 넘겨받아 코드 수정 없이 다양한 실험을 수행하도록 구현 되어야 할 것입니다.

$ python train.py --model_fn ./models/model.pth --n_layers 10 --dropout 0.3


또한 trainerdata loader 로 부터 준비된 데이터를 넘겨받아 모델에 넣어 학습과 검증을 진행하는 역할을 수행합니다. 이렇게 학습이 완료되면 모델의 가중치 파라미터는 보통 피클(pickle) 형태로 다른 필요한 정보(e.g. 모델을 생성하기 위한 각종 설정 및 하이퍼파라미터)들과 함께 파일로 저장됩니다.

그러면 predict.py 는 저장된 피클 파일을 읽어와서 모델 객체를 생성하고 학습된 가중치 파라미터를 그대로 복원합니다. 그리고 사용자로부터 추론을 위한 샘플이 주어지면 모델에 통과시켜 추론 결과를 반환합니다.

이처럼 실제 머신러닝 프로젝트는 반복적으로 수행되는 작업을 효율적으로 수행하기 위해서 복잡한 구조를 잘게 쪼개어 각각 모듈들로 구현하도록 합니다. 복잡한 머신러닝 프로젝트일지라도 결국 데이터와 모델을 불러와서 학습하고 기학습된 모델을 가지고 추론을 수행한다는 역할은 근본적으로 같습니다.


머신러닝 프로젝트 Workflow

실제 머신러닝 프로젝트를 수행하듯 각 기능별 모듈들을 구성하여 MNIST 분류를 구현하겠습니다.

  1. 문제 정의
    • 단계를 나누고 simplity
    • x와 y를 정의
  2. 데이터 수집
    • 문제 정의에 따른 수집
    • 필요에 따라 레이블링
  3. 데이터 전처리 및 분석
    • 형태를 가공
    • 필요에 따라 EDA 수행
  4. 알고리즘 적용
    • 가설을 세우고 구현/적용
  5. 평가
    • 실험 설계
    • 테스트셋 구성
  6. 배포
    • RESTful API를 통한 배포
    • 상황에 따라 유지/보수


1. 문제 정의

손글씨 순자를 인식하는 함수 $f^*$ 를 근사계산하고 싶습니다. 따라서 근사계산한 모델 함수 $f_{\theta}$ 는 이미지를 입력받아 숫자 레이블을 출력하도록 구성될 것입니다. 이 모델을 만들기 위해서 우리는 손글씨 숫자를 수집하고 이에 대한 레이블링도 수행, 즉 MNIST 데이터셋을 구축합니다.

image


위 그림과 같이 한 장의 이미지는 28×28 개의 흑백(grayscale) 픽셀로 구성되어 있습니다. 따라서 우리가 만들 함수의 입력은 784차원의 벡터가 되고, 출력은 각 숫자 클래스별 확률값이 되도록 구현될 것입니다.


2. 데이터 수집

MNIST라는 공개 데이터셋을 활용하므로 매우 수월하지만, 실무 환경에서는 데이터가 없거나 데이터가 있더라도 레이블이 존재하지 않는 상황도 맞이하게 될 것입니다. 데이터 수집 및 레이블링 작업을 수행하기 위한 두 가지 선택지가 있는데, 첫 번째로 직접 데이터 수집 및 레이블링을 진행하는 것이 있으며, 두 번째로는 외주를 맡기거나 단기 계약직 등을 고용하는 방법도 있습니다. 둘 중 어떤 선택을 하든지 업무의 크기를 산정해야 하고 예산을 준비하는 작업도 필요합니다.


3. 데이터 전처리

이제 데이터셋을 학습용과 검증용 그리고 테스트용으로 나누는 작업을 수행할 차례입니다. MNIST 데이터셋의 경우 기본적으로 60,000장의 학습 데이터셋(training dataset) 과 10,000장의 테스트 데이터셋(test dataset) 으로 구분되어 있습니다. 따라서 테스트셋은 주어진 10,000장을 사용하도록 하고 60,000장을 8:2의 비율로 학습 데이터셋과 검증 데이터셋(validation dataset) 으로 나누어 줍니다. 그러면 최종적으로 다음 그림과 같이 학습 데이터셋 48,000장, 검증 데이터셋 12,000장 그리고 테스트 데이터셋 10,000장을 얻을 수 있습니다.

image


데이터를 분할한 이후 데이터 전처리를 수행합니다. 이때 데이터의 성격에 따라 필요한 전처리가 매우 다르므로, 따라서 본격적으로 전처리를 수행하기에 앞서 데이터가 어떤 분포와 형태를 띠고 있는지 면밀히 분석해야 합니다. 다양한 전처리들은 데이터의 종류와 형태 그리고 상태에 따라서 다르게 적용되며 크게 다음 그림과 같이 나뉠 수 있습니다.

image


일부 전처리 기법들은 데이터를 기반으로 파라미터가 결정되는데, 데이터 기반의 전처리 기법은 학습 데이터셋 기준으로 수행되어야 합니다. 즉, 학습 데이터만을 가지고 평균과 표준편차를 계산한 뒤 학습/검증/테스트 데이터셋에 일괄 적용하는 형태가 되어야 합니다. 만약 전체 데이터셋을 기반으로 평균과 표준편차를 계산하고 정규화 스케일을 적용하게 되면 테스트셋을 보고 테스트를 평가하는 것과 다를 바 없습니다. 결론적으로 전처리는 학습/검증/테스트 데이터셋 분할 작업 이후에 수행하는 것이 바람직합니다.

다행히도 MNIST 데이터셋의 경우 별다른 전처리가 필요하지 않습니다. 0에서 255사이의 값으로 채워진 픽셀 값을 255로나누어 0에서 1사이의 값으로 정규화해주는 작업 정도면 충분합니다.


4. 알고리즘 적용

데이터 전처리 과정에서 수행된 분석을 통해 데이터의 분포나 성질을 파악할 수 있었을 것입니다. 따라서 우리는 분석 결과를 바탕으로 알맞은 가설을 설정하고 알고리즘 구현 및 적용해야 합니다. 이 과정에서 분석 결과에 따라 가장 적절한 머신러닝 알고리즘을 적용하면 됩니다.

신경망 내부의 자세한 구조 결정에 앞서 회귀 문제인지 분류 문제인지에 따라 손실 함수와 마지막 계층의 활성 함수가 결정됩니다. 또한 계층의 개수, 활성 함수의 종류, 정규화 방법 등의 하이퍼파라미터가 남아 있는데 이들을 결정하기 위한 프로세스는 다음과 같습니다.

  1. 신경망 외형 구성
    • 오버피팅이 발생할 때까지 계층을 쌓는다.
  2. 활성 함수 결정
  3. Regularization 결정
  4. Optimizer 결정
  5. 평가(Evaluation)
    • 평가를 통해 베이스라인(baseline)을 구축
  6. 튜닝(Tuning)
    • 점진적으로 성능을 개선


먼저 적당한 선택으로 초기 하이퍼파라미터를 설정한 다음에 오버피팅이 발생할 때까지 신경망을 깊고 넓게 만듭니다. 오버피팅이 발생하는 것을 확인함으로써 데이터셋의 복잡한 데이터를 신경망이 충분히 학습할 만한 수용 능력을 지녔음을 알 수 있습니다. 또한 오버피팅이 발생하더라도 매 에포크마다 검증 데이터셋에 대한 손실 값을 추적하고 있으므로 큰 문제가 되지않습니다. 이후에 적절한 score metric을 적용하여 모델을 평가하고 모델의 성능을 수치화합니다.

여기까지가 한 번의 모델링 과정을 거친 것이 되고 이후 하이퍼파라미터를 수정하며, 이 과정을 반복하여 모델의 성능을 점진적으로 개선합니다. 또는 단순한 하이퍼파라미터 수정만으로는 충분한 성능 개선이 이루어지지 않는다면 성능 저하 문제의 원인에 대한 적절한 가설을 설정하고 모델의 구조를 바꾸는 등 수정을 거쳐 성능을 개선할 수도 있습니다.


5. 평가

서비스 또는 배포를 위해서, 그리고 모델의 성능 개선을 위해서 공정하고 객관적인 평가가 수행되어야합니다.

image


평가 결과에 따라 언더피팅이 의심될 경우에는 모델의 수용 능력을 더 키우는 방향으로 하이퍼파라미터를 튜닝하고 오버피팅으로인해 일반화 성능이 저하되는 것이 우려될 때에는 정규화 기법을 강화하는 방향으로 튜닝하면서 학습과 평가를 반복 수행하게 될 것입니다.

범주 학습 데이터셋 검증 데이터셋 테스트 데이터셋
가중치 파라미터 결정 검증 검증
하이퍼파라미터 - 결정 검증
알고리즘 - - 결정


이와 같이 모델 성능 개선 작업이 종료되고 나면 테스트 데이터셋을 활용하여 평가를 수행함으로써 진정한 모델(또는 알고리즘)의 성능을 공정하게 평가할 수 있습니다.


6. 배포

이제 알고리즘이 실전에 투입될 준비가 되었다고 판단되면 본격적으로 배포 과정에 들어가게 됩니다.



MNIST 학습 구조 설계

1. 모델 구조 설계

MNIST 분류기를 만들 것이기 때문에 모델은 28×28 크기의 이미지를 펼쳐진 784차원의 벡터로 입력받아 각 숫자 클래스별 확률 값을 반환해야 합니다. 다음은 구현할 모델의 구조를 그림으로 나타낸 것입니다.

image


반복되는 계층을 쌓아 구현할 것이기 때문에 반복되는 부분을 깔끔하게 블록(block) 클래스로 정의하고 쌓아 올릴 것입니다. 하나의 블록 내에는 선형 계층과 비선형 활성 함수인 리키렐루 그리고 정규화를 위한 배치정규화 계층이 차례대로 들어가 있습니다. 이후에는 분류를 위해 클래스 개수(MNIST의 경우에는 10차원)만큼의 차원으로 변환하는 선형 계층과 로그소프트맥스 함수를 배치할 것입니다.

이렇게 하면 모델은 각 클래스 (MNIST의 경우에는 0부터 9까지의 숫자)별 로그 확률 값을 뱉어낼 것이고 이것을 정답 원 핫 벡터와 비교하면 손실 값을 계산할 수 있습니다. 이때 그냥 소프트맥스 함수가 아닌 로그소프트맥스 함수를 활용했기 때문에 손실 값 계산을 위해서 NLL 손실 함수를 사용해야 합니다.

흥미로운 점은 모델을 구현할 때 우리가 풀고자 하는 MNIST와 연관된 하드코딩을 거의 하지 않을 것이므로 이 모델이 MNIST 이외의 분류 문제에도 바로 적용 가능할 것이라는 것입니다. 따라서 만약 다른 문제에 바로 코드를 적용하고자 한다면 데이터 로더 부분만 수정하면 거의 그대로 동작할 것입니다. 이와 같이 애초에 구현 단계에서 최소한의 수정으로 최대한 재활용과 확장이 가능하도록 설계하고 구현하는 것이 매우 중요하며, 또한 이를 위해서 각 기능의 모듈이 잘 나뉘어 독립적으로 구현되어 있어야 합니다.


2. 학습 과정

image


오른쪽 그림과 같이 학습/검증/테스트 데이터셋으로 구성된 데이터를 Trainer 모듈에 넣어주어 모델을 학습할 것입니다. 이때 학습을 위한 전체 n_epochs 만큼의 반복이 진행될 것이고, 이것은 왼쪽 그림의 맨 바깥쪽 싸이클로 표현되어 있습니다.

하나의 에포크는 학습을 위한 부분과 검증을 위한 부분으로 나누어져 있을 것입니다. 학습과 검증은 각각 미니배치별로 이터레이션을 위한 반복문으로 구현되어 있을 텐데 이중 학습 과정에서는 손실 계산 후 역전파와 경사하강법을 통한 가중치 파라미터 업데이트 과정이 포함되어 있을 것입니다.


3. 파일 구조

파일명 설명
model.py nn.Module을 상속받아 모델 클래스를 정의
predict.ipynb 학습이 완료된 모델 피클 파일을 불러와 샘플을 입력받아 추론 수행
train.py 사용자가 학습을 진행하기 위한 진입 지점
trainer.py 모델 객체와 데이터를 받아 실제 학습 이터레이션을 수행하는 클래스를 정의
utils.py 프로그램 내에서 공통적으로 활용되는 모듈을 모아 놓은 스크립트


현재 굉장히 작은 프로젝트이므로 이 파일들 전부를 한 디렉터리 내에 위치하게 할 것이지만 나중에 프로젝트 규모가 커지고 파일이 많아진다면 디렉터리 구조를 추가하여 좀 더 효율적으로 관리해야 합니다.




분류기 모델 구현하기

분류기(classifier) 모델 클래스를 정의하도록 하겠습니다. 반복되는 형태를 블록으로 만들어 크기만 다르게 한 후에 필요한 만큼 쌓을 수 있도록 구현할 것입니다. 먼저 블록을 서브 모듈(sub-module)로 넣기 위해 클래스로 정의합니다.

class Block(nn.Module):
    
    def __init__(self,
                 input_size,
                 output_size,
                 use_batch_norm=True,
                 dropout_p=.4):
        self.input_size = input_size
        self.output_size = output_size
        self.use_batch_norm = use_batch_norm
        self.dropout_p = dropout_p
        
        super().__init__()
        
        def get_regularizer(use_batch_norm, size):
            return nn.BatchNorm1d(size) if use_batch_norm else nn.Dropout(dropout_p)
        
        self.block = nn.Sequential(
            nn.Linear(input_size, output_size),
            nn.LeakyReLU(),
            get_regularizer(use_batch_norm, output_size),
        )
        
    def forward(self, x):
        # |x| = (batch_size, input_size)
        y = self.block(x)
        # |y| = (batch_size, output_size)
        
        return y


하나의 블록은 nn.Linear 계층, nn.LeakyReLU 활성 함수, nn.BatchNorm1d 계층 또는 nn.Dropout 계층 이렇게 3개로 이루어져 nn.Sequential 에 차례대로 선언되어있는 것을 볼 수 있습니다.

눈여겨보아야 할 점은 get_regularizer 함수를 통해 use_batch_normTrue 이면 nn.BatchNorm1d 계층을 넣어주고, False 이면 nn.Dropout 계층을 넣어준다는 것입니다. 이렇게 선언된 nn.Sequentialself.block 에 지정되어 forward 함수에서 피드포워드가 되도록 간단히 구현됩니다.

모델은 이렇게 선언된 블록을 반복해서 재활용할 수 있습니다. 다음 코드는 최종 모델로써 앞에서 선언된 블록을 재활용하여 아키텍처를 구성하도록 되어 있습니다. 참고로 이 모델은 이후에 작성할 코드에서 MNIST 데이터를 28×28 이 아닌 784차원의 벡터로 잘 변환했을 거라고 가정했습니다. 따라서 추후에 잊지 말고 올바른 데이터를 넣어주도록 구현해주어야 합니다.

class ImageClassifier(nn.Module):

    def __init__(self,
                 input_size,
                 output_size,
                 hidden_sizes=[500, 400, 300, 200, 100],
                 use_batch_norm=True,
                 dropout_p=.3):
        
        super().__init__()

        assert len(hidden_sizes) > 0, "You need to specify hidden layers"

        last_hidden_size = input_size
        blocks = []
        for hidden_size in hidden_sizes:
            blocks += [Block(
                last_hidden_size,
                hidden_size,
                use_batch_norm,
                dropout_p
            )]
            last_hidden_size = hidden_size
        
        self.layers = nn.Sequential(
            *blocks,
            nn.Linear(last_hidden_size, output_size),
            nn.LogSoftmax(dim=-1),
        )
        
    def forward(self, x):
        # |x| = (batch_size, input_size)        
        y = self.layers(x)
        # |y| = (batch_size, output_size)
        
        return y


마찬가지로 nn.Sequential 을 활용하여 블록을 필요한 만큼 쌓도록 합니다. 여기에서 클래스 선언 시에 입력받은 hidden_sizes 를 통해 필요한 블록의 개수와 각 블록의 입출력 크기를 알 수 있습니다. 따라서 hidden_sizes 를 활용하여 for 반복문 안에서 Block 클래스를 선언하여 blocks 라는 리스트에 넣어줍니다. 이렇게 채워진 blocksnn.Sequential 에 바로 넣어주고 이어서 각 클래스별 로그 확률 값을 표현하기 위한 nn.Linearnn.LogSoftmax 를 넣어줍니다. 이후 self.layers 에 선언한 nn.Sequential 객체를 넣어주어 forward 함수에서 피드포워드하도록 구현하였음을 확인할 수 있습니다.


데이터 로딩 구현하기

파이토치에는 MNIST를 쉽게 로딩할 수 있도록 코드를 제공하고 있습니다. 따라서 MNIST 파일을 직접 손으로 다운로드해서 코드상에 경로를 지정하여 읽어오는 일 따위는 하지 않아도 됩니다. 다음 함수는 MNIST를 로딩하는 함수입니다.

def load_mnist(is_train=True, flatten=True):
    from torchvision import datasets, transforms

    dataset = datasets.MNIST(
        '../data',
        train=is_train,
        download=True,
        transform=transforms.Compose([
            transforms.ToTensor(),
        ]),
    )

    x = dataset.data.float() / 255.
    y = dataset.targets

    if flatten:
        x = x.view(x.size(0), -1)

    return x, y


x와 y에는 이미지 데이터와 이에 따른 클래스 레이블이 담겨있을 것입니다. 다만 x의 경우 원래 28×28 이므로 flattenTrue 일 때 view 함수를 통해 784차원의 벡터로 바꿔주는 것을 볼 수 있습니다. 또한, 원래 각 픽셀은 0에서 255까지의 그레이 스케일 데이터이기 때문에 이를 255로 나누어서 0에서 1사이의 데이터로 바꿔줍니다.

MNIST는 본래 60,000장의 학습 데이터와 10,000장의 테스트 데이터로 나누어져 있습니다. 따라서 60,000장의 학습 데이터를 다시 학습 데이터와 검증데이터로 나누는 작업을 수행해야 합니다. 다음의 함수는 해당 작업을 수행합니다.

def split_data(x, y, train_ratio=.8):
    train_cnt = int(x.size(0) * train_ratio)
    valid_cnt = x.size(0) - train_cnt

    # Shuffle dataset to split into train/valid set.
    indices = torch.randperm(x.size(0))
    x = torch.index_select(
        x,
        dim=0,
        index=indices
    ).split([train_cnt, valid_cnt], dim=0)
    y = torch.index_select(
        y,
        dim=0,
        index=indices
    ).split([train_cnt, valid_cnt], dim=0)

    return x, y


Trainer 클래스 구현하기

앞에서 작성한 모델 클래스의 객체를 학습하기 위한 트레이너 클래스를 살펴볼 차례입니다. 클래스의 각 메서드(함수)를 살펴보도록 하겠습니다. 다음은 클래스의 가장 바깥에서 실행될 train 함수입니다.

def train(self, train_data, valid_data, config):
    lowest_loss = np.inf
    best_model = None

    for epoch_index in range(config.n_epochs):
        train_loss = self._train(train_data[0], train_data[1], config)
        valid_loss = self._validate(valid_data[0], valid_data[1], config)

        # You must use deep copy to take a snapshot of current best weights.
        if valid_loss <= lowest_loss:
            lowest_loss = valid_loss
            best_model = deepcopy(self.model.state_dict())

        print("Epoch(%d/%d): train_loss=%.4e  valid_loss=%.4e  lowest_loss=%.4e" % (
            epoch_index + 1,
            config.n_epochs,
            train_loss,
            valid_loss,
            lowest_loss,
        ))

    # Restore to best model.
    self.model.load_state_dict(best_model)


앞에서 코드를 살펴보기 전 그림을 통해 전체 과정을 설명을 했었습니다. 그때 학습과 검증 등을 아우르는 큰 루프(loop)가 있었고 학습과 검증 내의 작은 루프가 있었습니다. train 함수 내의 for 반복문은 큰 루프를 구현한 것입니다. 따라서 내부에는 self._train 함수와 self._validate 함수를 호출하는 것을 볼 수 있습니다.

그리고 곧이어 검증 손실 값에 따라 현재까지의 모델을 따로 저장하는 과정도 구현되어있습니다. 현재까지의 최고 성능 모델을 best_model 변수에 저장하기 위해서 state_dict 라는 함수를 사용하는 것을 볼 수 있는데, 이 state_dict 함수는 모델의 가중치 파라미터 값을 json 형태로 변환하여 리턴합니다. 이 json 값의 메모리를 best_model 에 저장하는 것이 아니라 값 자체를 새로 복사하여 best_model 에 할당하는 것을 볼 수있습니다.

그리고 학습이 종료되면 best_model 에 저장된 가중치 파라미터 json 값을 load_state_dict 를 통해 self.model 에 다시 로딩합니다. 이 마지막 라인을 통해서 학습 종료후 오버피팅이 되지 않은 가장 좋은 상태의 모델로 복원할 수 있게 됩니다.

이번에는 _train 함수를 살펴봅니다. 이 함수는 한 이터레이션의 학습을 위한 for 반복문을 구현했습니다.

def _train(self, x, y, config):
    self.model.train()

    x, y = self._batchify(x, y, config.batch_size)
    total_loss = 0

    for i, (x_i, y_i) in enumerate(zip(x, y)):
        y_hat_i = self.model(x_i)
        loss_i = self.crit(y_hat_i, y_i.squeeze())

        # Initialize the gradients of the model.
        self.optimizer.zero_grad()
        loss_i.backward()

        self.optimizer.step()

        if config.verbose >= 2:
            print("Train Iteration(%d/%d): loss=%.4e" % (i + 1, len(x), float(loss_i)))

        # Don't forget to detach to prevent memory leak.
        total_loss += float(loss_i)

    return total_loss / len(x)


함수의 시작 부분에서 잊지 않고 train() 함수를 호출하여 모델을 학습 모드로 전환하는 것을 확인할 수 있습니다. 만약 이 라인이 생략된다면 이전 에포크의 검증 과정에서 추론 모드였던 모델 그대로 학습에 활용될 것입니다. for 반복문은 작은 루프를 담당하고 해당 반복문의 내부는 미니배치의 피드포워드와 역전파 그리고 경사하강법에의한 파라미터 업데이트가 담겨있습니다.

마지막으로 config.verbose 에 따라 현재 학습 현황을 출력합니다. config 는 가장 바깥의 train.py 에서 사용자의 실행 시 파라미터 입력에 따른 설정값이 들어있는 객체입니다.

train 함수의 가장 첫 부분에 _batchify 함수를 호출하는 것을 볼 수 있습니다. 다음 _batchify 함수는 매 에포크마다 SGD를 수행하기 위해 셔플링 후 미니배치를 만드는 과정입니다.

def _batchify(self, x, y, batch_size, random_split=True):
    if random_split:
        indices = torch.randperm(x.size(0), device=x.device)
        x = torch.index_select(x, dim=0, index=indices)
        y = torch.index_select(y, dim=0, index=indices)

    x = x.split(batch_size, dim=0)
    y = y.split(batch_size, dim=0)

    return x, y


검증 과정에서는 random_split 이 필요 없으므로 False 로 넘어올 수 있음을 유의해야합니다. 다음 코드는 검증 과정을 위한 _validate 함수입니다.

def _validate(self, x, y, config):
    # Turn evaluation mode on.
    self.model.eval()

    # Turn on the no_grad mode to make more efficintly.
    with torch.no_grad():
        x, y = self._batchify(x, y, config.batch_size, random_split=False)
        total_loss = 0

        for i, (x_i, y_i) in enumerate(zip(x, y)):
            y_hat_i = self.model(x_i)
            loss_i = self.crit(y_hat_i, y_i.squeeze())

            if config.verbose >= 2:
                print("Valid Iteration(%d/%d): loss=%.4e" % (i + 1, len(x), float(loss_i)))

            total_loss += float(loss_i)

        return total_loss / len(x)


대부분 train 과 비슷하게 구현되어 있음을 알 수 있습니다. 다만 가장 바깥쪽에 torch.no_grad() 가 호출되어 있는 것에 대해 유의해야합니다.


train.py 구현하기

train.py 를 통해 다양한 파라미터를 시도하고 모델을 학습할 수 있습니다. CLI 환경에서 바로 train.py 를 호출할 것이며 그러고 나면 train.py 의 다음 코드가 실행될 것입니다.

if __name__ == '__main__':
    config = define_argparser()
    main(config)


먼저 define_argparser() 라는 함수를 통해 사용자가 입력한 파라미터들을 config 라는 객체에 저장합니다. 다음 코드는 define_argparser 함수를 정의한 코드입니다.

def define_argparser():
    p = argparse.ArgumentParser()

    p.add_argument('--model_fn', required=True)
    p.add_argument('--gpu_id', type=int, default=0 if torch.cuda.is_available() else -1)

    p.add_argument('--train_ratio', type=float, default=.8)

    p.add_argument('--batch_size', type=int, default=256)
    p.add_argument('--n_epochs', type=int, default=20)

    p.add_argument('--n_layers', type=int, default=5)
    p.add_argument('--use_dropout', action='store_true')
    p.add_argument('--dropout_p', type=float, default=.3)

    p.add_argument('--verbose', type=int, default=1)

    config = p.parse_args()

    return config


argparse 라이브러리를 통해 다양한 입력 파라미터들을 손쉽게 정의하고 처리할 수 있습니다. train.py 와 함께 주어질 수 있는 입력들은 다음과 같습니다.

파라미터 이름 설명 기본 설정값
model_fn 모델 가중치가 저장될 파일 경로 없음. 사용자 입력 필수
gpu_id 학습이 수행될 그래픽카드 인덱스 번호 (0부터 시작) 0 또는 그래픽 부재 시 -1
train_ratio 학습 데이터 내에서 검증 데이터가 차지할 비율 0.8
batch_size 미니배치 크기 256
n_epochs 에포크 개수 20
n_layers 모델의 계층 개수 5
use_dropout 드롭아웃 사용 여부 False
dropout_p 드롭아웃 사용 시 드롭 확률 0.3
verbose 학습 시 로그 출력의 정도 1


model_fn 파라미터는 required=True 가 되어 있으므로 실행 시 필수적으로 입력되어야 합니다. 이외에는 디폴트 값이 정해져 있어서 사용자가 따로 지정해주지 않으면 디폴트 값이 적용됩니다. 만약 다른 알고리즘의 도입으로 이외에도 추가적인 하이퍼파라미터의 설정이 필요하다면 add_argument 함수를 통해 프로그램이 입력받도록 설정할 수 있습니다. 이렇게 입력받은 파라미터들은 다음과 같이 접근할 수 있습니다.

config.model_fn


앞서 모델 클래스를 정의할 때 hidden_sizes 라는 리스트를 통해 쌓을 블록들의 크기를 지정할 수 있었습니다. 사용자가 블록 크기들을 일일이 지정하는 것은 어쩌면 번거로운 일이 될 수 있기 때문에, 사용자가 모델의 계층 개수만 정해주면 자동으로 등차수열을 적용하여 hidden_sizes 를 구해봅시다. 다음의 get_hidden_sizes 함수는 해당 작업을 수행합니다.

def get_hidden_sizes(input_size, output_size, n_layers):
    step_size = int((input_size - output_size) / n_layers)

    hidden_sizes = []
    current_size = input_size
    for i in range(n_layers - 1):
        hidden_sizes += [current_size - step_size]
        current_size = hidden_sizes[-1]

    return hidden_sizes


이제 학습에 필요한 대부분이 구현되었습니다. 이것들을 모아서 학습이 진행되도록 코드를 구현하면 됩니다. 다음의 코드는 앞서 구현한 코드를 모아서 실제 학습을 진행 과정을 수행하도록 구현한 코드입니다.

def main(config):
    # Set device based on user defined configuration.
    device = torch.device('cpu') if config.gpu_id < 0 else torch.device('cuda:%d' % config.gpu_id)

    x, y = load_mnist(is_train=True, flatten=True)
    x, y = split_data(x.to(device), y.to(device), train_ratio=config.train_ratio)

    print("Train:", x[0].shape, y[0].shape)
    print("Valid:", x[1].shape, y[1].shape)

    input_size = int(x[0].shape[-1])
    output_size = int(max(y[0])) + 1

    model = ImageClassifier(
        input_size=input_size,
        output_size=output_size,
        hidden_sizes=get_hidden_sizes(input_size,
                                      output_size,
                                      config.n_layers),
        use_batch_norm=not config.use_dropout,
        dropout_p=config.dropout_p,
    ).to(device)
    optimizer = optim.Adam(model.parameters())
    crit = nn.NLLLoss()

    if config.verbose >= 1:
        print(model)
        print(optimizer)
        print(crit)

    trainer = Trainer(model, optimizer, crit)

    trainer.train(
        train_data=(x[0], y[0]),
        valid_data=(x[1], y[1]),
        config=config
    )

    # Save best model weights.
    torch.save({
        'model': trainer.model.state_dict(),
        'opt': optimizer.state_dict(),
        'config': config,
    }, config.model_fn)


MNIST에 특화된 입출력 크기를 갖는 것이 아닌 벡터 형태의 어떤 데이터도 입력받아 분류할 수 있도록 input_sizeoutput_size 변수를 계산하는 것에 주목하세요. MNIST 에 특화된 하드코딩을 제거하였기 때문에 load_mnist 함수가 아닌 다른 로딩 함수로 바꿔치기하면 이 코드는 얼마든지 바로 동작할 수 있습니다.

사용자로부터 입력받은 설정(configuration)을 활용하여 모델을 선언한 이후에 아담 옵티마이저와 NLL 손실 함수도 함께 준비합니다. 그리고 트레이너를 초기화한 후 train 함수를 호출하여 불러온 데이터를 넣어주어 학습을 시작합니다. 학습이 종료된 이후에는 torch.save 함수를 활용하여 모델 가중치를 config.model_fn 경로에 저장합니다.


코드 실행

파이썬 스크립트로 작성되었기 때문에 CLI 환경에서 실행할 수 있습니다. train.py 에서 argparse 라이브러리를 활용하여 사용자의 입력을 파싱하여 인식할 수 있습니다. 다만, 처음 사용하거나 오랜만에 실행하는 경우 어떤 입력 파라미터들이 가능한지 기억이 나지 않을 수도 있습니다. 그때에는 입력 파라미터 없이 다음과 같이 실행하거나 ’–help’ 파라미터를 넣어 실행하면 입력 가능한 파라미터들을 확인할 수 있습니다.

$ python train.py
usage: train.py [-h] --model_fn MODEL_FN [--gpu_id GPU_ID]
                [--train_ratio TRAIN_RATIO] [--batch_size BATCH_SIZE]
                [--n_epochs N_EPOCHS] [--n_layers N_LAYERS] [--use_dropout]
                [--dropout_p DROPOUT_P] [--verbose VERBOSE]
train.py: error: the following arguments are required: --model_fn


입력할 파라미터를 정해서 다음과 같이 직접 실행하면 정상적으로 학습이 진행되는 것을 볼 수 있습니다.

$ python train.py --model_fn tmp.pth --gpu_id -1 --batch_size 256 --n_epochs 20 --n_layers 5
[output]
Train: torch.Size([48000, 784]) torch.Size([48000])
Valid: torch.Size([12000, 784]) torch.Size([12000])
ImageClassifier(
  (layers): Sequential(
    (0): Block(
      (block): Sequential(
        (0): Linear(in_features=784, out_features=630, bias=True)
        (1): LeakyReLU(negative_slope=0.01)
        (2): BatchNorm1d(630, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): Block(
      (block): Sequential(
        (0): Linear(in_features=630, out_features=476, bias=True)
        (1): LeakyReLU(negative_slope=0.01)
        (2): BatchNorm1d(476, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (2): Block(
      (block): Sequential(
        (0): Linear(in_features=476, out_features=322, bias=True)
        (1): LeakyReLU(negative_slope=0.01)
        (2): BatchNorm1d(322, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (3): Block(
      (block): Sequential(
        (0): Linear(in_features=322, out_features=168, bias=True)
        (1): LeakyReLU(negative_slope=0.01)
        (2): BatchNorm1d(168, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (4): Linear(in_features=168, out_features=10, bias=True)
    (5): LogSoftmax(dim=-1)
  )
)
Adam (
Parameter Group 0
    amsgrad: False
    betas: (0.9, 0.999)
    capturable: False
    differentiable: False
    eps: 1e-08
    foreach: None
    fused: False
    lr: 0.001
    maximize: False
    weight_decay: 0
)
NLLLoss()
Epoch(1/20): train_loss=1.9642e-01  valid_loss=1.0231e-01  lowest_loss=1.0231e-01
Epoch(2/20): train_loss=8.1590e-02  valid_loss=9.6950e-02  lowest_loss=9.6950e-02
Epoch(3/20): train_loss=5.8084e-02  valid_loss=8.0420e-02  lowest_loss=8.0420e-02
Epoch(4/20): train_loss=4.1818e-02  valid_loss=8.2402e-02  lowest_loss=8.0420e-02
Epoch(5/20): train_loss=3.3787e-02  valid_loss=7.7139e-02  lowest_loss=7.7139e-02
Epoch(6/20): train_loss=2.6198e-02  valid_loss=8.4243e-02  lowest_loss=7.7139e-02
Epoch(7/20): train_loss=2.4709e-02  valid_loss=7.6167e-02  lowest_loss=7.6167e-02
Epoch(8/20): train_loss=2.5783e-02  valid_loss=9.7144e-02  lowest_loss=7.6167e-02
Epoch(9/20): train_loss=2.1204e-02  valid_loss=8.3943e-02  lowest_loss=7.6167e-02
Epoch(10/20): train_loss=1.4652e-02  valid_loss=7.6630e-02  lowest_loss=7.6167e-02
Epoch(11/20): train_loss=1.7424e-02  valid_loss=8.1460e-02  lowest_loss=7.6167e-02
Epoch(12/20): train_loss=1.4826e-02  valid_loss=8.0137e-02  lowest_loss=7.6167e-02
Epoch(13/20): train_loss=1.2403e-02  valid_loss=9.0082e-02  lowest_loss=7.6167e-02
Epoch(14/20): train_loss=1.2167e-02  valid_loss=9.2580e-02  lowest_loss=7.6167e-02
Epoch(15/20): train_loss=1.4440e-02  valid_loss=9.5186e-02  lowest_loss=7.6167e-02
Epoch(16/20): train_loss=9.6789e-03  valid_loss=7.9955e-02  lowest_loss=7.6167e-02
Epoch(17/20): train_loss=9.0307e-03  valid_loss=7.3424e-02  lowest_loss=7.3424e-02
Epoch(18/20): train_loss=9.2683e-03  valid_loss=8.4777e-02  lowest_loss=7.3424e-02
Epoch(19/20): train_loss=9.8202e-03  valid_loss=8.8804e-02  lowest_loss=7.3424e-02
Epoch(20/20): train_loss=8.7659e-03  valid_loss=9.8113e-02  lowest_loss=7.3424e-02


CLI 환경에서 사용자가 train.py 를 실행할 때 간단한 입력으로 필요한 파라미터를 주도록 하면 간편하기도 할뿐더러 실수를 줄일 수 있을 것입니다. MNIST 분류기의 성능을 높이는 것도 중요하지만 이처럼 MNIST 분류기를 좀 더 연구하기 위해 편리한 실험 환경을 갖추는 것도 매우 중요합니다.


predict.ipynb 구현하기

학습을 마치면 가중치 파라미터가 담긴 파일이 torch.save 함수를 활용하여 피클 형태로 저장되어 있을 것입니다. 그럼 이제 해당 모델 파일을 불러와서 추론 및 평가를 수행하는 코드를 구현해야 합니다. 보통은 train.py 처럼 predict.py 를 만들어서 일반 파이썬 스크립트로 짤 수도 있지만 좀 더 손쉬운 시각화를 위해 주피터 노트북을 활용하도록 하겠습니다. 만약 단순히 추론만 필요한 상황이라면 predict.py 를 만들어 추론 함수를 구현한 후에 API 서버 등에서 랩핑(wrapping) 하는 형태로 구현할 수 있을 것입니다. 다음은 torch.load 를 활용하여 torch.save 로 저장된 파일을 불러오기 위한 코드입니다.

model_fn = "./tmp.pth"
device = torch.device('cuda') if torch.cuda.is_available() else torch.device('cpu')

def load(fn, device):
    d = torch.load(fn, map_location=device)
    
    return d['model'], d['config']


map_location 을 통해서 내가 원하는 디바이스로 객체를 로딩하는 것에 주목하세요. 만약 map_location 을 쓰지 않는다면 자동으로 앞서 학습에 활용된 디바이스로 로딩될 것입니다. 같은 컴퓨터라면 크게 상관없지만 만약 다른 컴퓨터일 때 GPU가 없거나 개수가 다르다면 문제가 생길 수 있습니다. 예를 들어 GPU 4개짜리 컴퓨터에서 3번 GPU를 활용해서 학습된 파일인데 추론 컴퓨터에는 0번 GPU까지만 있는 상황이라면 문제가 발생할 것입니다.

다음은 추론을 직접 수행하는 코드를 test 함수로 구현한 모습입니다. eval() 함수를 활용하여 잊지 않고 모델을 추론 모드로 바꿔주었습니다. 또한 torch.no_grad() 를 활용하여 효율적인 텐서 연산을 위한 부분도 확인할 수 있습니다.

def plot(x, y_hat):
    for i in range(x.size(0)):
        img = (np.array(x[i].detach().cpu(), dtype='float')).reshape(28,28)

        plt.imshow(img, cmap='gray')
        plt.show()
        print("Predict:", float(torch.argmax(y_hat[i], dim=-1)))
def test(model, x, y, to_be_shown=True):
    model.eval()
    
    with torch.no_grad():
        y_hat = model(x)

        correct_cnt = (y.squeeze() == torch.argmax(y_hat, dim=-1)).sum()
        total_cnt = float(x.size(0))
        
        accuracy = correct_cnt / total_cnt
        print("Accuracy: %.4f" % accuracy)
        
        if to_be_shown:
            plot(x, y_hat)


다만 현재 이 코드의 문제점은 미니배치 단위로 추론을 수행하지 않는다는 것입니다. MNIST 와 같이 작은 데이터에 대해서는 크게 문제 되지 않을 수도 있지만 만약 테스트셋이 한 번에 연산하기에 너무 크다면 OOM(Out of Memory) 에러가 발생할 것입니다. 이 부분은 for 반복문을 통해 간단하게 구현할 수 있습니다. 다음 코드는 앞서 선언한 코드를 불러와서 실제 추론을 수행하는 코드입니다.

model_dict, train_config = load(model_fn, device)

# Load MNIST test set.
x, y = load_mnist(is_train=False)
x, y = x.to(device), y.to(device)

input_size = int(x.shape[-1])
output_size = int(max(y)) + 1

model = ImageClassifier(
    input_size=input_size,
    output_size=output_size,
    hidden_sizes=get_hidden_sizes(input_size,
                                  output_size,
                                  train_config.n_layers),
    use_batch_norm=not train_config.use_dropout,
    dropout_p=train_config.dropout_p,
).to(device)

model.load_state_dict(model_dict)

test(model, x, y, to_be_shown=False)
[output]
Accuracy: 0.9837


load_state_dictjson 형태의 모델 가중치가 저장된 객체를 실제 모델 객체에 로딩하는 함수입니다. 앞에서 트레이너 코드를 설명할 때에도 사용된 것을 볼 수 있었습니다. 무엇보다 load_state_dict 를 사용하기에 전에 ImageClassifer 객체를 먼저 선언하여 model 변수에 할당하는 것을 볼 수 있습니다. 즉, 이렇게 생성된 model 객체는 임의로 초기화된 가중치 파라미터 값을 가지고 있을 텐데, 이것을 load_state_dict 함수를 통해 학습이 완료된 기존의 가중치 파라미터 값으로 바꿔치기하는것으로 이해할 수 있습니다.

마지막에 test 함수에 전체 테스트셋을 넣어주어 전체 테스트셋에 대한 테스트 성능을 확인할 수 있습니다. 10,000장의 테스트셋 이미지에 대해서 98.37%의 정확도로 분류를 수행하는 것을 볼 수 있습니다.

아직 모델을 거의 튜닝하지 않은 것이기 때문에 검증 데이터셋을 활용하여 하이퍼파라미터 튜닝을 수행한다면 미미하게나마 성능 개선을 할 수도 있을 것입니다. 중요한 점은 절대로 테스트셋을 기준으로 하이퍼파라미터 튜닝을 수행해선 안된다는 것입니다. 다음은 실제 시각화를 위해서 일부 샘플에 대해 추론 및 시각화를 수행하는 코드와 그 결과를 보여주고 있습니다.

n_test = 2
test(model, x[:n_test], y[:n_test], to_be_shown=True)
[output]
Accuracy: 1.0000
Predict: 7.0

image


샘플에 대해서 정확도 100%가 나오고 시각화된 결과를 눈으로 확인해보았을 때에도 정답을 잘 맞히는 것을 확인할 수 있습니다. 단순히 테스트셋에 대해서 추론 및 정확도 계산만 하고 넘어가기 보다 이처럼 실제 샘플을 뜯어보고 눈으로 확인하면서 틀린 것들에 대한 분석을 해야 합니다.


마무리

단순히 주피터 노트북을 활용해서 한 셀씩 코드를 적어나가는 것이 아니라 문제를 해결하기 위한 최적의 알고리즘과 하이퍼파라미터를 연구하고 찾을 수 있는 환경 구축하는 방법을 살펴보았습니다. 이와 같이 프로젝트 환경을 구축하게 되면 추후 다른 프로젝트를 수행할 때에도 최소한의 수정을 거쳐 재활용할 수 있게 됩니다. 정리하면 현재 우리가 구현한 프로젝트는 다음과 같은 요구사항을 반영하고 있습니다.

  • 효율적으로 실험을 반복해서 수행할 수 있어야 한다.
  • 모델 아키텍처가 바뀌어도 바로 동작할 수 있어야 한다.
  • 하이퍼파라미터를 바꿔서 다양한 실험을 돌릴 수 있어야 한다.
  • 코드의 일부분이 수정되어도 다른 부분은 큰 수정이 없도록 독립적으로 동작해야 한다.




코드 정리

model.py : 모델 클래스 정의

import torch
import torch.nn as nn


class Block(nn.Module):
    
    def __init__(self,
                 input_size,
                 output_size,
                 use_batch_norm=True,
                 dropout_p=.4):
        self.input_size = input_size
        self.output_size = output_size
        self.use_batch_norm = use_batch_norm
        self.dropout_p = dropout_p
        
        super().__init__()
        
        def get_regularizer(use_batch_norm, size):
            return nn.BatchNorm1d(size) if use_batch_norm else nn.Dropout(dropout_p)
        
        self.block = nn.Sequential(
            nn.Linear(input_size, output_size),
            nn.LeakyReLU(),
            get_regularizer(use_batch_norm, output_size),
        )
        
    def forward(self, x):
        # |x| = (batch_size, input_size)
        y = self.block(x)
        # |y| = (batch_size, output_size)
        
        return y

    
class ImageClassifier(nn.Module):

    def __init__(self,
                 input_size,
                 output_size,
                 hidden_sizes=[500, 400, 300, 200, 100],
                 use_batch_norm=True,
                 dropout_p=.3):
        
        super().__init__()

        assert len(hidden_sizes) > 0, "You need to specify hidden layers"

        last_hidden_size = input_size
        blocks = []
        for hidden_size in hidden_sizes:
            blocks += [Block(
                last_hidden_size,
                hidden_size,
                use_batch_norm,
                dropout_p
            )]
            last_hidden_size = hidden_size
        
        self.layers = nn.Sequential(
            *blocks,
            nn.Linear(last_hidden_size, output_size),
            nn.LogSoftmax(dim=-1),
        )
        
    def forward(self, x):
        # |x| = (batch_size, input_size)        
        y = self.layers(x)
        # |y| = (batch_size, output_size)
        
        return y


utils.py : 프로그램 내에서 공통적으로 활용되는 모듈을 모아 놓은 스크립트

import torch


def load_mnist(is_train=True, flatten=True):
    from torchvision import datasets, transforms

    dataset = datasets.MNIST(
        '../data',
        train=is_train,
        download=True,
        transform=transforms.Compose([
            transforms.ToTensor(),
        ]),
    )

    x = dataset.data.float() / 255.
    y = dataset.targets

    if flatten:
        x = x.view(x.size(0), -1)

    return x, y


def split_data(x, y, train_ratio=.8):
    train_cnt = int(x.size(0) * train_ratio)
    valid_cnt = x.size(0) - train_cnt

    # Shuffle dataset to split into train/valid set.
    indices = torch.randperm(x.size(0))
    x = torch.index_select(
        x,
        dim=0,
        index=indices
    ).split([train_cnt, valid_cnt], dim=0)
    y = torch.index_select(
        y,
        dim=0,
        index=indices
    ).split([train_cnt, valid_cnt], dim=0)

    return x, y


def get_hidden_sizes(input_size, output_size, n_layers):
    step_size = int((input_size - output_size) / n_layers)

    hidden_sizes = []
    current_size = input_size
    for i in range(n_layers - 1):
        hidden_sizes += [current_size - step_size]
        current_size = hidden_sizes[-1]

    return hidden_sizes


trainer.py : 모델 객체와 데이터를 받아 실제 학습 이터레이션을 수행하는 클래스를 정의

from copy import deepcopy
import numpy as np

import torch

class Trainer():

    def __init__(self, model, optimizer, crit):
        self.model = model
        self.optimizer = optimizer
        self.crit = crit

        super().__init__()

    def _batchify(self, x, y, batch_size, random_split=True):
        if random_split:
            indices = torch.randperm(x.size(0), device=x.device)
            x = torch.index_select(x, dim=0, index=indices)
            y = torch.index_select(y, dim=0, index=indices)

        x = x.split(batch_size, dim=0)
        y = y.split(batch_size, dim=0)

        return x, y

    def _train(self, x, y, config):
        self.model.train()

        x, y = self._batchify(x, y, config.batch_size)
        total_loss = 0

        for i, (x_i, y_i) in enumerate(zip(x, y)):
            y_hat_i = self.model(x_i)
            loss_i = self.crit(y_hat_i, y_i.squeeze())

            # Initialize the gradients of the model.
            self.optimizer.zero_grad()
            loss_i.backward()

            self.optimizer.step()

            if config.verbose >= 2:
                print("Train Iteration(%d/%d): loss=%.4e" % (i + 1, len(x), float(loss_i)))

            # Don't forget to detach to prevent memory leak.
            total_loss += float(loss_i)

        return total_loss / len(x)

    def _validate(self, x, y, config):
        # Turn evaluation mode on.
        self.model.eval()

        # Turn on the no_grad mode to make more efficintly.
        with torch.no_grad():
            x, y = self._batchify(x, y, config.batch_size, random_split=False)
            total_loss = 0

            for i, (x_i, y_i) in enumerate(zip(x, y)):
                y_hat_i = self.model(x_i)
                loss_i = self.crit(y_hat_i, y_i.squeeze())

                if config.verbose >= 2:
                    print("Valid Iteration(%d/%d): loss=%.4e" % (i + 1, len(x), float(loss_i)))

                total_loss += float(loss_i)

            return total_loss / len(x)

    def train(self, train_data, valid_data, config):
        lowest_loss = np.inf
        best_model = None

        for epoch_index in range(config.n_epochs):
            train_loss = self._train(train_data[0], train_data[1], config)
            valid_loss = self._validate(valid_data[0], valid_data[1], config)

            # You must use deep copy to take a snapshot of current best weights.
            if valid_loss <= lowest_loss:
                lowest_loss = valid_loss
                best_model = deepcopy(self.model.state_dict())

            print("Epoch(%d/%d): train_loss=%.4e  valid_loss=%.4e  lowest_loss=%.4e" % (
                epoch_index + 1,
                config.n_epochs,
                train_loss,
                valid_loss,
                lowest_loss,
            ))

        # Restore to best model.
        self.model.load_state_dict(best_model)


train.py : 사용자가 학습을 진행하기 위한 진입 지점

import argparse

import torch
import torch.nn as nn
import torch.optim as optim

from model import ImageClassifier
from trainer import Trainer

from utils import load_mnist
from utils import split_data
from utils import get_hidden_sizes


def define_argparser():
    p = argparse.ArgumentParser()

    p.add_argument('--model_fn', required=True)
    p.add_argument('--gpu_id', type=int, default=0 if torch.cuda.is_available() else -1)

    p.add_argument('--train_ratio', type=float, default=.8)

    p.add_argument('--batch_size', type=int, default=256)
    p.add_argument('--n_epochs', type=int, default=20)

    p.add_argument('--n_layers', type=int, default=5)
    p.add_argument('--use_dropout', action='store_true')
    p.add_argument('--dropout_p', type=float, default=.3)

    p.add_argument('--verbose', type=int, default=1)

    config = p.parse_args()

    return config


def main(config):
    # Set device based on user defined configuration.
    device = torch.device('cpu') if config.gpu_id < 0 else torch.device('cuda:%d' % config.gpu_id)

    x, y = load_mnist(is_train=True, flatten=True)
    x, y = split_data(x.to(device), y.to(device), train_ratio=config.train_ratio)

    print("Train:", x[0].shape, y[0].shape)
    print("Valid:", x[1].shape, y[1].shape)

    input_size = int(x[0].shape[-1])
    output_size = int(max(y[0])) + 1

    model = ImageClassifier(
        input_size=input_size,
        output_size=output_size,
        hidden_sizes=get_hidden_sizes(input_size,
                                      output_size,
                                      config.n_layers),
        use_batch_norm=not config.use_dropout,
        dropout_p=config.dropout_p,
    ).to(device)
    optimizer = optim.Adam(model.parameters())
    crit = nn.NLLLoss()

    if config.verbose >= 1:
        print(model)
        print(optimizer)
        print(crit)

    trainer = Trainer(model, optimizer, crit)

    trainer.train(
        train_data=(x[0], y[0]),
        valid_data=(x[1], y[1]),
        config=config
    )

    # Save best model weights.
    torch.save({
        'model': trainer.model.state_dict(),
        'opt': optimizer.state_dict(),
        'config': config,
    }, config.model_fn)


if __name__ == '__main__':
    config = define_argparser()
    main(config)


Read more

Pytorch Custom Dataset 사용하기

|

메모리와 같은 하드웨어 성능의 한계 등의 이유로 한 번에 전체 데이터를 학습하는것은 힘들기 때문에 일반적으로 배치 형태의 묶음으로 데이터를 나누어 모델 학습에 이용됩니다. 또한 모델을 학습할 때 데이터의 특징과 사용 방법에 따라 학습 성능의 차이가 날 수 있으므로 데이터를 배치 형태로 만드는 법과 데이터를 전처리하는 방법에 대해서 알아보겠습니다.


1. 파이토치 제공 데이터 사용 : torchvision.datasets

import torch
import torchvision
import torchvision.transforms as tr
from torch.utils.data import DataLoader, Dataset
import numpy as np
import matplotlib.pyplot as plt


  • torch : 파이토치 기본 라이브러리
  • torchvision : 이미지와 관련된 파이토치 라이브러리
  • torchvision.transforms : 이미지 전처리 기능들을 제공하는 라이브러리
  • from torch.utils.data import DataLoader, Dataset : 데이터를 모델에 사용할 수 있도록 정리해 주는 라이브러리


# tr.Compose 내에 원하는 전처리를 차례대로 넣어준다.
transf = tr.Compose([
    tr.Resize(16),
    tr.ToTensor()
]) # 16x16으로 이미지 크기 변환 후 텐서 타입으로 변환

# torchvision.datasets에서 제공하는 CIFAR10 데이터를 불러온다.
# root : 다운로드 받을 경로를 입력
# train : Ture이면 학습 데이터를 불러오고 False이면 테스트 데이터를 불러옴
# transform : 미리 선언한 전처리를 사용
trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transf)
testset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transf)


tr.Compose 내에 원하는 전처리를 차례대로 넣어주면 됩니다. 예시에서는 16×16으로 이미지 크기 변환 후 텐서 타입으로 변환합니다. 만약 원본 이미지의 너비, 높이가 다를 경우 너비, 높이를 각각 지정을 해야하므로 tr.Resize((16, 16)) 이라고 입력해야 합니다.

이후 torchvision.datasets 에서 제공하는 CIFAR10 데이터를 불러오고, 동시에 미리 선언한 전처리를 사용하기 위해 transform=transf 을 입력합니다.

print(trainset[0][0].size())
[output]
torch.Size([3, 16, 16])


일반적으로 데이터셋이미지와 라벨이 동시에 들어있는 튜플(이미지, 라벨) 형태 입니다.

  • trainset[0] : 학습 데이터의 첫 번째 데이터로 이미지 한 장과 라벨 숫자 하나가 저장되어 있음.
  • trainset[0][0] : 이미지
  • trainset[0][1] : 라벨


현재 이미지 사이즈는 3×16×16 이며, 여기서 3은 채널 수를 말하고 16×16은 이미지의 너비와 높이를 의미합니다. 일반적인 컬러 사진은 RGB 이미지이기 때문에 채널이 3개이고 (너비)x(높이)x(채널 수) 로 크기가 표현되는 반면, 파이토치에서는 이미지 한 장이 (채널 수)X(너비)×(높이)로 표현 되니 유의해야합니다.

# DataLoader는 데이터를 미니 배치 형태로 만들어 줍니다.
trainloader = DataLoader(trainset, batch_size=50, shuffle=True)
testloader = DataLoader(testset, batch_size=50, shuffle=False)


DataLoader데이터를 미니 배치 형태로 만들어 줍니다. 따라서 배치 데이터에 관한 배치 사이즈 및 셔플 여부 등을 선택할 수 있습니다. 즉, batch_size=50, shuffle=True 은 무작위로 데이터를 섞어 한 번에 50개의 이미지를 묶은 배치로 제공하겠다는 의미입니다.

# CIFAR10의 학습 이미지는 50,000장이고 배치 사이즈가 50장이므로 1,000은 배치의 개수가 됨
# 즉 trainloader가 잘 만들어졌다는 것을 단편적으로 알 수 있다.
len(trainloader)
[output]
1000


CIFAR10 의 학습 이미지는 50000장이고 배치 사이즈가 50장이므로 1000은 배치의 개수가 됩니다.

# 일반적으로 학습 데이터는 4차원 형태로 모델에서 사용된다.
# (배치 크기)x(채널 수)x(너비)x(높이)
images, labels = next(iter(trainloader))
print(images.size())
[output]
torch.Size([50, 3, 16, 16])


배치 이미지를 간단히 확인하기 위해 파이썬에서 제공하는 iternext 함수를 이용하면 됩니다. 이를 통해 trainloader 의 첫 번째 배치를 불러올 수 있습니다. 배치 사이즈는 (배치 크기)×(채널 수)×(너비)×(높이)를 의미 합니다. 즉, 배치 하나에 이미지 50개가 잘 들어가 있음을 알 수 있습니다.

oneshot = images[1].permute(1, 2, 0).numpy()
plt.figure(figsize=(2, 2))
plt.imshow(oneshot)
plt.axis("off")
plt.show()

image


image[1] 의 크기는 (3, 16, 16) 입니다. 이때 그림을 그려주기 위해서 채널 수가 가장 뒤로 가는 형태인 (16, 16, 3) 을 만들어야 하므로 permute() 함수를 이용하여 수정합니다. permute(1,2,0) 은 기존 차원의 위치인 0, 1, 2를 1, 2, 0으로 바꾸는 함수입니다. 따라서 0번째의 크기가 3인 텐서를 마지막으로 보니다. 마지막으로 numpy() 를 이용해 넘파이 배열로 변환합니다.


2. 같은 클래스 별로 폴더를 정리한 경우 : ImageFolder

데이터가 같은 클래스 별로 미리 폴더를 정리한 경우, ImageFolder 하나로 개인 데이터를 사용할 수 있고, 또한 별도의 라벨링이 필요 없으며 폴더 별로 자동으로 라벨링을 합니다. 예를 들어 class 폴더에 tiger, lion 폴더(./class/tiger와 ./class/lion)를 미리 만들고나서 ImageFolder 에 상위 폴더 ./class 를 입력하면 이미지와 라벨이 정리되어 데이터를 불러옵니다.

# 데이터가 같은 클래스 별로 미리 폴더를 정리 된 경우, ImageFolder의 1줄 선언으로 개인 데이터를 사용할 수 있다.
# 별도의 라벨링이 필요 없으며 폴더 별로 자동으로 라벨링을 한다.
transf = tr.Compose([tr.Resize((128, 128)), tr.ToTensor()]) # 128x128 이미지 크기 변환 후 텐서로 만든다.
trainset = torchvision.datasets.ImageFolder(root='./class', transform=transf) # 커스텀 데이터 불러온다.
trainloader = DataLoader(trainset, batch_size=10, shuffle=False) # 데이터를 미니 배치 형태로 만들어 준다.
images, labels = next(iter(trainloader))
print(images.size(), labels)
[output]
torch.Size([10, 3, 128, 128]) tensor([0, 0, 0, 0, 1, 1, 1, 1, 1, 1])


3. 정리되지 않은 커스텀 데이터 불러오기

ImageFolder 를 이용하면 매우 간단하게 이미지 데이터를 사용할 수 있지만 여러 가지 이유로 사용이 불가한 경우가 있다.

  • 라벨 별로 폴더 정리가 되어 있으면 매우 좋겠지만 그렇지 않은 경우가 많은 경우
  • 정리를 하고 싶지만 다른 작업들과 공유된 데이터인 경우 폴더를 함부로 정리할 수 없는 경우
  • 이미지 데이터라도 이미지가 아닌 텍스트, 리스트, 배열 등의 다른 형태로 저장되어 있는 경우


다음 양식은 커스텀 데이터를 불러오는 가장 기본적인 형태입니다.

from torch.utils.data import Dataset

class 클래스명(Dataset):
    
    def __init__(self):
        ...

    def __getitem__(self, index):
        ...

    def __len__(self):
        ...


  • class 클래스명(Dataset): : Dataset 을 상속받아 DataLoader 에서 배치 단위로 불러올 수 있게 해줍니다.
  • def __init__(self): : 데이터 세팅에 필요한 것들을 미리 정의하는 역할을 합니다.
  • def __getitem__(self, index): : 이후 DataLoader 를 통해 샘플이 요청되면 인덱스에 해당하는 샘플을 찾아서 줍니다.
  • def __len__(self): : 크기를 반환합니다.


현재 32×32 크기인 RGB 컬러 이미지 100장과 그에 대한 라벨이 되어 있고 넘파이 배열로 정리가 되어 있다고 가정해보고 커스텀 데이터 세트 예시를 살펴보겠습니다.

train_images = np.random.randint(256, size=(100, 32, 32, 3)) / 255 # (이미지 수)x(너비)x(높이)x(채널 수)
train_labels = np.random.randint(2, size=(100, 1)) # 라벨 수

# .....
# train_images, train_labels = preprocessing(train_images, train_labels)
# .....

print(train_images.shape, train_labels.shape)
[output]
(100, 32, 32, 3) (100, 1)


이미지 전처리 작업이 필요할 경우 openCV와 같은 라이브러리를 이용하여 이 곳에서 작업할 수도 있습니다. preprocessing(train_images, train_labels) 처럼 코드를 추가하여 전처리를 할 수 있는데, 이는 torchvision.transforms 라이브러리보다 OpenCV, SciPy와 같은 라이브러리가 더 많은 전처리 기술을 제공하며, 이미지를 미리 처리해 놓고 전처리 된 이미지를 살펴보면서 작업할 수 있습니다. 따라서 사용 목적과 편의성에 맞게 전처리를 어디서 할 지 정하면 됩니다.

class TensorData(Dataset):

    def __init__(self, x_data, y_data):
        self.x_data = torch.FloatTensor(x_data) # 이미지 데이터를 FloatTensor로 변형
        self.x_data = self.x_data.permute(0, 3, 1, 2) # (이미지 수)x(너비)x(높이)x(채널 수) -> (배치 크기)x(채널 수)x(너비)x(높이)
        self.y_data = torch.LongTensor(y_data) # 라벨 데이터를 LongTensor로 변형
        self.len = self.y_data.shape[0] # 클래스 내의 들어 온 데이터 개수 

    def __getitem__(self, index):
        return self.x_data[index], self.y_data[index] # 뽑아 낼 데이터를 적어준다.

    def __len__(self):
        return self.len # 클래스 내의 들어 온 데이터 개수


  • __init__
    • __init__ 에서 데이터를 받아 데이터를 텐서로 변환합니다.
    • 이때 원래 이미지의 크기가 (100,32,32,3) 이므로 permute(0,3,1,2) 함수를 통해 (100,3,32,32) 으로 바꿔줍니다. 파이토치에서는 (배치 크기)x(채널 수)x(너비)x(높이) 데이터가 사용되므로 원래 데이터 (이미지 수)x(너비)x(높이)x(채널 수) 를 변경해야만 합니다.
    • 입력 데이터의 개수에 대한 변수 self.len 을 만들어줍니다.
  • __getitem__
    • 뽑아낼 데이터에 대해서 인덱스 처리를 하여 적어줍니다.
  • __len__
    • 미리 선언한 self.len 를 반환할 수 있도록 넣어줍니다.


train_data = TensorData(train_images, train_labels) # 텐서 데이터 불러오기 
train_loader = DataLoader(train_data, batch_size=10, shuffle=True) # 미니 배치 형태로 데이터 갖추기

images, labels = next(iter(train_loader))
print(images.size())
print(labels)
[output]
torch.Size([10, 3, 32, 32])
tensor([[1],
        [0],
        [1],
        [1],
        [0],
        [1],
        [1],
        [0],
        [0],
        [1]])


이후 TensorData 클래스를 train_data 로 정의하여 DataLoader 에 넣어주면 배치 데이터의 형태로 사용할 수 있습니다.


4. 커스텀 데이터와 커스텀 전처리 사용하기

파이토치는 전처리 함수들을 제공하여 매우 편리하게 사용할 수 있습니다. 하지만 이미지의 경우 PILImage 타입이거나 Tensor 타입일 때만 사용이 가능하며, 또한 제공하지 않는 기능에 대해서는 직접 구현이 필요합니다. 이번 예시에서는 전처리 클래스 2개를 직접 정의하고 사용해보겠습니다.

import torch
import torchvision.transforms as tr
from torch.utils.data import DataLoader, Dataset
import numpy as np
import matplotlib.pyplot as plt

# 32x32 컬러 이미지와 라벨이 각각 100장이 있다고 가정
train_images = np.random.randint(256, size=(100, 32, 32, 3)) / 255 # (이미지 수)x(너비)x(높이)x(채널 수)
train_labels = np.random.randint(2, size=(100, 1)) # 라벨 수
# 1. 텐서 변환
class ToTensor:
    def __call__(self, sample):
        inputs, labels = sample
        inputs = torch.FloatTensor(inputs) # 텐서로 변환
        inputs = inputs.permute(2, 0, 1) # 크기 변환
        return inputs, torch.LongTensor(labels) # 텐서로 변환


텐서 변환 전처리 클래스를 정의합니다. 전처리는 MyDataset 클래스의 sample 을 불러와 작업하기 때문에 __call__ 함수를 이용합니다. ToTensor: 는 입력 데이터를 텐서 데이터로 변환해 주고 학습에 맞는 크기로 변환하는 작업을 담당합니다. torch.FloatTensortorch.LongTensor 를 이용해 텐서로 변환하고 permute(2,0,1) 을 이용해 크기를 변경하는데, 여기서 유의할 점은 __call__ 함수는 입력값을 하나씩 불러오기 때문에 permute(0, 3, 1, 2) 이 아닌 permute(2, 0, 1) 로 코드를 작성해야합니다.

다음은 CutOut 전처리 클래스를 정의합니다. CutOut 은 이미지 내부에 무작위로 사각형 영역을 선택하여 0으로 만드는 데이터 증식 방법입니다.

# 2. CutOut    
class CutOut:
    
    def __init__(self, ratio=.5):
        self.ratio = int(1/ratio)
           
    def __call__(self, sample):
        inputs, labels = sample
        active = int(np.random.randint(0, self.ratio, 1))
        
        if active == 0:
            _, w, h = inputs.size()
            min_len = min(w, h)
            box_size = int(min_len//4) # CutOut의 크기를 길이의 최솟값의 25%로 설정한다.
            idx = int(np.random.randint(0, min_len-box_size, 1)) # idx를 통해 CutOut 박스의 좌측 상단 꼭지점 위치를 정해준다.
            inputs[:, idx:idx+box_size, idx:idx+box_size] = 0 # 해당 정사각형 영역의 값을 0으로 대체한다.
        
        return inputs, labels


ToTensor 와 다르게 외부에서 CutOut 발생 비율을 받기 위해 __init__ 함수를 사용하여 ratio 를 받습니다. 기본 ratio 는 0.5로 세팅하면 불러온 이미지에 대해서 50% 확률로 CutOut 를 발현합니다.

__call__ 함수에서는 샘플을 받습니다. active 는 정수를 뽑으며, 50%일 경우 0과 1 중 하나를 뽑게 되고 0이면 CutOut 를 발현하고 0이 아니면 원본을 그대로 내보내게 됩니다. CutOut 이 발현될때 inputs.size() 를 통해 이미지의 너비와 높이를 받아 최솟값을 구하고, CutOut의 크기를 길이의 최솟값의 25%로 설정한 후, CutOut 박스의 좌측 상단 꼭지점 위치를 정하여 해당 정사각형 영역의 값을 0으로 대체합니다.

MyDataset 에서 전처리를 추가해보겠습니다.

# 3.3에서 사용한 양식을 그대로 사용하되 전처리 작업을 할 수 있도록 transform을 추가한다. 
class MyDataset(Dataset):
    
    def __init__(self, x_data, y_data, transform=None):
        
        self.x_data = x_data # 넘파이 배열이 들어온다.
        self.y_data = y_data # 넘파이 배열이 들어온다.
        self.transform = transform
        self.len = len(y_data)
        self.tensor = ToTensor()
    
    def __getitem__(self, index):
        sample = self.x_data[index], self.y_data[index]
        
        if self.transform:
            sample = self.transform(sample) # self.transform이 None이 아니라면 전처리를 작업한다.
        else:
            sample = self.tensor(sample)
        
        return sample
    
    def __len__(self):
        return self.len


__init__ 의 입력값에 transform=None 을 추가하는데, transform=None 는 아무 것도 적지 않으면 전처리를 사용하지 않겠다는 의미입니다. 만약 transformNone 이 아니라면 __getitem__ 에서 sample 을 반환하기 전에 전처리를 할 수 있도록 if문을 작성하고, transform=None 일 경우에는 텐서 변환은 기본적으로 하도록 구성합니다.

trans = tr.Compose([ToTensor(), CutOut()]) 
dataset1 = MyDataset(train_images,train_labels, transform=trans)
train_loader1 = DataLoader(dataset1, batch_size=10, shuffle=True)

images1, labels1 = next(iter(train_loader1))
print(images1.size()) # 배치 및 이미지 크기 확인
[output]
torch.Size([10, 3, 32, 32])


ToTensor()tr.ToTensor() 의 차이를 살펴보면, 앞서 사용한 tr.ToTensor()torchvision.transforms 를 이용한 파이토치 메소드를 이용한 것이고, ToTensor() 는 위에서 정의된 메소드를 사용한 것입니다. CutOut 은 괄호에 아무 값도 없으므로 발현 비율의 기본값인 0.5로 CutOut 이 시행됩니다. 그리고 정의된 전처리를 입력한 데이터 세트를 만들고 DataLoader 를 사용합니다.

import torchvision

def imshow(img):
    plt.figure(figsize=(10, 100))
    plt.imshow(img.permute(1, 2, 0).numpy())
    plt.show()

imshow(torchvision.utils.make_grid(images1, nrow=10))

image


그리드를 만들어주는 torchvision.utils.make_grid 를 사용하기 위해 torchvision 을 불러온 후 그림을 그리기 위해 (채널 수, 너비, 높이) 인 이미지 크기를 permute(1, 2, 0) 으로 (너비, 높이, 채널 수) 로 변경하고 numpy() 를 이용하여 넘파이 배열로 변환합니다. 첫번째 이미지를 확대해서 살펴보면 다음과 같습니다.

imshow(images1[0])

image


5. 커스텀 데이터와 파이토치 제공 전처리 사용하기

텐서 변환과 같은 전처리는 파이토치에서 제공하는 전처리를 사용하면 편리합니다. 하지만 앞서 언급했듯이 파이토치의 torchvision.transforms 에서 제공되는 많은 전처리는 PILImage 타입 또는 텐서 일 경우 사용할 수 있습니다. 따라서 기능은 있는데 데이터 타입이 다른 경우는 PILImage 타입으로 변환하여 제공된 전처리를 사용할 수 있습니다.

# torchvision.transforms은 입력 이미지가 일반적으로 PILImage 타입이나 텐서일 경우에 동작한다.
# 현재 데이터는 넘파이 배열이므로, 텐서 변환 후 tr.ToPILImage()을 이용하여 PILImage 타입으로 만들어 준다.

class MyTransform:
    
    def __call__(self, sample):
        inputs, labels = sample
        inputs = torch.FloatTensor(inputs)
        inputs = inputs.permute(2, 0, 1)
        labels = torch.FloatTensor(labels)

        transf = tr.Compose([
            tr.ToPILImage(),
            tr.Resize(128),
            tr.ToTensor()
        ])
        final_output = transf(inputs)      
        
        return final_output, labels  


전처리 클래스 MyTransform 을 정의하여 원하는 전처리를 모두 작성합니다. tr.Compose 는 차례대로 전처리 작업을 하므로 가장 첫 번째에 tr.ToPILImage() 를 넣어 이미지 타입을 바꿔줄 수 있습니다. 이후 불러온 샘플을 전처리 작업에 넣어줍니다.

dataset2 = MyDataset(train_images, train_labels, transform=MyTransform())
train_loader2 = DataLoader(dataset2, batch_size=10, shuffle=True)

images2, labels2 = next(iter(train_loader2))
print(images2.size()) # 배치 및 이미지 크기 확인
[output]
torch.Size([10, 3, 128, 128])


MyDataset 의 전처리에 MyTransform() 을 넣어주면 전처리가 완료됩니다.

imshow(torchvision.utils.make_grid(images2, nrow=10))

image


6. 커스텀 전처리와 파이토치에서 제공하는 전처리 함께 사용하기

위에서 사용한 CutOut 과 달리 다음 CutOut 은 라벨은 받지 않고 이미지를 받아 처리하도록 세팅합니다. 그 이유는 Compose 내부에 있는 제공된 전처리는 이미지만 받아서 처리하기 때문에 그 양식을 맞춰 주어야 하기 때문입니다. 이후 MyDatasetMyTransform 을 정의하겠습니다. 우리가 만든 CutOut은 텐서나 넘파이 배열 타입 모두 작동을 하게 만들었지만 PILImage 타입에서는 타입 오류가 나므로 tr.ToTensor() 뒤에 CutOut 을 배치합니다.

class CutOut:
    
    def __init__(self, ratio=.5):
        self.ratio = int(1/ratio)
           
    def __call__(self, inputs):

        active = int(np.random.randint(0, self.ratio, 1))
        
        if active == 0:
            _, w, h = inputs.size()
            min_len = min(w, h)
            box_size = int(min_len//4)
            idx = int(np.random.randint(0, min_len-box_size, 1))
            inputs[:, idx:idx+box_size, idx:idx+box_size] = 0

        return inputs
class MyDataset(Dataset):
    
    def __init__(self, x_data, y_data, transform=None):
        self.x_data = x_data # 넘파이 배열이 들어온다.
        self.y_data = y_data # 넘파이 배열이 들어온다.
        self.transform = transform
        self.len = len(y_data)
        self.tensor = ToTensor()
    
    def __getitem__(self, index):
        sample = self.x_data[index], self.y_data[index]
        
        if self.transform:
            sample = self.transform(sample) # self.transform이 None이 아니라면 전처리를 작업한다.
        else:
            sample = self.tensor(sample)
        
        return sample
    
    def __len__(self):
        return self.len       
class MyTransform:
    
    def __call__(self, sample):
        inputs, labels = sample
        inputs = torch.FloatTensor(inputs)
        inputs = inputs.permute(2, 0, 1)
        labels = torch.FloatTensor(labels)

        transf = tr.Compose([
            tr.ToPILImage(),
            tr.Resize(128),
            tr.ToTensor(),
            CutOut()
        ])
        final_output = transf(inputs)
        
        return final_output, labels


이제 전처리를 적용한 결과를 확인하겠습니다.

import torch
import torchvision.transforms as tr
from torch.utils.data import DataLoader, Dataset
import numpy as np
import matplotlib.pyplot as plt

# 32x32 컬러 이미지와 라벨이 각각 100장이 있다고 가정
train_images = np.random.randint(256, size=(100, 32, 32, 3)) / 255 # (이미지 수)x(너비)x(높이)x(채널 수)
train_labels = np.random.randint(2, size=(100, 1)) # 라벨 수

dataset3 = MyDataset(train_images, train_labels, transform=MyTransform())
train_loader3 = DataLoader(dataset3, batch_size=10, shuffle=True)

images3, labels3 = next(iter(train_loader3))
print(images3.size()) # 배치 및 이미지 크기 확인
[output]
torch.Size([10, 3, 128, 128])


imshow(torchvision.utils.make_grid(images3, nrow=10))

image


전처리를 적용하지않은 결과를 확인하겠습니다.

dataset3 = MyDataset(train_images, train_labels)
train_loader3 = DataLoader(dataset3, batch_size=10, shuffle=True)

images3, labels3 = next(iter(train_loader3))
print(images3.size()) # 배치 및 이미지 크기 확인
[output]
torch.Size([10, 3, 32, 32])


imshow(torchvision.utils.make_grid(images3, nrow=10))

image


다음 결과를 통해 CIFAR10 데이터가 배치 10개씩 나눠지고 이미지 사이즈를 128로 늘린 뒤 텐서로 변환되고 50% 확률로 무작위 선택하여 CutOut을 적용한 것을 알 수 있습니다.

transf = tr.Compose([
    tr.Resize(128),
    tr.ToTensor(),
    CutOut()
])
trainset = torchvision.datasets.CIFAR10(
    root='./data',
    train=True,
    download=True,
    transform=transf
)
trainloader = DataLoader(
    trainset,
    batch_size=10,
    shuffle=True
)
images, labels = next(iter(trainloader))
print(images.size()) # 배치 및 이미지 크기 확인
[output]
torch.Size([10, 3, 128, 128])


imshow(torchvision.utils.make_grid(images, nrow=10))

image

Read more

DBSCAN

|

DBSCAN 개요

DBSCAN(Density Based Spatial Clustering of Applications with Noise)데이터의 분포가 기하학적으로 복잡한 데이터 세트에도 효과적인 군집화가 가능 합니다. 다음과 같이 내부의 원 모양과 외부의 원 모양 형태의 분포를 가진 데이터 세트를 군집화 한다고 가정할 때 K-평균, 평균 이동, GMM으로는 효과적인 군집화를 수행하기가 어렵습니다.

DBSCAN특정 공간 내에 데이터 밀도 차이를 기반 알고리즘 으로 하고 있어서 복잡한 기하학적 분포도를 가진 데이터 세트에 대해서도 군집화를 잘 수행 합니다.

image


DBSCAN을 구성하는 가장 중요한 두 가지 파라미터입실론(epsilon)으로 표기하는 주변 영역 과 이 입실론 주변 영역에 포함되는 최소 데이터의 개수 min points 입니다.

  • 입실론 주변 영역(epsilon) : 개별 데이터를 중심으로 입실론 반경을 가지는 원형의 영역
  • 최소 데이터 개수(min points) : 개별 데이터의 입실론 주변 영역에 포함되는 타 데이터의 개수


입실론 주변 영역 내에 포함되는 최소 데이터 개수를 충족시키는가 아닌가에 따라 데이터 포인트를 다음과 같이 정의합니다.

  • 핵심 포인트(Core Point) : 주변 영역 내에 최소 데이터 개수 이상의 타 데이터를 가지고 있을 경우 해당 데이터를 핵심 포인트라고 합니다.
  • 이웃 포인트(Neighbor Point) : 주변 영역 내에 위치한 타 데이터를 이웃 포인트라고 합니다.
  • 경계 포인트(Border Point) : 주변 영역 내에 최소 데이터 개수 이상의 이웃 포인트를 가지고 있지 않지만 핵심 포인트를이웃 포인트로 가지고 있는 데이터를 경계 포인트라고 합니다.
  • 잡음 포인트(Noise Point) : 최소 데이터 개수 이상의 이웃 포인트를 가지고 있지 않으며, 핵심 포인트도 이웃 포인트로 가지고 있지 않는 데이터를 잡음 포인트라고 합니다.


다음 그림과 같이 P1에서 P12까지 12개의 데이터 세트에 대해서 DBSCAN 군집화를 적용하면서 주요 개념을 설명하겠습니다. 특정 입실론 반경 내에 포함될 최소 데이터 세트를 6개로(자기 자신의 데이터를 포함) 가정 하겠습니다.

image


P1 데이터를 기준으로 입실론 반경 내에 포함된 데이터가 7개(자신은 P1, 이웃 데이터 P2, P6, P7, P8, P9, P11)로 최소 데이터 5개 이상을 만족하므로 P1 데이터는 핵심 포인트(Core Point) 입니다.

image


다음으로 P2 데이터 포인트를 살펴보겠습니다. P2 역시 반경 내에 6개의 데이터(자신은 P2, 이웃 데이터 P1, P3, P4, P9, P10)를 가지고 있으므로 핵심 포인트 입니다.

image


핵심 포인트 P1의 이웃 데이터 포인트 P2 역시 핵심 포인트일 경우 P1에서 P2로 연결해 직접 접근이 가능 합니다.

image


특정 핵심 포인트에서 직접 접근이 가능한 다른 핵심 포인트를 서로 연결하면서 군집화를 구성 합니다. 이러한 방식으로 점차적으로 군집(Cluster) 영역을 확장해 나가는 것이 DBSCAN 군집화 방식 입니다.

image


P3 데이터의 경우 반경 내에 포함되는 이웃 데이터는 P2, P4로 2개 이므로 군집으로 구분할 수 있는 핵심 포인트가 될 수 없습니다. 하지만 이웃 데이터 중에 핵심 포인트인 P2를 가지고 있습니다. 이처럼 자신은 핵심 포인트가 아니지만, 이웃 데이터로 핵심 포인트를 가지고 있는 데이터경계 포인트(Border Point) 라고 합니다. 경계 포인트는 군집의 외곽을 형성합니다.

image


다음 그림의 P5와 같이 반경 내에 최소 데이터를 가지고 있지도 않고, 핵심 포인트 또한 이웃 데이터로 가지고 있지 않는 데이터잡음 포인트(Noise Point) 라고 합니다.

image


DBSCAN 은 이처럼 입실론 주변 영역의 최소 데이터 개수를 포함하는 밀도 기준을 충족시키는 데이터인 핵심 포인트를 연결하면서 군집화를 구성하는 방식 입니다.

사이킷런은 DBSCAN 클래스를 통해 DBSCAN 알고리즘을 지원합니다. DBSCAN 클래스는 다음과 같은 주요한 초기화 파라미터를 가지고 있습니다.

  • eps
    • 입실론 주변 영역의 반경을 의미
  • min_samples
    • 핵심 포인트가 되기 위해 입실론 주변 영역 내에 포함돼야 할 데이터의 최소 개수를 의미
    • 자신의 데이터를 포함(min points + 1)


DBSCAN 적용하기 - 붓꽃 데이터 세트

DBSCAN 클래스를 이용해 붓꽃 데이터 세트를 군집화하겠습니다. eps=0.6, min_samples=8 로 하겠습니다. 일반적으로 eps 값으로는 1 이하의 값을 설정합니다.

from sklearn.datasets import load_iris

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
%matplotlib inline

iris = load_iris()
feature_names = ['sepal_length', 'sepal_width', 'petal_length', 'petal_width']

# 보다 편리한 데이타 Handling을 위해 DataFrame으로 변환
irisDF = pd.DataFrame(data=iris.data, columns=feature_names)
irisDF['target'] = iris.target
from sklearn.cluster import DBSCAN

dbscan = DBSCAN(
    eps=0.6,
    min_samples=8,
    metric='euclidean'
)
dbscan_labels = dbscan.fit_predict(iris.data)

irisDF['dbscan_cluster'] = dbscan_labels
irisDF['target'] = iris.target

iris_result = irisDF.groupby(['target'])['dbscan_cluster'].value_counts()
print(iris_result)
[output]
target  dbscan_cluster
0        0                49
        -1                 1
1        1                46
        -1                 4
2        1                42
        -1                 8
Name: dbscan_cluster, dtype: int64


먼저 dbscan_cluster 값을 살펴보면 0과 1 외에 특이하게 -1이 군집 레이블로 있는 것 을 알 수 있습니다. 군집 레이블이 -1 인 것은 노이즈에 속하는 군집을 의미 합니다. 따라서 위 붓꽃 데이터 세트는 DBSCAN에서 0과 1 두 개의 군집으로 군집화됐습니다. Target 값의 유형이 3가지인데, 군집이 2개가 됐다고 군집화 효율이 떨어진다는 의미는 아니며, 특히 붓꽃 데이터 세트는 군집을 3개로 하는 것보다는 2개로 하는 것이 군집화의 효율로서 더 좋은 면이있습니다.

DBSCAN은 군집의 개수를 알고리즘에 따라 자동으로 지정 하므로 DBSCAN에서 군집의 개수를 지정하는 것은 무의미 하다고 할 수 있습니다.


DBSCAN 으로 군집화 데이터 세트를 2차원 평면에서 표현하기 위해 PCA 를 이용해 2개의 피처로 압축 변환한 뒤, visualize_cluster_plot() 함수를 이용해 시각화해 보겠습니다.

visualize_cluster_plot()
### 클러스터 결과를 담은 DataFrame과 사이킷런의 Cluster 객체등을 인자로 받아 클러스터링 결과를 시각화하는 함수  
def visualize_cluster_plot(clusterobj, dataframe, label_name, iscenter=True):
    if iscenter:
        centers = clusterobj.cluster_centers_
        
    unique_labels = np.unique(dataframe[label_name].values)
    markers=['o', 's', '^', 'x', '*']
    isNoise=False

    for label in unique_labels:
        label_cluster = dataframe[dataframe[label_name]==label]
        if label == -1:
            cluster_legend = 'Noise'
            isNoise=True
        else:
            cluster_legend = 'Cluster '+str(label)
        
        plt.scatter(x=label_cluster['ftr1'], y=label_cluster['ftr2'], s=70,\
                    edgecolor='k', marker=markers[label], label=cluster_legend)
        
        if iscenter:
            center_x_y = centers[label]
            plt.scatter(x=center_x_y[0], y=center_x_y[1], s=250, color='white',
                        alpha=0.9, edgecolor='k', marker=markers[label])
            plt.scatter(x=center_x_y[0], y=center_x_y[1], s=70, color='k',\
                        edgecolor='k', marker='$%d$' % label)
    
    if isNoise: legend_loc='upper center'
    else: legend_loc='upper right'
    
    plt.legend(loc=legend_loc)
    plt.show()


from sklearn.decomposition import PCA

# 2차원으로 시각화하기 위해 PCA n_componets=2로 피처 데이터 세트 변환
pca = PCA(n_components=2, random_state=0)
pca_transformed = pca.fit_transform(iris.data)

# visualize_cluster_plot( ) 함수는 ftr1, ftr2 컬럼을 좌표에 표현하므로 PCA 변환값을 해당 컬럼으로 생성
irisDF['ftr1'] = pca_transformed[:,0]
irisDF['ftr2'] = pca_transformed[:,1]

visualize_cluster_plot(dbscan, irisDF, 'dbscan_cluster', iscenter=False)

image


별표로 표현된 값은 모두 노이즈로, PCA로 2차원으로 표현하면 이상치인 노이즈 데이터가 명확히 드러납니다. DBSCAN을 적용할 때는 특정 군집 개수로 군집을 강제하지 않는 것이 좋습니다. DBSCAN 알고리즘에 적절한 eps와 min_samples 파라미터를 통해 최적의 군집을 찾는 게 중요합니다.

  • eps의 값을 크게 설정
    • 반경이 커져 포함하는 데이터가 많아지므로 노이즈 데이터 개수가 작아짐
  • min_samples를 크게 설정
    • 주어진 반경 내에서 더 많은 데이터를 포함시켜야 하므로 노이즈 데이터 개수가 커지게 됨
    • 데이터 밀도가 더 커져야 하는데, 매우 촘촘한 데이터 분포가 아닌 경우 노이즈로 인식하기 때문


eps를 기존의 0.6에서 0.8로 증가시키면 노이즈 데이터 수가 줄어듭니다.

from sklearn.cluster import DBSCAN

dbscan = DBSCAN(eps=0.8, min_samples=8, metric='euclidean')
dbscan_labels = dbscan.fit_predict(iris.data)

irisDF['dbscan_cluster'] = dbscan_labels
irisDF['target'] = iris.target

iris_result = irisDF.groupby(['target'])['dbscan_cluster'].value_counts()
print(iris_result)

visualize_cluster_plot(dbscan, irisDF, 'dbscan_cluster', iscenter=False)
[output]
target  dbscan_cluster
0        0                50
1        1                50
2        1                47
        -1                 3
Name: dbscan_cluster, dtype: int64

image


노이즈 군집인 -1이 3개밖에 없습니다. 기존에 eps 가 0.6일 때 노이즈로 분류된 데이터 세트는 eps 반경이 커지면서 Cluster 1에 소속됐습니다. 이번에는 eps 를 기존 0.6으로 유지하고 min_samples 를 16으로 늘려보겠습니다.

dbscan = DBSCAN(eps=0.6, min_samples=16, metric='euclidean')
dbscan_labels = dbscan.fit_predict(iris.data)

irisDF['dbscan_cluster'] = dbscan_labels
irisDF['target'] = iris.target

iris_result = irisDF.groupby(['target'])['dbscan_cluster'].value_counts()
print(iris_result)

visualize_cluster_plot(dbscan, irisDF, 'dbscan_cluster', iscenter=False)
[output]
target  dbscan_cluster
0        0                48
        -1                 2
1        1                44
        -1                 6
2        1                36
        -1                14
Name: dbscan_cluster, dtype: int64

image


노이즈 데이터가 기존보다 많이 증가함을 알 수 있습니다.


DBSCAN 적용하기 - make_circles() 데이터 세트

이번에는 복잡한 기하학적 분포를 가지는 데이터 세트에서 DBSCAN 과 타 알고리즘을 비교해 보겠습니다. 먼저 make_circles() 함수를 이용해 내부 원과 외부 원 형태로 돼 있는 2차원 데이터 세트를 만들어 보겠습니다.

  • make_circles()
    • 오직 2개의 피처만을 생성하므로 별도의 피처 개수를 지정할 필요가 없음
    • noise : 노이즈 데이터 세트의 비율
    • factor : 외부 원과 내부 원의 scale 비율


from sklearn.datasets import make_circles

X, y = make_circles(
    n_samples=1000,
    shuffle=True,
    noise=0.05,
    random_state=0,
    factor=0.5
)
clusterDF = pd.DataFrame(data=X, columns=['ftr1', 'ftr2'])
clusterDF['target'] = y

visualize_cluster_plot(None, clusterDF, 'target', iscenter=False)

image


make_circles() 는 내부 원과 외부 원으로 구분되는 데이터 세트를 생성함을 알 수 있습니다. DBSCAN 이 이 데이터 세트를 군집화한 결과를 보기 전에, 먼저 K-평균GMM 은 어떻게 이 데이터 세트를 군집화하는지 확인해 보겠습니다. 먼저 K-평균 으로 make_circles() 데이터 세트를 군집화해 보겠습니다.

# KMeans로 make_circles( ) 데이터 셋을 클러스터링 수행. 
from sklearn.cluster import KMeans

kmeans = KMeans(
    n_clusters=2,
    max_iter=1000,
    random_state=0
)
kmeans_labels = kmeans.fit_predict(X)
clusterDF['kmeans_cluster'] = kmeans_labels

visualize_cluster_plot(kmeans, clusterDF, 'kmeans_cluster', iscenter=True)

image


위, 아래 군집 중심을 기반으로 위와 아래 절반으로 군집화됐습니다. 거리 기반 군집화로는 위와 같이 데이터가 특정한 형태로 지속해서 이어지는 부분을 찾아내기 어렵습니다.

다음으로는 GMM을 적용해 보겠습니다.

# GMM으로 make_circles( ) 데이터 셋을 클러스터링 수행. 
from sklearn.mixture import GaussianMixture

gmm = GaussianMixture(
    n_components=2,
    random_state=0
)
gmm_label = gmm.fit(X).predict(X)
clusterDF['gmm_cluster'] = gmm_label

visualize_cluster_plot(gmm, clusterDF, 'gmm_cluster', iscenter=False)

image


GMM 은 일렬로 늘어선 데이터 세트에서는 효과적으로 군집화 적용이 가능했으나, 내부와 외부의 원형으로 구성된 더 복잡한 형태의 데이터 세트에서는 군집화가 원하는 방향으로 되지 않았습니다. 이제 DBSCAN으로 군집화를 적용해 보겠습니다.

# DBSCAN으로 make_circles( ) 데이터 셋을 클러스터링 수행. 
from sklearn.cluster import DBSCAN

dbscan = DBSCAN(
    eps=0.2,
    min_samples=10,
    metric='euclidean'
)
dbscan_labels = dbscan.fit_predict(X)
clusterDF['dbscan_cluster'] = dbscan_labels

visualize_cluster_plot(dbscan, clusterDF, 'dbscan_cluster', iscenter=False)

image


DBSCAN 으로 군집화를 적용해 원하는 방향으로 정확히 군집화가 됐음을 알 수 있습니다.

Read more

GMM(Gaussian Mixture Model)

|

GMM(Gaussian Mixture Model) 군집화군집화를 적용하고자 하는 데이터가 여러 개의 가우시안 분포(Gaussian Distribution)를 가진 데이터 집합들이 섞여서 생성된 것이라는 가정하에 군집화를 수행하는 방식입니다.

데이터를 여러 개의 가우시안 분포가 섞인 것으로 간주 하고, 섞인 데이터 분포에서 개별 유형의 가우시안 분포를 추출 합니다. 먼저 다음과 같이 세 개의 가우시안 분포 A, B, C를 가진 데이터 세트가 있다고 가정하겠습니다.

image


이 세 개의 정규 분포를 합치면 다음 형태가 될 것입니다.

image


군집화를 수행하려는 실제 데이터 세트의 데이터 분포도가 다음과 같다면 쉽게 이 데이터 세트가 정규분포 A, B, C가 합쳐서 된 데이터 분포도임을 알 수 있습니다.

image


전체 데이터 세트는 서로 다른 정규 분포 형태를 가진 여러 가지 확률 분포 곡선으로 구성될 수 있으며, 이러한 서로 다른 정규 분포에 기반해 군집화을 수행하는 것GMM 군집화 방식 입니다. 가령 1000개의 데이터 세트가 있다면 이를 구성하는 여러 개의 정규 분포 곡선을 추출하고, 개별 데이터가 이 중 어떤 정규 분포에 속하는지 결정하는 방식입니다.

image


이와 같은 방식은 GMM에서는 모수 추정 이라고 하는데, 모수 추정은 대표적으로 2가지를 추정하는 것 입니다.

  • 개별 정규 분포의 평균과 분산
  • 각 데이터가 어떤 정규 분포에 해당되는지의 확률


이러한 모수 추정을 위해 GMM은 EM(Expectation and Maximization) 방법을 적용 합니다. 사이킷런은 이러한 GMM의 EM 방식을 통한 모수 추정 군집화를 지원하기 위해 GaussianMixture 클래스를 지원합니다.


GMM을 이용한 붓꽃 데이터 세트 군집화

GMM확률 기반 군집화 이고 K-평균거리 기반 군집화 입니다. 이번에는 붓꽃 데이터 세트로 이 두 가지 방식을 이용해 군집화를 수행한 뒤 양쪽 방식을 비교해 보겠습니다.

from sklearn.datasets import load_iris
from sklearn.cluster import KMeans

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
%matplotlib inline

iris = load_iris()
feature_names = ['sepal_length', 'sepal_width', 'petal_length', 'petal_width']

# 보다 편리한 데이타 Handling을 위해 DataFrame으로 변환
irisDF = pd.DataFrame(data=iris.data, columns=feature_names)
irisDF['target'] = iris.target


GaussianMixture 객체의 가장 중요한 초기화 파라미터는 n_components 입니다. n_componentsgaussian mixture 의 모델의 총 개수 로, 군집의 개수를 정하는 데 중요한 역할을 수행합니다. n_components 를 3으로 설정하고 GaussianMixture 로 군집화를 수행하겠습니다.

from sklearn.mixture import GaussianMixture

gmm = GaussianMixture(
    n_components=3,
    random_state=0
).fit(iris.data)
gmm_cluster_labels = gmm.predict(iris.data)

# 클러스터링 결과를 irisDF 의 'gmm_cluster' 컬럼명으로 저장
irisDF['gmm_cluster'] = gmm_cluster_labels
irisDF['target'] = iris.target

# target 값에 따라서 gmm_cluster 값이 어떻게 매핑되었는지 확인. 
iris_result = irisDF.groupby(['target'])['gmm_cluster'].value_counts()
print(iris_result)
[output]
target  gmm_cluster
0       0              50
1       2              45
        1               5
2       1              50
Name: gmm_cluster, dtype: int64


Target 0은 cluster 0으로, Target 2는 cluster 1로 모두 잘 매핑됐습니다. Target 1만 cluster 2로 45개(90%), cluster 1로 5개(10%) 매핑됐습니다. 붓꽃 데이터 세트의 K-평균 군집화를 수행한 결과를 보겠습니다.

kmeans = KMeans(
    n_clusters=3,
    init='k-means++',
    max_iter=300,
    random_state=0
).fit(iris.data)
kmeans_cluster_labels = kmeans.predict(iris.data)

irisDF['kmeans_cluster'] = kmeans_cluster_labels
iris_result = irisDF.groupby(['target'])['kmeans_cluster'].value_counts()
print(iris_result)
[output]
target  kmeans_cluster
0       1                 50
1       0                 48
        2                  2
2       2                 36
        0                 14
Name: kmeans_cluster, dtype: int64


이는 어떤 알고리즘에 더 뛰어나다는 의미가 아니라 붓꽃 데이터 세트가 GMM 군집화에 더 효과적이라는 의미 입니다. K-평균은 평균 거리 중심으로 중심을 이동하면서 군집화를 수행하는 방식이므로 개별 군집 내의 데이터가 원형으로 흩어져 있는 경우에 매우 효과적으로 군집화가 수행될 수 있습니다.


GMM과 K-평균의 비교

KMeans원형의 범위에서 군집화를 수행 합니다. 데이터 세트가 원형의 범위를 가질수록 KMeans의 군집화 효율은 더욱 높아집니다.

다음은 make_blobs() 의 군집의 수를 3개로 하되, cluster_std 를 0.5로 설정해 군집 내의 데이터를 뭉치게 유도한 데이터 세트에 KMeans 를 적용한 결과입니다. 이렇게 cluster_std 를 작게 설정하면 데이터가 원형 형태로 분산될 수 있습니다. 결과를 보면 KMeans 로 효과적으로 군집화된 것을 알 수 있습니다.

image


KMeans 군집화는 개별 군집의 중심에서 원형의 범위로 데이터를 군집화 했습니다. 하지만 데이터가 원형의 범위로 퍼져 있지 않는 경우에는 어떨까요? KMeans 는 대표적으로 데이터가 길쭉한 타원형으로 늘어선 경우에 군집화를 잘 수행하지 못합니다.

다음에서 해당 데이터 세트를 make_blobs() 의 데이터를 변환해 만들어보겠습니다. 앞으로도 군집을 자주 시각화하므로 이를 위한 별도의 함수를 만들어 이용하겠습니다.

visualize_cluster_plot(
    clusterobj,
    dataframe,
    label_name,
    iscluster=True
)
  • clusterobj
    • 사이킷런의 군집 수행 객체
    • KMeans나 GaussianMixture의 fit()와 predict()로 군집화를 완료한 객체
    • 만약 군집화 결과 시각화가 아니고 make_blobs()로 생성한 데이터의 시각화일 경우 None 입력
  • dataframe
    • 피처 데이터 세트와 label 값을 가진 DataFrame
  • label_name
    • 군집화 결과 시각화일 경우 dataframe 내의 군집화 label 칼럼명
    • make_blobs() 결과 시각화일 경우는 dataframe 내의 target 칼럼명
  • iscenter
    • 사이킷런 Cluster 객체가 군집 중심 좌표를 제공하면 True, 그렇지 않으면 False


**visualize_cluster_plot()**
### 클러스터 결과를 담은 DataFrame과 사이킷런의 Cluster 객체등을 인자로 받아 클러스터링 결과를 시각화하는 함수  
def visualize_cluster_plot(clusterobj, dataframe, label_name, iscenter=True):
    if iscenter:
        centers = clusterobj.cluster_centers_
        
    unique_labels = np.unique(dataframe[label_name].values)
    markers=['o', 's', '^', 'x', '*']
    isNoise=False

    for label in unique_labels:
        label_cluster = dataframe[dataframe[label_name]==label]
        if label == -1:
            cluster_legend = 'Noise'
            isNoise=True
        else:
            cluster_legend = 'Cluster '+str(label)
        
        plt.scatter(x=label_cluster['ftr1'], y=label_cluster['ftr2'], s=70,\
                    edgecolor='k', marker=markers[label], label=cluster_legend)
        
        if iscenter:
            center_x_y = centers[label]
            plt.scatter(x=center_x_y[0], y=center_x_y[1], s=250, color='white',
                        alpha=0.9, edgecolor='k', marker=markers[label])
            plt.scatter(x=center_x_y[0], y=center_x_y[1], s=70, color='k',\
                        edgecolor='k', marker='$%d$' % label)
    
    if isNoise: legend_loc='upper center'
    else: legend_loc='upper right'
    
    plt.legend(loc=legend_loc)
    plt.show()


from sklearn.datasets import make_blobs

# make_blobs() 로 300개의 데이터 셋, 3개의 cluster 셋, cluster_std=0.5 을 만듬. 
X, y = make_blobs(n_samples=300, n_features=2, centers=3, cluster_std=0.5, random_state=0)

# 길게 늘어난 타원형의 데이터 셋을 생성하기 위해 변환함. 
transformation = [[0.60834549, -0.63667341], [-0.40887718, 0.85253229]]
X_aniso = np.dot(X, transformation)

# feature 데이터 셋과 make_blobs( ) 의 y 결과 값을 DataFrame으로 저장
clusterDF = pd.DataFrame(data=X_aniso, columns=['ftr1', 'ftr2'])
clusterDF['target'] = y

# 생성된 데이터 셋을 target 별로 다른 marker 로 표시하여 시각화 함. 
visualize_cluster_plot(None, clusterDF, 'target', iscenter=False)

image


위와 같이 만들어진 데이터 세트에서는 KMeans 의 군집화 정확성이 떨어지게 됩니다. KMeans 가 위 데이터 세트를 어떻게 군집화하는지 확인해 보겠습니다.

# 3개의 Cluster 기반 Kmeans 를 X_aniso 데이터 셋에 적용 
kmeans = KMeans(3, random_state=0)
kmeans_label = kmeans.fit_predict(X_aniso)
clusterDF['kmeans_label'] = kmeans_label

visualize_cluster_plot(kmeans, clusterDF, 'kmeans_label',iscenter=True)

image


KMeans 로 군집화를 수행할 경우, 주로 원형 영역 위치로 개별 군집화 가 되면서 원하는 방향으로 구성되지 않음 을 알 수 있습니다. KMeans가 평균 거리 기반으로 군집화를 수행 하므로 같은 거리상 원형으로 군집을 구성하면서 위와 같이 길쭉한 방향으로 데이터가 밀접해 있을 경우에는 최적의 군집화가 어렵습니다. 이번에는 GMM 으로 군집화를 수행해 보겠습니다.

# 3개의 n_components기반 GMM을 X_aniso 데이터 셋에 적용 
gmm = GaussianMixture(n_components=3, random_state=0)
gmm_label = gmm.fit(X_aniso).predict(X_aniso)
clusterDF['gmm_label'] = gmm_label

# GaussianMixture는 cluster_centers_ 속성이 없으므로 iscenter를 False로 설정. 
visualize_cluster_plot(gmm, clusterDF, 'gmm_label',iscenter=False)

image


데이터가 분포된 방향에 따라 정확하게 군집화됐음 을 알 수 있습니다. make_blobs()target 값과 KMeans, GMM 의 군집 Label 값을 서로 비교해 위와 같은 데이터 세트에서 얼만큼의 군집화 효율 차이가 발생하는지 확인해 보겠습니다.

print('### KMeans Clustering ###')
print(clusterDF.groupby('target')['kmeans_label'].value_counts())
print('\n### Gaussian Mixture Clustering ###')
print(clusterDF.groupby('target')['gmm_label'].value_counts())
[output]
### KMeans Clustering ###
target  kmeans_label
0       2                73
        0                27
1       1               100
2       0                86
        2                14
Name: kmeans_label, dtype: int64

### Gaussian Mixture Clustering ###
target  gmm_label
0       2            100
1       1            100
2       0            100
Name: gmm_label, dtype: int64


KMeans 의 경우 군집 1번만 정확히 매핑됐지만, 나머지 군집의 경우 target 값과 어긋나는 경우가 발생하고 있습니다. 하지만 GMM 의 경우는 군집이 target 값과 잘 매핑돼 있습니다.

이처럼 GMM 의 경우는 KMeans보다 유연하게 다양한 데이터 세트에 잘 적용될 수 있다는 장점 이 있습니다. 하지만 군집화를 위한 수행 시간이 오래 걸린다는 단점이 있습니다.

Read more