반응형

1년만에 큰 대회에서 한번 더 수상을 하게 되었습니다. 국가정보원, 국가보안기술연구소에서 주최하고, Dacon 에서 운영한 HAICon2021, 산업제어시스템 보안위협 탐지 AI 경진대회에서 4위와 특별상을 수상하게 되었습니다. 

4위!

데이콘 대회 바로가기:

 

HAICon2021 산업제어시스템 보안위협 탐지 AI 경진대회 - DACON

좋아요는 1분 내에 한 번만 클릭 할 수 있습니다.

dacon.io

 

시드 고정과 Threshold 설정하는 부분의 코드가 좀 부족해서 점수 재현이 잘 안되지 않을까 걱정되어 잠을 잘 못잤는데, 다행히 잘 봐주신 것 같습니다. 시상식을 갔다 왔는데, "4등에 특별상까지 타고, 정말 축하한다. 설명 문서 제출하는것도 제일 열심히 쓴 것 같고 정말 열심히 논문도 많이 본 것 같고, 관련 전공자도 아닌 뜬금없는 설비, 소방 학과 학생이 이렇게까지 해줘서 놀랐다. 열심히 참가해줘서 고맙다." 라는 엄청난 칭찬을 들었습니다. 몸둘 바를 모르겠습니다. 제가 더 감사합니다...

이번 학기는 학교에서 전공 과목들을 15주(16주) 수업을 12주로 압축하여 수업을 진행하는 3학년 2학기였는데요, 본전공 과목들, 복수전공 과목들, 졸업 필수 교양 과목들이 15주, 12주 수업이 섞여버렸고, 매주 나오는 과제, 팀플 일정과 중간고사 일정이 모두 겹쳐버리는 등의.... 힘든 학기였습니다. 공부하다 힘들어서 코피를 흘린다는게 다 거짓말인 줄 알았는데, 이번 학기 12주 수업이 진행되는 와중에만 코피를 7번을 흘렸네요. (세수하고보니 주르륵. 주르륵. 주르륵.) 그런 와중에 같이 진행하던 대회였어서 정말 힘들었습니다. 

하지만 12주 수업을 했던 과목들의 성적도 만족스럽게 나왔고, 큰 대회에서 좋은 성적을 거뒀고, 상을 한개도 아니고 두개! 나 타서 정말 좋습니다. 4위 수상으로 상금을 받고, 특별상으로는 M1 아이패드 프로(11인치) 를 받았는데, 아이패드 8세대를 쓰고 있던 저에게는 정말 만족스러웠습니다. True Tone 최고... ProMotion 최고... M1 짱짱...

 


 

1. 데이터 소개

 

HAICon2021 산업제어시스템 보안위협 탐지 AI 경진대회 - DACON

좋아요는 1분 내에 한 번만 클릭 할 수 있습니다.

dacon.io

데이터는 여러 파일로 나누어져 있지만 크게 보아 총 3개입니다. 학습용, 검증용, 그리고 테스트, 3개입니다. 데이터는 여러 가지의 센서, 액추에이터의 값인데, 이러한 센서값들을 이용해서, 해당 시스템이 공격을 받는지, 이상이 발생하였는지를 맞춰야 하는 문제입니다. 하지만 무턱대고 진행하면 되는 것이 아니고, 학습 데이터에는 '이상 여부' 가 없이 모두 '정상 상태'의 데이터셋만 존재합니다. 정상 상태의 데이터만을 가지고 학습을 진행하여, 시스템의 이상 여부를 판단해야 하는 문제입니다. 아무래도 이상 상태의 데이터는 정상 상태의 센서와 액추에이터 값이 다를 것이므로, AutoEncoder 를 통한 Anomaly Detection 을 진행하는 것 처럼, 데이터의 복원이 잘 안되면 이상 상태인 것으로 간주할 수 있고, 그 기준선을 정하는 것 역시 필요합니다. 

다음 중요한 특징은, 시계열 데이터라는 점입니다. 개인적으로 시계열을 매우 어려워하지만, 돌아보니 시계열에 대한 좋은 기억들이 많아 열심히 해봐야겠다 라고 다짐하였습니다. 무작정 표 데이터여서 MLP 를 만들고 시작하면, 시간 순서를 잘 고려하는 것 보다 낮은 성능을 보일 가능성이 매우 높습니다. 실제로 이 데이터로 한 첫번째 실험이 MLP AutoEncoder 였는데, 아주 엉망이었습니다. 그래서 바로 시계열 모델을 만들어야겠다고 생각했습니다. 

 

2. 모델링

최종적으로 사용한 모델은 MLP-Mixer 를 구현하여 사용하였습니다. 구현한 코드는 여기 에 있는 MLP Mixer 구현을 모델 저장하기 편하게, 사용하기 편하게 개인적으로 수정을 하였습니다. 

모델은 일단 '시계열'을 바로 다룰 수 있는 모델이어야 하기 때문에, 우선적으로는 RNN, 1D CNN, CNN+RNN, RNN+CNN, Transformer 들의 구조가 생각이 났습니다. Input 으로 time N 까지의 시계열 시퀀스를 받아, N+1 스텝의 센서 값을 예측하는 것이 목표였습니다. 아예 Seq2Seq 구조 처럼 출력 시퀀스를 뱉어내도록 만들어 볼까 하는 생각도 있었지만, 여러 실험을 하면서 코드를 새로 뜯어 고치기는 너무 바빴습니다. ㅠㅠ

첫번째 시도는, Dacon 에서 제공한 Baseline 코드 처럼 Bidirectional RNN 모델입니다. GRU와 LSTM 모두 실험 해 보았는데, 결과는 영 좋지 않았습니다. 검증 데이터셋에서는 아주 낮은 점수를 보였고, 그에 비해서 테스트 셋에서는 상대적으로 높은 점수를 보였습니다. 여기서 검증데이터와 테스트 데이터가 꽤 차이가 있을 수 있겠구나 라고 생각했습니다. 

두번째 시도는 CNN을 같이 이용한 모델을 시험해 보았습니다. 1D CNN만을 이용하여 만든 VGG style 의 모델은 전혀 학습을 하지 못했고, RNN과 CNN이 결합된 모델 역시 마찬가지였습니다. 

그 다음 시도한 것은 Transformer 모델입니다. Transformer 는 인코더만 만들어서 사용하였습니다. 예측하고자 하는 Timestep이 1개였기에 굳이 디코더까지 만들지 않아도 될 것이라 생각하였습니다. 그렇게 트랜스포머 인코더를 간단히 구현해서 예측을 하였는데, 점수가 눈에 띄게 상승한 것을 확인했습니다. RNN, CNN과 Transformer 가 다른 가장 큰 포인트, '전체 시퀀스를 한번에 볼 수 있느냐' 의 차이가 큰 효과가 있었다고 생각했고, Transformer 모델을 만들기 시작했었습니다. 하지만 Transformer 모델은 입력 시퀀스 길이를 길게 하여 실험을 하다 보니까, 시간이 너무 오래 걸렸고, 제 컴퓨터로 하기에는 답답해졌습니다. 

그래서 최종 결정한 모델이 MLP-Mixer 입니다. MLP Mixer 는 Transformer 처럼 한번에 전체 시퀀스를 다룰 수 있지만, 더 가볍고, 빠르고, VRAM이 모자란 제 컴퓨터에서 더 잘 돌아갈 것 같았고, MultiHeadAttention 과정보다 'Function Approximator' 에 가깝다고 볼 수 있다고 생각했습니다.  이러한 시계열 문제에서 Self Attention이 작동하는 과정은 (수학적으로 정확한 설명은 아니지만), Sequence 내의 각  Timestep간의 연관성, 중요도를 계산하는 것이라 생각했습니다. 하지만 MLP Mixer 는 각 Timestep 간의 연관성, 혹은 중요도, 관계를 파악하는 것이 아니라 함수로 표현하는 것이라 생각해서, 이것도 충분히 가능성 있는 모델이라고 생각했습니다. 제가 이해하고 있는 두 모델의 차이를 키노트로 그려 보았는데, 아래 그림과 같습니다. 

Sequence in Self Attention

 

Sequence in Mixer

 

하지만 MLP-Mixer 를 구현하여 학습을 진행해보니, 너무 빠르게 Overfit 되는 현상이 있었습니다. 그래서 MLP Mixer 에다가 Sharpenss Aware Minimization 을 추가해서 학습하기로 생각했습니다. 이 논문(When Vision Transformers Outperform ResNets without Pre-training or Strong Data Augmentations) 에서 보면, ViT와 Mixer 의 학습을 SAM 을 이용해서 도울 수 있다고 나와있습니다. 그래서 Mixer 와 SAM을 같이 써서 Overfitting 을 줄이고, Transformer + SAM 모델보다 더 빠르게 많은 모델을 만들어서 결과물을 앙상블 하는 것이 더 효율적으로 좋은 결과를 낼 것이라 생각하였습니다. 하지만 가장 큰 이유는, MLP-Mixer 논문 마지막 부분에, '다른 도메인에서도 잘 먹히는지 봐도 좋을 것 같다' 고 쓰여있어서 시도해본 것이 결정적 이유였습니다. 

MLP-Mixer 를 구현하고 나서, 어느 정도 성능이 잘 나오는 것을 확인하였습니다. 1D CNN, RNN보다 Transformer 와 Mixer 의 성능이 높게 나오는 것을 보아, 전체 Sequence 를 한번에 보고 처리하는 모델이 더 잘 작동하고 있고, 두 모델 모두 컴퓨터에서 돌아가는 선에서는 성능이 유사하게 나온다면, 복잡한 모델보다 단순한 모델이 낫다는 생각으로 MLP-Mixer 를 여러 타임스텝에 맞춰 Scale Up, Scale Down 한 16개의 모델을 만들고, 그 앙상블 모델을 최종 모델로 선택했습니다. MLP AutoEncoder 를 만들 때, 무작정 층 수를 늘리거나 뉴런 수를 많게 한다고 반드시 좋은 모델이 되지 않는다는 생각과 맥락을 같이 합니다. 

모델만큼 성능에 중요한 영향을 끼친 부분은 Threshold 를 정하는 일이었습니다. Threshold 는 optuna 를 사용하여 2000회의 반복을 통해서 결정하였습니다. 무작정 RandomSearch 를 하는 것 보다는 Bayesian Optimization 을 하는 것이 좋다고 판단했고, 평소 하이퍼파라미터 튜닝에 optuna 를 많이 사용해서 익숙한 함수를 만들듯이 적용할 수 있었습니다. 먼저 16개 예측값에 대하여, 이동 평균을 이용해 예측 결과물을 smoothing 시키고, threshold 를 결정해 [0, 1, 0, 0, ...] 과 같은 예측 결과물을 만들었습니다. 적용할 이동평균 값과 threshold 를 아래 그림과 같이 반복을 통하여 구했습니다. 

Decide MA, Threshold

다음으로는 16개 예측 결과를 조합하는 과정 역시 optuna 를 이용해서 만들었습니다. 0과 1로 이루어진 예측 결과물들을 softmax 함수를 거친 weight vector 를 통해 Soft Voting 하도록 하였습니다. 

Soft Voting Ensemble of 16 predictions

이러한 파이프라인을 만들었는데, 이 과정에서 가장 큰 문제는 검증 데이터에 대한 Overfitting 이 매우 잘 일어난다는 점이었습니다. 2020년 대회에 적용했을 때는, 먼저 예측값을 만들고 Voting 하는 것이 그닥 좋은 결과를 내지 못했는데, 2021년 대회에는 잘 적용되는 것을 확인했습니다. 제가 내년 대회에도 참여하게 될지는 잘 모르겠는데, 이 부분은 실험을 통해서 대회마다 다르게 적용되어야 할 것 같습니다. 

 

3. 결론, 느낀점

검증데이터셋의 활용이 정말 힘들었던 대회였습니다. 학습에는 절대 사용하면 안되지만, Early Stopping 을 걸거나, 검증 데이터셋 일부를 학습 데이터셋에 포함시켜서 Scaling 하는 것은 허용되었고, 검증 데이터셋 점수와 테스트 데이터 점수가 일관성이 별로 없게 나와서 혼란이 많았던 대회라고 생각합니다. 

2020년에도 HAICon이 진행되었는데, 1년 전에 저는 이 과정을 이해를 못하고, 1D CNN으로 만든 모델 3번 만들어보고, 대회가 이해가 되지 않아 포기했었던 기억이 납니다. 비슷한 시기에 올해도 비슷한 대회가 진행되어서, 1년 사이에 제가 조금은 성장했구나 라는 생각이 들어서 기뻤고, 좋은 결과까지 얻어서 더욱 기쁩니다. Public LB에서 7위였기 때문에 3등 안으로 들어가는 것은 상상도 하지 못했었고, Private LB도 뚜껑을 열어 보니 3등팀과의 점수 차이는 어마어마해서, 4등에 매우 만족하고있습니다. 더 열심히, 정진하도록 하겠습니다. 

반응형
Posted by Jamm_
개발 환경 구축2021. 6. 26. 21:04
반응형

최근에 버전을 업데이트하고, 학습을 시작하다가 갑자기 멈추는 등, 문제가 많았는데, 어떻게든 초기화하지 않고 해결하려고 했다가 결국 다시 컴퓨터를 초기화 했습니다. 

2021년 6월 26일 설치 완료했고, 다시 학습을 하니까 잘 되네요. 

다음을 위해 다시 기록 남겨둡니다..

[21.06.26] - TensorFlow 2.5.0 / CUDA 11.2 / cuDNN 8.1.0 / PyTorch 1.9.0


 

1. 윈도우 설치 (Pass)

2. 파이썬 (3.7.9) 설치

다른 버전의 파이썬에서 시도해보지는 않았지만, 텐서플로우(현재 최신 2.5.0) 공식 문서에 따르면, 파이썬 3.5 ~ 3.8을 지원한다고 한다. 

중간 버전인 파이썬 3.7.9 버전을 설치해준다.

 

Python Release Python 3.7.9

The official home of the Python Programming Language

www.python.org

Windows x86-64 executable installer 를 설치하면 된다. 

 

3. Visual Studio 2019 Community 다운로드

무료 버전인 Community 버전을 다운받고 실행하면 된다. 

 

Visual Studio 2019 | 무료 다운로드

Visual Studio에서 코드 완성, 디버깅, 테스트, Git 관리, 클라우드 배포를 사용하여 코드를 작성합니다. 지금 무료로 커뮤니티를 다운로드하세요.

visualstudio.microsoft.com

실행한 이후에 설치 창이 뜨면, "C++을 이용한 데스크톱 개발" 하나만 체크하고 설치하면 된다. 

 

4. Nvidia 드라이버 삭제 후 최신으로 재설치

제어판 - 프로그램 추가 / 제거 에서 "NVIDIA" 라고 검색하여 나오는 모든 프로그램들을 삭제한다. 

그리고 GPU에 맞는 최신 그래픽 드라이버를 설치해준다. 

 

NVIDIA 드라이버 다운로드

 

www.nvidia.co.kr

GeForce Experience 는 필요가 없고, NVIDIA STUDIO 드라이버만 설치해주면 된다. 

 

5. CUDA 11.2 설치 (드라이버 빼고)

CUDA를 다운로드하여 설치해준다. 

 

CUDA Toolkit 11.2 Downloads

Select Target Platform Click on the green buttons that describe your target platform. Only supported platforms will be shown. By downloading and using the software, you agree to fully comply with the terms and conditions of the CUDA EULA. Operating System

developer.nvidia.com

exe (network) 는 설치 프로그램 안에서 바로 다운로드 받으며 설치하는 건데, 전에 할때 되지 않은 경험이 있어서 exe (local) 로 받아주었다. 

다운받고, 설치를 진행할 때에 '빠른 설치 (권장)' 같은 메시지가 뜨는데, 이걸 선택하면 드라이버가 더 낮은 버전으로 새로 깔리게 된다.

드라이버를 유지하기 위해 '사용자 정의 설치 (고급)' 을 클릭하고, 'Driver components' 를 제거하고 설치한다. 'GeForce Experience' 역시 필요 없으므로 체크를 해제하고 설치를 진행하였다. 

다 필요 없어!

 

6. cuDNN (8.1.0) 설치

cuDNN은 NVDIA 에 로그인을 해야 다운로드 받을 수 있다. 

https://developer.nvidia.com/rdp/cudnn-download

Archived cuDNN Releases 를 눌러 구 버전의 cuDNN을 찾는다. 

맨 아래에 있는 'Download cuDNN v8.1.0 (어쩌구...) for CUDA 11.0, 11.1, 11.2' 를 다운로드 받는다. 

다운로드를 받으면 압축 파일을 하나 받게 되는데, 압축 파일 내의 'cuda' 폴더를 복사한다. 

아래 사진처럼 C:\ 위치에 'tools' 라는 이름의 폴더를 하나 만들고, 그 안에 복사한 cuda 폴더를 붙여넣는다. 

tools 폴더 만들고
그 안에 cuda 폴더 붙여넣기

 

7. 환경 변수 설정

윈도우 검색창에 '환경 변수' 라고 검색한다. 

아래에 있는 환경 변수 클릭
아래쪽에 있는 시스템 변수 에서 'Path'를 클릭하고 편집 클릭
이 창에서 '새로 만들기' 를 클릭

다음 세 개의 경로를 새로 추가해주면 된다. 

C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v11.2\extras\CUPTI\libx64

C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v11.2\include

C:\tools\cuda\bin

맨 위에 있는 비슷하게 생긴 경로는 건드리지 말고 추가만 해주면 된다. 

 

9. CMake, Boost Binaries 설치

Boost binaries :

https://sourceforge.net/projects/boost/files/boost-binaries/1.71.0/boost_1_71_0-msvc-14.2-64.exe/download?use_mirror=jaist&use_mirror=jaist&r 

CMake : 

맨 아래에 있는 cmake-3.21.0-rc1-windows-x86_64.msi 다운로드

LightGBM을 위해 설치를 해준다. 

CMake 역시 다운로드를 하고 실행을 한다. 설치가 끝나면 'Add CMake to PATH' 라고 나오는데, 체크를 해 준다. 

 

10. 컴퓨터 재부팅 (pass)

11. pip 를 통해 패키지 설치 (pass)

 

글로벌 환경이던, 파이썬 가상 환경이던 'pip install 뭐시기' 를 통해서 패키지들을 설치해준다. 

pip3 install sklearn pandas seaborn matplotlib jupyter tensorflow xgboost catboost tqdm numpy==1.19.5 opencv-python optuna torch==1.9.0+cu111 torchvision==0.10.0+cu111 torchaudio===0.9.0 -f https://download.pytorch.org/whl/torch_stable.html 

numpy 최신 버전은 Tensorflow 2.5.0 과 에러가 나는 것을 확인했다. 

pip3 install  lightgbm --install-option=--gpu

lightgbm gpu 버전 설치

텐서플로우 버전은 2.5.0 (현재최신), 파이토치 버전은 (1.9.0) 입니다. 

반응형
Posted by Jamm_
반응형

첫번째 Playground 클리어

기말고사가 끝나고 방학때부터는 새로운 취미?로 iOS를 해보고 싶었다. 

일단 나는 엄청난 앱등이이고, 아이폰, 맥북(M1 최고 진짜...), 아이패드, 애플워치, Beats를 다 쓰고있고, 맥북을 쓰고 있기 때문에 앱개발 하면 당연히 iOS를 하겠다고 생각하고 있었고, 안드로이드를 매우 극혐하며 관심도 별로 없고(열등한 안드로이드ㅡㅡ), 결정적으로 안드로이드를 만들면, 내가 만들고 나는 못쓰는 사단이 나기 때문에 그냥 고민안하고 iOS 맛을 보기로 했다. 

내가 만든 모델을 단지 '대회용' 으로만 쓰는것은 공부하는 의미가 줄어들기도 하고, 신기술이 있으면 그것을 써먹어야만 의미가 있기 때문에 최종 목표는 내가 만든 모델들을 배포하는 하나의 수단으로 사용하고 싶다. 

하지만 대회 프로세스에 필요한 파이썬만 할줄 아는 나는 진짜 '퓨어(?) 개발'에 있어서는 아주 일자무식이었고, 알고리즘도 모르고, 그냥 바로 뚝딱뚝딱 알고리즘을 써낼만큼 머리도 좋지 않은 소시민인 나에게는 아주 험난한 길이 되지 않을까... 생각이 들지만 역시 취미는 돈을 쓰는 만큼 재미있는 법이니, 열심히 돈을 쓰기로 했다... 나중에 어딘가에는 쓸모가 있겠지... 

해보면서 느낀점은 스위프트도 (아직까지는) 매우 쉽다. 자꾸 눈보다 머리보다 손이 먼저 반응해서 파이썬 문법을 치고 당황하기를 몇 번 반복하니, 이제 어느정도 기초 문법의 감은 잡은 것 같다. 

Swift Playground를 통해서 일단 기본 문법 쓰는것부터 익히기로 했다. 그래서 이 글은... 그냥 "나 플레이그라운드 1 다했다!" 가 사실상 내용의 전부라, 그냥 파이썬과 비교하며 문법을 적어만 보고, 기억하는 용도로 글을 작성해야겠다... 몇월 며칠부터 이걸 시작했다는 기록용도...?

옛날에 학교다닐때 스크래치 했던 생각도 나고, 아주 게임스럽게 만들어져 있어서 재미있게 시작할 수 있었다. 매번 이런식이지 여기까지는 쉽다가 좀더 하다보면 벽에 막혀서 허우적거리는...

내 귀여운 물방울 캐릭터로 보석을 먹고, 스위치를 다 누르면 클리어된다. 


 

1. 함수

파이썬에서는

def Hello():
    print('Hello World!')

def 로 함수를 정의하는데, 

func moveForward(){
    go_and_go()
}

스위프트에서는 func으로 할 수 있고, 들여쓰기가 아니라 중괄호를 사용해 함수 내용을 안에다 써주면 된다. 이 중괄호를 쓰는것이 1차 충격이었다. 파이썬의 부작용. 중괄호를 쓰자니 소괄호를 빼먹고, 소괄호를 쓰면 자동으로 손은 콜론( : )에 가 있는...

아직 Playground1 에서는 파라미터를 넘겨주는건 안나왔는데, 저 괄호 ( ) 에다가 파라미터를 넘겨주면 되려나....

 

2. for Loop

다음으로 익힌게 for loop 였는데, 그냥 이게 끝이다. 

for year in 1 ... 5{
    letsLearnSomething()
}

습관적으로 파이썬에서 ' range(5): ' 라고 적듯이 하다가 안돼가지고 당황했었다. 역시 콜론과 중괄호 적는건 자꾸 헷갈리고...

 

3. 조건문

if myNameJamm{
    print("Jamm")
} else if myNameUnknown{
    print("Unknown")
} else {
    print("NoJamm")
}

이렇게 if의 중괄호에다가 내용을 적고, else 에다가도 내용을 적어주면 된다. 파이썬의 elif 는 else 뒤에 if를 바로 붙여서 하나 더 쓰면 된다. 파이썬이랑 달라서 그런가, 조건문을 겹겹이 만들면 내가 보기에도 너무 헷갈려보였다. 파이썬의 들여쓰기에 너무 적응해버린 탓인가... 가장 충격받았던 점은 작은따옴표로 문자열을 쓸 수가 없다는거...? 큰따옴표로 자동으로 고쳐주려고 한다. 나 맨날 작은따옴표만 썼었는데... 이건 정말 적응안된다...

 

4. 논리 연산자

and 는 ' && ' 로 표시하고, or 는 ' || ' 로 쓰면 된다. 

조건을 바로 뒤집을 수 있는데, myNameJamm 이 true라면, !myNameJamm 은 false 가 된다. 파이썬에서는 a is not b 이런식으로 작성했던 것 같은데, 이런 것들을 생각해보니 파이썬은 정말로 그냥 영어였다는 것을 알았다. 이걸 뒤집으면서, 안뒤집으면서 조건문 안에 반복문을 몇 겹으로 넣어 보았는데, 매우 헷갈렸다. 

 

5. while Loop

while myNameJamm{
    studyNewThing()
}

그냥 이런식으로 적으면 된다. 파이썬과 중괄호 말고는 똑같은...

 


 

적고 보니까 정말 보잘것 없다는 생각이 든다. 엊그제 시작해서 배운 내용이니 아직까지는 그럴 수 있지... 파이썬 말고 처음으로 다른 언어를 해본거라서 종종 헷갈리기는 하는데, 이정도면 그래도 엄청 쉬운 언어 같다. 언젠가는 좀 더 도움이 되는 고급 정보를 담은 글을 쓸 수 있기를 바라며... 플레이그라운드2 나 클리어하러 가야겠다. 게임중독자

반응형
Posted by Jamm_
반응형

 

 

cuijamm/CompetitionReview

Review of Competitions. My solutions, winner's code, or trials with new algorithms are uploaded. - cuijamm/CompetitionReview

github.com

 

 

운동 동작 분류 AI 경진대회

출처 : DACON - Data Science Competition

dacon.io

Github Repository & Dacon Codshare Post. 

 


 

오랜만의 대회 관련 포스팅입니다. 

2020년은 개인적으로 최고의 상승장이었지만, 2021년에는 다시 하락장이 시작되고있네요. 하락장 와중에 있었던 반등 같은 대회였습니다. 

월간 데이콘 13으로 진행되었던 운동 동작 분류 AI 경진대회에서 최종 3위를 기록하게 되었습니다. 무야호~

 

그만큼 신나시다는 거지!

 

전체 파이프라인 코드는 깃헙과 데이콘 코드공유 (맨 위의 링크 두개)에 올려져 있으니, 코드 자체를 블로그에 다시 적는건 의미가 없을 것 같고, 대회 중에 들었던 생각들과 과정들만 정리해보도록 하겠습니다. 

 


 

1. 데이터

총 600개의 timestep 을 가진 시계열 센서 데이터가 주어졌습니다. 해당 센서는 오른쪽 팔에 자이로스코프, 가속도계가 달린 센서를 착용하고, 특정 운동 동작을 수행했을 때, 그 동작이 61개 동작 중에서 어떤 class 에 해당하는지를 맞추는 Classification 문제였습니다. 데이터는 csv 파일로 주어지지만, 시계열 데이터에 맞춰 numpy array 로 reshape 하면 총 3125개의 센서 값이 기록되어 있음을 알 수 있습니다. 데이터가 아주 많지는 않네요. (Original Shape : (3125, 600, 6))

때마침 애플워치를 구입한지 얼마 되지 않았던 시기였기 때문에, 워치를 생각하며 애플워치를 착용하고 운동을 하는구나 라고 생각하고 대회에 재밌게 참여할 수 있었습니다. 

 

1.1. 라벨 불균형

대회 초반에 모델을 무작정 만들고 있을때도 어느 정도의 점수는 나왔었지만, 특정 점수 이상으로 잘 올라가지 않는 느낌을 받았습니다. 그래서 혹시나 해서 타겟변수를 살펴보니

 

총 학습데이터 3125개 중 절반이 26번, 나머지 절반 데이터를 60개 동작이 나눠먹고 있는 모습

 

상당히 imbalance 가 심한 것을 확인했습니다. 3000여 개의 데이터중에서 한 클래스의 갯수가 12개라니... 이거 너무한거 아니냐고? 응아니야

점수를 더 올리려면 이걸 해결해야겠다고 생각했습니다. 

 

1.2. Feature Engineering

feature_names = ['acc_x','acc_y','acc_z','gy_x','gy_y','gy_z']

grad_cols=[]
for col in feature_names:
    grad_cols.append(f"grad_{col}")

integ_cols = []
for col in feature_names:
    integ_cols.append(f"integ_{col}")
    
#position_cols = ['pos_x','pos_y','pos_z']
    
total_feature_names = feature_names + grad_cols + integ_cols #+ position_cols

고등학교때 수학시간에 들었던 말이 생각났습니다. 미적분 문제에 접근하는 것을 유독 힘들어했었는데, 선생님께서 '일단 속도가 보인다? 미분 할 생각부터 해라. 가속도를 구해야 풀리는 문제들이다' 이런 뉘앙스의 말을 하셨습니다. 주어진 데이터는 가속도 x, y, z 와 각속도 x, y, z 이므로 이들을 미분해서 가가속도, 각가속도를 만들고, 적분도 해서 속도, 각도 변수도 만들었습니다. 이렇게 적분했던 속도를 한번 더 적분하여 변위를 만들어서 사용했었는데, 이렇게 연속으로 적분을 하니까 오차가 점점 누적되어서 그런가, 의미가 없는 결과값을 얻었습니다. 

예전에 캐글의 Ion Switching 대회에서도 이렇게 gradient 를 만들어서 접근을 했던게 생각났습니다. 그때는 lag feature, delta features, moving average features 역시 만들어서 추가했었는데, 대회 중에는 생각이 안나서 시도해보지 못했던 것이 아쉽습니다. 

이렇게 해서 사용한 변수는 총 6 * 3 = 18개의 변수를 사용하였습니다. 

 


 

2. 모델

 

2.1. Augmentation

이번 대회에서 가장 아쉬움이 남았던 부분입니다. 1위 솔루션을 보았는데 정말 여러가지 Augmentation 기법들을 시도하고 사용해 보셨더라고요. 심지어 라벨에서 'left arm', 'right arm' 이라고 쓰여진 부분도 있었는데, 전부 다 오른팔에 착용했다고 생각하고 다른 augmentation 을 생각조차 하지 않았다는 점이 좀 아쉬웠습니다. 

처음에는 도저히 감이 잡히지 않았지만, Dobby님 께서 올려주신 코드 공유를 보고, 이런 방식으로 접근하면 되겠다고 생각했습니다. 

 

운동 동작 분류 AI 경진대회

출처 : DACON - Data Science Competition

dacon.io

numpy의 roll 을 이용하여 augmentation을 하면, 머릿속으로 동영상을 만들어 봤을 때 해당 센서 데이터가 Loop 처럼 반복된다고 볼 수 있다고 생각이 들었습니다. 킹도비 아이디어 갓... 직접적으로 저 코드처럼 구현을 하지는 않았지만, tf.roll 을 사용하여 커스텀 레이어를 만들어서, 학습시에는 랜덤한 값으로 Augmentation 을 수행하고, test 시에는 적용되지 않도록 구현하였습니다. 

# 모델의 인풋 바로 다음에 랜덤한 값으로 Rolling 을 하는 커스텀 레이어. 
class Rolling(Layer):
    def __init__(self, roll_max=599, roll_min=0):
        super(Rolling, self).__init__()
        self.random_roll = random.randint(roll_min, roll_max)   
        
    #def build(self, input_shape):  # Create the state of the layer (weights)
    #    pass
    
    def call(self, inputs, training=None):# Defines the computation from inputs to outputs
        if training:
            return tf.roll(inputs, shift=self.random_roll, axis=1)
        else:
            return inputs
        
    def get_config(self):
        return {'random_roll': self.random_roll}

 

2.2. Minority Oversampling

# 데이터를 하나하나마다 다른 Rolling 과 다른 노이즈를 추가하여 오버샘플링 하는 용도의 함수
def aug_data(w, noise=True, roll_max=550, roll_min=50, noise_std=0.02):
    assert w.ndim == 3
    auged=[]

    for i in range(w.shape[0]):
        roll_amount = np.random.randint(roll_min, roll_max)
        data = np.roll(w[i:i+1], shift=roll_amount, axis=1)
        if noise:
            gaussian_noise = np.random.normal(0, noise_std, data.shape)
            data += gaussian_noise
        auged.append(data)
    
    auged = np.concatenate(auged)
    return auged

위에서 확인했듯, Imbalance 가 매우 심합니다. 3125개중에 12개를 정확히 맞추는 것은 아무리 생각해 보아도 선을 넘은 것 같습니다. 그래서 Oversampling을 해 주었습니다. 

학습을 Stratified 10 Fold CV 를 하였는데, 매 Fold 마다 train과 valid를 쪼갠 이후, train데이터의 26번(Non-Exercise)항목이 아닌 데이터들만 뽑아서 위 함수를 이용하여 적용시켜 주었습니다. 원본 데이터를 그대로 복사하는것은 아니고, 데이터 전체가 아니라 각각의 데이터마다 랜덤하게 roll을 해주고, 약간의 가우시안 노이즈를 추가하여 train 데이터에 concat 하였습니다. 1번 정도만 적용하니 성능이 향상되었고, 2번 이상부터는 overfit이 쉽게 일어나는 것 같았습니다. 

 

2.3. Modeling

모델 구조는 여러 가지를 생각해 보았는데, 

  • Conv1D 이후 Dense (VGG-like)
  • RNN (LSTM / GRU) 이후 Dense (Stacked LSTM)
  • RNN 과 Conv1D 를 섞어서 Skip Connection을 골고루 넣는 (떡칠하는) 모델
  • RNN Path 와 Conv1D Path 를 따로 두고 Concat하여 Timestep 과 Local feature들을 동시에 고려하는 모델

들이 생각이 났었는데, 최종 모델로 선택한 것은 1번 이었습니다. 레이어를 아무리 넣고 빼고 자시고를 반복해도 RNN계열 층이 섞여있을 때는 성능이 생각보다 잘 나오지 않았습니다. 개인적으로 시계열 문제를 굉장히 싫어하는데, (잘하고싶은데, 잘 안돼요..) 아직까지는 한번도 RNN 계열 층을 써서 CNN보다 잘 나오는 경우를 못겪어봤습니다...

 

# Convolution, Dense 레이어 여러번 적기 번거로워서 만든 함수
def ConvBlock3(w, kernel_size, filter_size, activation):
    x_res = Conv1D(filter_size, kernel_size, kernel_initializer='he_uniform', padding='same')(w)
    x = BatchNormalization()(x_res)
    x = Activation(activation)(x)
    x = Conv1D(filter_size, kernel_size, kernel_initializer='he_uniform', padding='same')(x)
    x = BatchNormalization()(x)
    x = Activation(activation)(x)
    x = Conv1D(filter_size, kernel_size, kernel_initializer='he_uniform', padding='same')(x)
    x = Add()([x, x_res])
    x = BatchNormalization()(x)
    x = Activation(activation)(x)
    return x
    
def DenseBNAct(w, dense_units, activation):
    x = Dense(dense_units, kernel_initializer='he_uniform')(w)
    x = BatchNormalization()(x)
    x = Activation(activation)(x)
    return x



def build_fn(lr = 0.001):
    activation='elu'
    kernel_size=9
    
    
    model_in = Input(shape=Xtrain_scaled.shape[1:])
    x = Rolling(roll_max=599, roll_min=0)(model_in)
    x = SpatialDropout1D(0.1)(x)
    
    x = ConvBlock3(x, kernel_size=kernel_size, filter_size=128, activation=activation)
    x = MaxPooling1D(3)(x)
    x = SpatialDropout1D(0.1)(x)
    
    x = ConvBlock3(x, kernel_size=kernel_size, filter_size=128, activation=activation)
    x = GlobalAveragePooling1D()(x)
    
    x = DenseBNAct(x, dense_units=64, activation=activation)
    x = Dropout(0.4)(x)
    
    
    model_out = Dense(units=61, activation='softmax')(x)
    model = Model(model_in, model_out)
    model.compile(loss='sparse_categorical_crossentropy', optimizer=Nadam(learning_rate=lr), metrics='accuracy')
    
    return model


build_fn().summary()

VGG 스타일의 심플한 Conv1D 모델입니다. Conv1D는 커널사이즈를 꽤나 크게 잡아도 파라미터 수가 엄청 뻥튀기 되지 않고, 오히려 충분한 커널사이즈가 있어야 Timeseries 의 컨텍스트를 잡아낼 수 있을거라 생각해서 커널 사이즈를 흔히 Conv2D에서 사용하는 3이 아니라 9로 정했습니다. 

이후 학습은 Stratified 10Fold CV를 사용하여 10개 모델의 평균을 내어 제출하였습니다. 

 


 

3. 기타 다른 아이디어

  • 캐글의 ion switching 대회에서 나왔던 Kalman Filter 를 이용한 noise smoothing - 데이터가 상당히 깔끔하게 잘 나와있었어서 굳이 할 필요가 없었다고 생각이 듭니다. 
  • 데이터들의 statistics 들을 통한 aggregation, 및 Tree 기반 모델 접근 - 대회 초반에 가만히 생각해 보았지만, '굳이 데이터를 요약?까지 해야 하나? Conv1D나 LSTM, GRU 쓰면 바로도 충분히 접근할 수 있을 것 같은데.' 라는 생각에 시도해보지는 않았습니다. 
  • Stacking(meta-modeling) - 스태킹을 할때 test 셋을 bagging 해서 만들면 oof로 만들어진 meta training set과 bagging으로 만들어진 meta test set이 차이가 나서 그런가, 점수가 잘 오르지 않는 모습을 예전부터 보고 있었습니다. 스태킹 잘하시는 분들 혹시 이 글을 보신다면... 꿀팁 알려주시면 감사하겠습니다.  개인적으로 앞으로도 평균 앙상블은 정말 많이 사용할 것 같은데, 스태킹은 거의 안하게 될 것 같습니다. 좀 많이 양보하면.. 단순평균 아니라 가중평균정도...?

 


 

4. 결론 및 아쉬운 점

다른 대회에서도 저는 Augmentation을 잘 안하는 편인데, 역시나 이번에도 마찬가지였습니다. 항상 적절한 augmentation 방법을 찾아 적용하는데 실패해서 매번 버리는 경우가 많았는데, 이 대회에서는 Augmentation 에 더 노력을 덜 기울였던 점이 끝나고 보니까 아쉬움으로 남는 것 같습니다. 충분한 Augmentation으로 성능이 잘 나오는 데이터였는데, 위에 생각했던 것들을 하나씩 하고 나니까 리더보드 수상권으로 들어오기도 했고, 너무 안일하게 슬슬 마무리 짓자 라는 생각을 했던 것 같습니다. 기회가 된다면 다른 유저분들이 사용했던 Augmentation 방법론들을 또 추가해보고, (특히 왼손 오른손 Augmentation이 제일 인상깊었습니다...) 한번 더 해보고 싶은 대회네요. 데이터도 작아서 데스크탑 정도로 부담 없이 재밌게 진행할 수 있었고, CV-LB 점수가 상당히 정직하게 나와서 접근하기 좋았던 대회였던 것 같습니다. 

반응형
Posted by Jamm_
개인 프로젝트/MySQL2021. 1. 28. 16:30
반응형

단순히 대회만 해가지고는 혼자서 할 수 있는게 제한되는 것 같았다. 21년이 되고 나서 하고있는 데이콘 대회들은 전부다 죽쑤고있고, 갑자기 우승뽕맛, 상금뽕맛에 취해서 열심히 안한것도 맞지만,  내가 그렇게 아주 잘하고 있는것 같지도 않고, '돌아가는 무언가' 를 만들려면 단지 대회만 잘해서 될게 아니라는 생각을 했다. 

그래서 데이터베이스와 웹 백엔드 맛도 좀 보기로 결심했다. 일단 국민DB까지는 아닌가?라고 불리는 MySQL 을 좀 해보고, 마침 주변에 프론트 열심히 하고있는 친구가 있어서, 파이썬밖에 할줄 모르는 나는 일단 플라스크로 백엔드 짜는걸 맛을 좀 보기로 결심했다...

M1 맥북에어를 사용하고 있는데, MySQL 은 최신버전을 사용해도 잘 되고 있고, MySQL Workbench 는 8.0.21 버젼을 받아야만 잘 실행된다. 무턱되고 워크벤치 최신버전을 설치했는데 안돼서 욕 엄청했는데...

 

Jamm... 여기서 멈추면 넌 작은 그릇이다... 그 뽕맛 언제까지 혓바닥에 남아있을 것 같냐... "왕년에 내가말야~어!! 막!! 어!! 대회도 하고!!! 우승도 하고!!! 상금도 타고!!! 했어임마~~" 라는 말만 되풀이 하는 꼰대가 되지 않으려면 더 정진하자...

힘든 삶이다...

각설하고.... 다음 글부터 또 천천히 하나씩 적어보자...

반응형
Posted by Jamm_
반응형

기말고사가 드디어 끝났습니다. 얼마 전 시험 시작 전에 한국수력원자력에서 진행했던 데이콘 대회의 최종 순위가 발표되었고, 정말 운이 좋게도 우승이라는 좋은 결과를 얻을 수 있었습니다. 개인적으로는 친구들이 양주사라, 코로나만 잠잠해지면 회식 가자 하는 중이고.. 양가 친척 모두에게 소문이 나서 '아이고 장하다 고놈' 그런 이야기를 듣고 있었습니다. 신문 기사에서 너무 대문짝만하게 나오기도 해서 너무 부끄럽고, 학교 홈페이지에도 올라가서 수업듣는 교수님이 '너가 그 우승한 걔 맞냐' 물어보시기도 했습니다... 아싸는 부담스러워요...

지금까지 공부하면서 얻었던 가장 좋은 성적이었기에 얼른 공유를 하고 싶었는데, 코드의 저작권과 데이콘과 작성했던 양수양도 계약서, 상금 지급 여부 등의 문제 때문에 바로 올리지는 못하고 약간 뒷북이 되긴 하였지만 이제서야 올릴 수 있게 되었습니다. 개인적으로 너무 힘들었던 대회였고, 마지막까지 예측이 안되던 대회였어서 얼떨떨하고 합니다...

 

공공데이터 활용 수력 댐 강우예측 AI 경진대회

출처 : DACON - Data Science Competition

dacon.io

입상자 코드가 공개된다고 하니, 저는 코드를 직접 쓰지는 않고 생각만 적어보겠습니다. 

사실 코드를 안올리는 진짜 이유는...코드 내에 오타가 있었어서...아래에 썼던 '생각했던 모델' 과 연결구조가 약간 다릅니다... 층을 3개 쓰려고 했는데 두개만 들어갔다던가... 이걸 대회 끝날때까지 모르고 있다가, 코드 제출하면서 찾았다니 어이가 없을 뿐입니다...

 

1. 데이터 소개

데이터셋에 대해서 이야기를 하자면, 학습데이터로는 (120, 120) 의 레이더 사진이 4장, 타겟 데이터로는 (120, 120)의 한장이 주어졌습니다. 샘플의 수는 약 6만여개가 주어졌고, npy 파일로 데이터를 받을 수 있습니다. 4장의 사진은 (30분 전, 20분 전, 10분 전, 현재) 의 4장의 사진을 가지고 10분 후의 사진 1장을 예측하는것이 목표입니다. 이렇게 레이더 사진을 예측하여 제출하면 레이더 사진을 강수량으로 변환하여, 강수 여부(정확도, 정확히는 CSI)와 강수량(MAE) 를 계산하여 점수를 얻게 됩니다. 

이미지의 시계열 데이터라고 생각을 했습니다. '이미지의 시계열이면 동영상 아니야?' 하는 생각에, 대회 초반에는 케라스의 ConvLSTM2D 층을 잔뜩 쌓은 모델을 만들려고 했습니다. 또 어떻게 보면 구름이 있어야만 비가 올 수 있으니, 미래 이미지에 대한 Segmentation으로도 접근할 수 있겠다고 생각하고 있었습니다. (CSI만 생각하면 어느정도 비슷한 접근이라고 생각합니다.) 하지만 ConvLSTM2D의 결과는 점수상으로 엉망이었고, 아 이게 아닌가 어떻게 하지 하며 고민하는 와중에 데이콘에서 Baseline 코드가 올라왔고, UNet이라는 것을 알게 되었습니다. 

UNet 의 구조 (논문 내 사진)

 

U-Net: Convolutional Networks for Biomedical Image Segmentation

There is large consent that successful training of deep networks requires many thousand annotated training samples. In this paper, we present a network and training strategy that relies on the strong use of data augmentation to use the available annotated

arxiv.org

UNet 의 논문은 위 링크에서 확인할 수 있습니다. 

원래는 Biomedical 분야에서의 의료 영상 Segmentation 을 위해 만들어진 모델이라고 하는데, 데이콘의 베이스라인 코드가 간소화된 UNet 구조를 사용하고 있었습니다. 

놀랍게도 이 UNet구조를 본따서 모델을 만들기 시작했는데, ConvLSTM2D와는 차원이 다른(?) 점수 상승을 불러왔습니다. 아, 이거구나! 유레카를 외치며 UNet 구조의 제 모델을 더 개선시키기 위해 코드를 계속 사용했습니다. 

그리고 또 검색을 해보니 RainNet 이라는 것도 있더군요. 

 

hydrogo/rainnet

RainNet: a convolutional neural network for radar-based precipitation nowcasting - hydrogo/rainnet

github.com

RainNet의 구조 역시 UNet과 거의 같았습니다. 깃헙 안에 있는 모델 코드를 보면 두군데 Dropout이 추가되었습니다. 이 RainNet에 따르면 데이터는 레이더 영상이 아니라 강수량으로 변환한 데이터였고, Loss Function은 LogCosh라고 검색을 하다가 봤던 것 같습니다. 아무튼 이 UNet Style Model이 효과가 있다는 것은 확인했습니다. 

 

 

2. 모델링

최종적으로 작성한 모델링 아이디어

키노트로 모델 구조를 그려보았습니다. 기본적인 UNet 구조로 흘러가는 Path 1 과, ConvLSTM2D를 거치는 Path 2 가 있고, 마지막에는 둘을 합쳐 마지막 Convolution을 진행하여 출력하고, 출력 Convolution을 3 * 3 으로 두었습니다. 이렇게 하면 구름의 주변 값도 고려하지 않을까 라고 생각했는데, 1 * 1 Convolution 과의 비교를 할 시간이 없었어가지고, 약간 아쉽습니다. Upsampling 층 대신에 Conv2DTranspose를 사용했고, RainNet에 있던 Dropout 위치에다가 그림에는 지금 Dropout 이라고 표시되어 있지만 SpatialDropout2D를 사용했고, 활성화함수는 ELU를 사용했습니다.  개인적으로 mish 를 정말 좋아하는데 이번에는 mish를 못써서 속상했습니다(?) 왜 좋냐고요? 그냥 이름이 이뻐서...

그림에는 그냥 나와있지만, 필터수 256, 512에서는 제가 '월간데이콘 6, 음성 중첩 분류 대회' 에서 사용했던 선형 Bottleneck 구조를 다시 사용했고,(파라미터 수를 줄이고 오래 걸리는 모델을 얻었다...) 모든 3번의 Convolution 블럭? 의 첫번째와 세번째를 잇는 Additive Skip Connection이 들어있습니다. 개인적으로 스킵커넥션이 Add 인 경우는 뭔가 '야 이게 맞으니까 당장 반영해.' 느낌이 있고, Concat인 경우는, '자 이렇게 될 수도 있는데 같이 생각해봐~' 이런 느낌이었습니다.

그냥 UNet만 사용하다가 ConvLSTM2D를 추가하게 된 이유는 다음과 같습니다. 

  • Conv 층의 필터끼리는 Dense Layer 와 비슷하게 연산된다. -> 이렇게 되면 시계열 순서가 담고있던 의미가 섞이게 될 것입니다.
  • (예전에 어떤 블로그에서 읽었던 글인데, 기억이 안나요 죄송합니다.) 신경망은 Sparse 할 수록 성능이 좋다, 인셉션이 성능이 좋았던 이유. 라고 설명을 해 주셨었는데, 지금도 Sparse가 어떤 의미인지 완벽히 이해하지 못하고 있지만, 인셉션 구조의 컨셉? 그림? 이 생각이 나서 이런식의 디자인이 나름의 인셉션 역할을 하지 않을까 라고 생각했습니다. 
  • ConvLSTM2D층 한개짜리 모델의 결과를 시각화 해 보았더니, 생각보다 성능이 좋아 보였다. -> 아주 개인적으로는 구름이 대충 '어디에 있는지' 정도는 층 하나로도 잘 잡아내는 듯 했습니다. 하지만 구름 모양의 세부적인 모양 디테일이나 그 값을 잘 잡아내지 못했기 때문에 점수가 잘 나오지 않았다고 생각했습니다. 그래서 ConvLSTM 구조로 모양을 잡고, UNet 구조로 디테일을 잡아서 합치면 어떨까? 하는 생각에 이렇게 만들게 되었습니다. 

학습, 예측 과정은 특별한 과정 없이 10 Fold Cross Validation을 하였고, Early Stopping을 걸어서 Fold 별로 베스트 모델을 저장해두었다가, 10개 모델들의 결과물의 단순평균을 구해서 제출했습니다. 오랫동안 1등을 유지하고있었던 0.50033(?) 점의 모델도 이 구조의 단일 모델 결과였습니다. 그래서 평균 앙상블 모델의 Public 점수는 매우 별로였지만 그래도 선택 할 수 있었다고 생각합니다. 

 

 

3. 기타 시도했던 것들 / 대충 시도 또는 생각만 해본....

  1. Metric 을 AUC 로 둔 것. 픽셀별로 0~1 값이니까 뭔가 측정이 될 수 있지 않을까 하는데 의미 없었습니다.
  2. Grouped Conv2D. Timestep이 4니까 UNet의 Conv에서 group을 4로 주고 해보았습니다. 지금 봐도 나쁘지 않은 생각인 것 같은데, 할 수 있다면 다시 해보고 싶습니다. 
  3. TimeDistributed(Conv2D). 이거 할 바에는 그냥 ConvLSTM2D를 해야겠다 생각했습니다.
  4. UNet Style ConvLSTM2D. ConvLSTM을 64로 시작해서, UNet의 모든 Conv 층을 ConvLSTM으로 대신할 수 있을까 생각했지만 컴퓨터가 못버텨서 빠른 포기.
  5. Input -> ConvLSTM2D -> UNet 구조 / UNet에서 가운데 층만 ConvLSTM2D. 전자의 경우는 좀 더 생각해볼 수 있지 않을까 싶은데, 후자의 경우는 Conv2D 통과하는 순간 시계열이 섞이기 때문에 틀린 생각이었다고 생각되네요. 시간 압박때문에 ConvLSTM2D 층을 두개 이상 쓰기는 정말 힘들었습니다. 
  6. Input -> DownSample -> ConvLSTM2D -> Upsample. 모델 크기는 작아지긴했지만, 대책없이 DownSampling을 해서 그런가 정보 손실이 있을 것 같습니다. 
  7. DownSampling 시에 CBAM 어텐션, UpSampling 시에는 'Attention UNet' 의 Attention Gate 추가. 둘다 썼을 때나, 둘중에 하나만 썼을 때나 둘다 결과가 그닥 좋지 않았습니다. 논문에 있는 그림 보고 무작정 만들어 봤었는데, 잘못 만들었을 것 같은 느낌이 확 드네요. 이것도 다시 해보고 싶은 아이디어 입니다. 

 

4. 결론

정말 너무 힘든 대회였습니다. 제출할 때마다 Public LB 점수는 오락가락 하고 있고, 모델 하나 만들어서 제출하는 시간은 너무 오래 걸리고, 컴퓨터는 이러다가 불날 것 같았고... 하지만 운이 잘 따라주었다고 생각합니다. 저는 여전히 부족한게 너무 많네요... 아직은 실력에 비해서 Over Prized(?) 된 것 같습니다. 그래도 상은 감사히 받고, 상값은 앞으로 더 열심히 해서 갚도록 하겠습니다. 

 

데이콘 토론 탭에도 요약본을 올렸습니다 :

 

공공데이터 활용 수력 댐 강우예측 AI 경진대회

출처 : DACON - Data Science Competition

dacon.io

 

반응형
Posted by Jamm_
반응형

처음으로 대회에서 상금을 받게 되었다. 대회 마감시간인 8월 14일 오후 6시, Private 랭킹이 결정된 후 너무 신나서 방방 뛰었던 기억이 난다.

3위!

처음으로 상금을 수상하게 되었고, 데이콘 측에 블로그에 글을 올려도 되냐고 물어보았는데, 가능하다는 답변을 받았다.

수상자들은 데이콘 코드공유 페이지에 코드를 올려서 코드 검증을 받아야한다.

 

음성 중첩 데이터 분류 AI 경진대회

출처 : DACON - Data Science Competition

dacon.io

(출처 : 데이콘에 올렸던 코드 원본 (위 링크))

 

 


1. Load Data

import os
import pandas as pd
import numpy as np
import scipy
from tqdm import tqdm
from glob import glob
from scipy.io import wavfile
import librosa
import matplotlib.pyplot as plt
import seaborn as sns
%matplotlib inline
import time

sns.set_style('whitegrid')

import warnings ; warnings.filterwarnings('ignore')

def data_loader(files):
    out = []
    for file in tqdm(files):
        data, fs = librosa.load(file, sr = None)
        out.append(data)
    out = np.array(out)
    return out

Xtrain = glob(data_dir + 'train/*.wav')
Xtrain = data_loader(Xtrain)

Ytrain = pd.read_csv(data_dir + 'train_answer.csv', index_col='id')
submission = pd.read_csv(data_dir + 'submission.csv', index_col='id')

print(Xtrain.shape, Ytrain.shape)
time.sleep(1)

Xtest = glob(data_dir + 'test/*.wav')
Xtest = data_loader(Xtest)

Xtrain = Xtrain.astype('float32')
Xtest = Xtest.astype('float32')

print(Xtrain.shape, Ytrain.shape, Xtest.shape, submission.shape)

언제나 그랬듯 비슷한 단계이다. 이번 대회는 음성 데이터를 다루는 대회여서, 데이터를 받으면 train 폴더 안에는 약 10만 개의 음성 파일이 wav 파일로 저장되어있고, test 폴더에는 1만 개의 음성 wav 파일이 들어있다.

wav 파일을 처음 다뤄보아서 어떻게 해야할지 손도 못대고 있던 와중에 데이콘에서 제공한 baseline 코드가 올라왔다. Scipy 를 이용해서 wav 파일을 불러오는 코드였다. 코드를 약간 바꿔서 librosa 패키지로 음성파일을 불러왔다.

Scipy 와 librosa 는 음성을 로딩한 결과물이 다른데, scipy에서는 +-30000 으로 원래의 값이 그대로 담겨있는 numpy array 가 생겼고, librosa 는 로딩된 데이터를 자동으로 +- 1 의 범위로 normalize 되어서 로딩되었다. 그래서 이렇게 느린거였나

 


 

2. Preprocessing

def get_melspectrogram(data, n_fft, win_length, hop_length, n_mels, sr=16000, save=False, to_db=True, normalize=False):
    array = []
    for i in tqdm(range(len(data))):
        melspec = librosa.feature.melspectrogram(data[i], sr=sr, n_fft=n_fft, win_length=win_length, 
                                                 hop_length=hop_length,n_mels=n_mels)
        array.append(melspec)
    array = np.array(array)
    if to_db == True:
        array = librosa.power_to_db(array, ref = np.max)
    if normalize==True: 
        mean = array.mean()
        std = array.std()
        array = (array - mean) / std
    if save == True:
        np.save(f"{data_dir}mel_spectrogram({n_fft},{win_length},{hop_length},{n_mels}).npy", array) 
    return array

def gen_4_mels(data, normalize=True):
    alpha = get_melspectrogram(data, n_fft=256, win_length=200, hop_length=160, n_mels=64, save=False, to_db=True, normalize=normalize)
    beta = get_melspectrogram(data, n_fft=512, win_length=400, hop_length=160, n_mels=64, save=False, to_db=True, normalize=normalize)
    gamma = get_melspectrogram(data, n_fft=1024, win_length=800, hop_length=160, n_mels=64, save=False, to_db=True, normalize=normalize)
    delta = get_melspectrogram(data, n_fft=2048, win_length=1600, hop_length=160, n_mels=64, save=False, to_db=True, normalize=normalize)
    
    data = np.stack([alpha, beta, gamma, delta], axis=-1)
    return data

음성 데이터를 처리하는 법에 대해서 찾아보니 몇가지 방법으로 요약되는 것 같았다.

  • 파일 하나하나가 시계열이므로 RNN 으로 처리
  • 1D-CNN 으로 처리
  • MFCC, 또는 (Mel-) Spectrogram 으로 변환해서 2D-CNN으로 처리

모델을 만들면서는 RNN 만 빼고 1D CNN, MFCC, Mel-Spectrogram 3가지를 시도해보았었다. RNN 만들줄몰라요 1D-CNN 으로 원래 음성 파일을 그대로 다루는것 보다, MFCC 가 결과가 더 좋았고, MFCC 보다는 Spectrogram이, Spectrogram 에서도 원래 스펙트로그램인 Mel Power Spectrogram 보다 이 값들을 dB로 바꿔준 dB Mel Spectrogram (그냥 이름 붙임) 이 결과가 제일 좋았다.

데이콘에 코드 공유를 올리면서도 적어두었는데, 이번 대회에서는 음성 데이터의 ㅇ 자도 모르던 내가 코드 공유해주신 고수분들의 덕을 톡톡히 봤다고 해야 할 것 같다. 출처 : 우승하신 JunhoSun 님의 설명글 (from 데이콘 코드 공유)


'마지막으로 spectrogram과 melspectrogram의 해상력에 대해 설명하겠습니다. win_length가 커질수록 주파수 성분에 대한 해상력은 높아지지만, 즉 더 정밀해지지만, 시간 성분에 대한 해상력은 낮아지게 됩니다. 즉, 더 정밀한 주파수 분포를 얻을 수 있으나 시간에 따른 주파수 변화를 관찰하기가 어려워집니다. 반대로 win_length가 작은 경우에는 주파수 성분에 대한 해상력은 낮아지지만, 시간 성분에 대한 해상력은 높아지게 됩니다. 따라서 적절한 값을 찾는 것이 중요합니다.' - JunhoSun / 음성 신호 기본 정보 / (월간 데이콘 6 우승)

나의 능력으로 적절한 값 하나를 찾아낼 수는 없을 것 같아서 데이터를 여러 개 만들어서 합치기로 했다. 딥러닝은 나보다 똑똑하니까. 몇개의 mel spectrogram 을 만들다보니, 이 스펙트로그램의 크기는 hop_length 와 n_mels 로 결정된다는 것을 찾을 수 있었다.

이 값들에 대한 설명도 위 링크에서 내용을 찾아볼 수 있었다.

  1. n_fft : win_length의 크기로 잘린 음성의 작은 조각은 0으로 padding 되어서 n_fft로 크기가 맞춰집니다. 그렇게 padding 된 조각에 푸리에 변환이 적용됩니다. n_fft는 따라서 win_length 보다 크거나 같아야 하고 일반적으로 속도를 위해서 2^n의 값으로 설정합니다.
  2. win_length : 이는 원래 음성을 작은 조각으로 자를 때 작은 조각의 크기를 의미합니다. 자연어 처리 분야에서는 25m의 크기를 기본으로 하고 있으며 16000Hz인 음성에서는 400에 해당하는 값입니다.
  3. hop_length : 이는 음성을 작은 조각으로 자를 때 자르는 간격을 의미합니다. 즉, 이 길이만큼 옆으로 밀면서 작은 조각을 얻습니다. 일반적으로 10ms의 크기를 기본으로 하고 있으며 16000Hz인 음성에서는 160에 해당하는 값입니다.
  4. n_mels : 적용할 mel filter의 개수를 의미합니다.

아하. 처음 보는 말들이다. 이중에서 이 win_length 의 값에 따라서 시간성분 vs 소리성분 어느 것을 더 잘 뽑아낼 수 있느냐 가 결정된다고 한다. 32, 40, 64, 128 의 값들 중에서 n_mels 를 최대한 많이 하려고 했는데,  의 n_mels 가 128개가 되었을때는 에러가 나고 있었다.

Empty filters detected in mel frequency basis. Some channels will produce empty responses. Try increasing your sampling rate (and fmax) or reducing n_mels. warnings.warn('Empty filters detected in mel frequency basis.')

라고 하는데, 나의 경우 4개의 mel spectrogram 모두에서 에러메시지가 출력되지 않는 가장 큰 값이 64였어가지고 64개로 설정했다.

all_data = np.concatenate([Xtrain, Xtest], axis=0)
print(all_data.shape)
time.sleep(1)
all_dbmel = gen_4_mels(all_data, normalize=True)
Xtrain_dbmel = all_dbmel[:len(Ytrain)]
Xtest_dbmel = all_dbmel[len(Ytrain):]
print(Xtrain_dbmel.shape, Ytrain.shape, Xtest_dbmel.shape)

이게 하나하나 처리하다 보니 속도가 정말 느리다. Xtrain, Xtest를 합쳐서 4개 채널을 가진 mel spectrogram 으로 만들어주고, 케라스에 넣기 위해 Z-Score Normalization을 했다.

 


 

3. Build Model & Train

import keras
import keras.backend as K
from keras.models import Model, Sequential
from keras.layers import Input, Convolution2D, BatchNormalization, Activation, Flatten, Dropout, Dense, Add, AveragePooling2D
from keras.callbacks import EarlyStopping
from keras.losses import KLDivergence
from sklearn.model_selection import train_test_split
from keras.optimizers import Nadam

def mish(x):
    return x * K.tanh(K.softplus(x))

def eval_kldiv(y_true, y_pred):
    return KLDivergence()(np.array(y_true).astype('float32'), np.array(y_pred).astype('float32')).numpy()

케라스를 불러오고, KL-Divergence 를 계산할 수 있게끔 함수도 만들었다.

def build_fn():
    dropout_rate=0.5
    
    model_in = Input(shape = (Xtrain_dbmel.shape[1:]))
    x = Convolution2D(32, 3, padding='same', kernel_initializer='he_normal')(model_in)
    x = BatchNormalization()(x)
    x_res = x
    x = Activation(mish)(x)
    x = Convolution2D(32, 3, padding='same', kernel_initializer='he_normal')(x)
    x = BatchNormalization()(x)
    x = Activation(mish)(x)
    x = Convolution2D(32, 3, padding='same', kernel_initializer='he_normal')(x)
    x = BatchNormalization()(x)
    x = Add()([x, x_res])
    x = Activation(mish)(x)
    x = AveragePooling2D()(x)
    x = Dropout(rate=dropout_rate)(x)

    x = Convolution2D(64, 3, padding='same', kernel_initializer='he_normal')(x)
    x = BatchNormalization()(x)
    x_res = x
    x = Activation(mish)(x)
    x = Convolution2D(64, 3, padding='same', kernel_initializer='he_normal')(x)
    x = BatchNormalization()(x)
    x = Activation(mish)(x)
    x = Convolution2D(64, 3, padding='same', kernel_initializer='he_normal')(x)
    x = BatchNormalization()(x)
    x = Add()([x, x_res])
    x = Activation(mish)(x)
    x = AveragePooling2D()(x)
    x = Dropout(rate=dropout_rate)(x)

    x = Convolution2D(128, 3, padding='same', kernel_initializer='he_normal')(x)
    x = BatchNormalization()(x)
    x_res = x
    x = Activation(mish)(x)
    x = Convolution2D(128, 3, padding='same', kernel_initializer='he_normal')(x)
    x = BatchNormalization()(x)
    x = Activation(mish)(x)
    x = Convolution2D(128, 3, padding='same', kernel_initializer='he_normal')(x)
    x = BatchNormalization()(x)
    x = Add()([x, x_res])
    x = Activation(mish)(x)
    x = AveragePooling2D()(x)
    x = Dropout(rate=dropout_rate)(x)

    x = Convolution2D(64, 1, padding='same', kernel_initializer='he_normal')(x)
    x = Convolution2D(64, 3, padding='same', kernel_initializer='he_normal')(x)
    x = Convolution2D(256, 1, padding='same', kernel_initializer='he_normal')(x)
    x = BatchNormalization()(x)
    x_res = x
    x = Activation(mish)(x)
    x = Convolution2D(64, 1, padding='same', kernel_initializer='he_normal')(x)
    x = Convolution2D(64, 3, padding='same', kernel_initializer='he_normal')(x)
    x = Convolution2D(256, 1, padding='same', kernel_initializer='he_normal')(x)
    x = BatchNormalization()(x)
    x = Activation(mish)(x)
    x = Convolution2D(64, 1, padding='same', kernel_initializer='he_normal')(x)
    x = Convolution2D(64, 3, padding='same', kernel_initializer='he_normal')(x)
    x = Convolution2D(256, 1, padding='same', kernel_initializer='he_normal')(x)
    x = BatchNormalization()(x)
    x = Add()([x, x_res])
    x = Activation(mish)(x)
    x = AveragePooling2D()(x)
    x = Dropout(rate=dropout_rate)(x)

    x = Convolution2D(128, 1, padding='same', kernel_initializer='he_normal')(x)
    x = Convolution2D(128, 3, padding='same', kernel_initializer='he_normal')(x)
    x = Convolution2D(512, 1, padding='same', kernel_initializer='he_normal')(x)
    x = BatchNormalization()(x)
    x_res = x
    x = Activation(mish)(x)
    x = Convolution2D(128, 1, padding='same', kernel_initializer='he_normal')(x)
    x = Convolution2D(128, 3, padding='same', kernel_initializer='he_normal')(x)
    x = Convolution2D(512, 1, padding='same', kernel_initializer='he_normal')(x)
    x = BatchNormalization()(x)
    x = Activation(mish)(x)
    x = Convolution2D(128, 1, padding='same', kernel_initializer='he_normal')(x)
    x = Convolution2D(128, 3, padding='same', kernel_initializer='he_normal')(x)
    x = Convolution2D(512, 1, padding='same', kernel_initializer='he_normal')(x)
    x = BatchNormalization()(x)
    x = Add()([x, x_res])
    x = Activation(mish)(x)
    x = AveragePooling2D()(x)
    x = Dropout(rate=dropout_rate)(x)


    x = Flatten()(x)

    x = Dense(units=128, kernel_initializer='he_normal')(x)
    x = BatchNormalization()(x)
    x_res = x
    x = Activation(mish)(x)
    x = Dropout(rate=dropout_rate)(x)

    x = Dense(units=128, kernel_initializer='he_normal')(x)
    x = BatchNormalization()(x)
    x = Add()([x_res, x])
    x = Activation(mish)(x)
    x = Dropout(rate=dropout_rate)(x)

    model_out = Dense(units=30, activation='softmax')(x)
    model = Model(model_in, model_out)
    model.compile(loss=KLDivergence(), optimizer=Nadam(learning_rate=0.002))
    return model
build_fn().summary()

Model Summary

Convolution 의 필터 수를 32, 64, 128, 256, 512 이렇게 5스텝으로 늘어나게끔 구성했다. Convolution 과 BatchNormalization 은 빅맥과 콜라처럼 한 세트로 보고 같이 묶었다.  이 모델을 만들던 시기에 ResNet 에 대해서 알게 되었다. 단순히 이전 레이어의 값을 뒷 레이어에 더해주는 행동 하나로, 깊은 신경망에서 학습이 더 잘된다고? 나도 얼른 적용해보았다. 어떻게 생겼는지 찾아도 보고, 따라해본다고 따라했는데, 비슷하게 따라한 것 같지는 않다. 그냥 텐서 크기가 같은 적절한 위치에서 뽑고, 더하고.  근데 나중에 CNN 깊게 만들때도 이거 있고 없고 차이가 '아주 약간' 났었다.

필터 수가 256, 512 일때는 ResNet 의 BottleNeck 구조를 이용했다. 기본적으로 Convolution 은 선형 연산이기 때문에, 나처럼 ConvConvConv 를 연속해서 하면 똑같은거라고 하지만 그냥 '최종적인 필터 수 256 (또는 512) 이면서, 파라미터 갯수 줄이기) 가 목적이었어서 그냥 ConvConvConv 를 적용해보았다. 그냥 BottleNeck 없이 Convolution 을 했으면 어떨까 궁금하다. 데이터가 크고 많을 수록 적합에 필요한 파라미터도 많을 것이기 때문에... 해볼만한 시도였던 것 같다.

 

num_models=15
model_list=[]

for i in tqdm(range(num_models)):
    model = build_fn()
    model.fit(Xtrain_dbmel, Ytrain, epochs=187, batch_size=16)
    model_list.append(model)
    model.save(f"model_{i}.h5")

이렇게 모델을 15개를 만들었다. 위 모델은 단일 모델을 만들면서 실험하면서 가장 점수가 좋았던 모델이고, early stopping 을 걸면서 에폭수 187 을 찾고, 배치 사이즈도 32, 16 을 했을때 16이 점수가 더 좋았어서 최종 선택했다.

RTX 2060 Super(8GB) 그래픽카드로 모델을 1개 학습시키는데 약 9시간 30분이 걸렸다.
코드는 이렇게 15개를 한번에 넣고 실행했지만, 실제로는 5개 하고 제출하고, 5개 더하고, 제출하고 했는데, 각각 결과는

  • 5개 단순 평균 : 0.41778
  • 10개 단순 평균 : 0.40481

정도이다.

preds = np.zeros(shape=submission.shape)
train_preds = np.zeros(shape = Ytrain.shape)

train_preds_list=[]
test_preds_list=[]
score_list=[]

for model, i in zip(models, range(len(models))):
    a = model.predict(Xtrain_dbmel)
    b = model.predict(Xtest_dbmel)
    eval_score = eval_kldiv(Ytrain, a)
    
    print(f"Model {i+1} Evaluation Score : {eval_score}")
    train_preds = train_preds + a
    preds = preds + b
    
    train_preds_list.append(a)
    test_preds_list.append(b)
    score_list.append(eval_score)
    
train_preds = train_preds / len(models)
preds = preds / len(models)
print(f"\nMean Predictions Evaluation Score : {eval_kldiv(Ytrain, train_preds)}")
simple_average = pd.DataFrame(preds, index=submission.index, columns=submission.columns)
simple_average.to_csv('15 Average Ensemble model.csv')
simple_average.head(10)

예측 결과물들을 단순 평균을 내는데, 단순 평균만 해도 점수가 확 좋아지는 것을 확인할 수 있었다.

와우~~ 이렇게 점수가 확 좋아진것은 KL-Divergence 의 특성 때문인 것 같다.

KL-Divergence 는 맞춰야 할 값을 잘 못맞추더라도, 조금이라도 값을 잡고, 안잡고에 따라서 점수가 확 차이가 난다고 한다. 아무래도 평균을 내면, 조금이라도 값을 잡는 경우가 많아지기 때문이라고 생각한다.

15개 예측값들의 최종 평균 점수는

 

Simple Average of 15 Predictions

  • Public LB : 0.399484
  • Private LB : 0.39202

 

 


 

 

4. 마지막으로 할 말

으아으아으아으아감사합니다유ㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠ

처음으로 상금을 받았다. 운도 잘 따라준 것 같고, 내 능력 안에서 내가 해야하는 노력도 할만큼 했다고 생각한다. 코드 검증도 끝나고, 계약서도 쓰고, 최종적으로 9월 8일에 상금을 받았다. 불과 1년 반 전까지만 해도 나는 간신히 타이타닉 코드 따라 치면서 외우고, 손에 파이썬이란것을 처음으로 익히고 있었던 중이었는데, 저번 대회부터 랭킹 안에도 들었고, 이제는 돈도 받아봤다! 원래는 게이밍 노트북에(i5-8세대, GTX 1060) 램을 16G 로 업그레이드하고 1TB HDD 를 가상메모리용으로 두고 하던 중이었는데, 너무 느려 터지고, 데이터 불러오는것도 한세월, 한 에포크 도는거 두세월씩 걸려버리니까 너무 화가 나서 반드시 상금 타겠다는 각오로 들고있던 돈을 모두 때려 넣어서 컴퓨터를 바로 새로 샀다. 역시 귀족학문

그냥... 뭐...음... 아직까지는 이렇게 대회하는 것이 재미있다. 이걸로 일을 하게 된다면 어떻게 될까는 아직 잘 모르겠지만, 전과도 일단은 보류중이기 때문에 머신러닝 대회는 정말 취미가 된 상태지만, 아직까지는 대회 하는게 재미있다. 앞으로도 좀 더 잘 해봐야겠다.

상금은 좋은 곳(이라 쓰고 컴퓨터 투자금 회수) 에 쓰겠습니다.

반응형
Posted by Jamm_
글 번역2020. 9. 9. 19:43
반응형

Part 1 링크 : 

 

Kaggle Ensembling Guide(mlwave) / 캐글 앙상블 가이드 한글 번역 / Part. 1

원글은 다음 링크의 글입니다. Kaggle Ensembling Guide | MLWave Model ensembling is a very powerful technique to increase accuracy on a variety of ML tasks. In this article I will share my ensembling..

jamm-notnull.tistory.com

 

이 글은 위 문서에 대한 자의적인 한글 번역본입니다. 번역을 하면서 이해한대로 설명을 더 추가할것이고, 아무리 시도해도 이해가 되지 않은 부분은 빠진 내용이 있을 수 있습니다. 영어 실력의 부족으로 번역이 매끄럽지 않을 수 있습니다. 댓글로 번역, 내용을 지적해주시면 감사한 마음으로 수정할 수 있도록 하겠습니다. 본문이 매우, 매우, 매우 긴 관계로 몇 편의 글로 끊어서 올릴 수 있습니다. 

번역상 '나' 는 필자가 아니라, 원 글의 저자임을 밝힙니다.

 

 

 

스태킹 앙상블 & 블렌딩 (Stacked Generalization & Blending)

결과물들을 평균하는 방법은 아주 쉽고 괜찮은 방법이지만, 최고 순위의 캐글러들이 사용하는 유일한 방법은 아닙니다. 

 

Netflix 대회

Netflix 측에서는 꽤나 인기를 끌었던 데이터 사이언스 대회를 주최했습니다. 참가자들은 영화를 추천해주는 추천 시스템을 만들어야 하는데, 참가자들은 그들의 결과물들을 앙상블하는 것을 예술의 경지까지 끌어올렸습니다. 넷플릭스는 결국 우승자의 솔루션을 도입하지 않기로 결정했는데, 그 이유는 '너무 복잡하기 때문' 이었습니다. 

그럼에도 불구하고, 이 대회로 인해서 몇개의 논문들과 참신한 방법들이 나왔습니다. 

상당히 흥미롭습니다. 당신의 '캐글 게임' 능력을 향상시키고 싶다면 읽어보면 좋은 글들입니다. 

최종 결과물을 위해 수백 개의 예측모델들을 혼합(원 : blending) 하는 것은 참으로 인상깊습니다. 우리는 몇가지의 새로운 방법들을 오프라인으로 측정해 보았지만, 여기서 추가적으로 정확도를 올리는 것이 이것을 생산 환경에 적용하려는 엔지니어링에 관한 노력을 정당화하지는 못했습니다. - 넷플릭스 엔지니어들

(역주 : 여기서 정확도 몇 퍼센트 더 올리는걸 적용하는 것이 그렇게 의미 있을 정도로 효과적이지는 않았다 라는 의미 같습니다)

 

스태킹 (Stacked generalization)

스태킹 앙상블 (원 : Stacked generalization) 은 1992년 논문 에서 Wolpert 에 의해 처음 언급되었습니다. Breiman의 논문인 Bagging Predictors“ 보다 2년 먼저 나왔네요. Wolpert 는 그의 또 다른 머신러닝 이론인 “탐색과 최적화에 공짜 점심은 없다“ 로도 유명합니다.

스태킹의 기본적인 아이디어는 여러 '기본 분류기' (원 : base classifiers) 들의 pool 을 사용한다는 것입니다. 그 후, 다른 분류기로 그 예측 결과를 조합하면서 일반화 오류를 낮추는 데에 목표를 두고 있습니다. 

2-Fold Stacking 을 하는 예시를 들어보면 다음과 같습니다. 

  • train 데이터를 두 개 파트로 나눕니다 : train_a and train_b
  • 1단계 모델은 train_a 를 학습해서 train_b 를 예측합니다. 
  • 같은 모델로 train_b 를 학습해서 train_a 를 예측합니다. 
  • 마지막으로 전체 train 데이터에 대해 학습한 후, test 셋을 예측합니다.
  • 그리고 2단계 모델로 1단계 모델(들)의 예측 결과물을 학습합니다. 

이 스태킹 모델은 문제 공간에 대해 1단계 모델의 예측값을 feature 로 추가적으로 받기 때문에, 단독으로 학습하는것보다 더 많은 정보를 가지고 학습을 하게 됩니다. 

스태킹을 할 때, 0단계 모델들은 어느 하나의 변형이 아닌 가능한 한 모든 '타입' 들이어야 좋습니다. 이렇게 하면, 학습 데이터를 탐색하는 가능한 많은 방법들을 뽑아낼 수 있습니다. 이는 0단계 모델들은 '공간을 넓혀야 한다' 는 의미와 같습니다. 

[…] 스태킹은 일반화 모델들을 비선형적으로 결합해서 새로운 일반화 모델을 만드는 것입니다. 각각의 모델들을 최적의 방법으로 통합하여 원래 모델이 학습 데이터에 대해서 예측해야했던 것들을 찾아야 합니다. 각각의 모델들이 뱉는 결과값이 다양할수록, 스태킹의 결과물은 더 좋아집니다 -  Wolpert (1992) Stacked Generalization

 

블렌딩 (Blending)

블렌딩 (Blending) 이라는 말은 넷플릭스 우승자들이 처음 사용하였습니다. 이는 스태킹과 유사한 방법이지만, 비교적 더 단순하고 정보 누출의 위험이 적습니다. "Stacked ensembling" 과 "blending" 은 종종 같은 의미로 사용됩니다. 

블렌딩을 사용할 경우, out-of-fold 예측값을 사용하는 대신에, 학습 데이터의 일부를 쪼개서 holdout validation 을 사용합니다. 다음 단계 모델은 이 holdout set 만을 이용해서 학습을 하게 됩니다. 

블렌딩의 몇가지 장점은 다음과 같습니다. 

  • 스태킹보다 단순합니다. 
  • 0단계 모델과 이후 단계 모델이 다른 데이터를 사용하기 때문에 정보 누출에 더 강한 모습을 보입니다. 
  • 시드 값이나 Stratified Fold 를 팀원들과 공유할 필요가 없습니다. 누구나 모델을 만들고 'blender' 에 추가할 수 있고, 이 새로 추가한 모델을 사용할 것인가 폐기할 것인가에 대해서는 'blender' 가 결정할 뿐입니다. 

몇가지 단점은 다음과 같습니다.

  • 아무튼 사용하게 되는 데이터의 양이 적어집니다. 
  • 최종 모델이 holdout set에 대해 과적합 되었을 수 있습니다. 
  • Holdout set을 사용하는것 보다 교차 검증이 더 강인한 모습을 보여줍니다. 

성능면에서는 스태킹과 블렌딩 두 기술 모두 비슷한 결과를 주지만, 어떤 것을 사용할 지는 당신의 선호에 따라 달라질 것입니다. 나같은 경우는 스태킹을 더 선호하는 편입니다. 만약 도저히 고르지 못하겠다면 둘 다 하는것도 방법입니다. 스태킹을 하고, 다음 단계 모델에서 holdout 셋을 사용해 블렌딩하는 것도 방법이 될 수 있습니다. 

 

로지스틱 회귀분석 (Stacking with logistic regression)

로지스틱 회귀분석을 사용해서 스태킹을 하는 방법은 가장 기본적이고 전통적인 스태킹의 방법론입니다.  Emanuele Olivetti 가 적었던  덕분에 나는 이것을 이해할 수 있었습니다. 

test 세트에 대한 예측을 만들 때 한 번에 예측을 하거나 out-of-fold 예측값을 사용해서 평균을 구할 수도 있습니다.  사실 out-of-fold 로 평균을 구하는 것이 더 정확하고 깔끔한 방법이기는 하지만, 모델과 코딩의 복잡성을 낮추기 위해 나는 한번에 구하는 평균을 더 선호하는 편입니다. 

캐글 예시 : “Papirusy z Edhellond”

나는 Emanuele 가 만든 blend.py 를 사용해서 이 대회에 나갔습니다. 8개듸 다른 모델들 (ExtraTrees, RandomForest, GBM...등등) 을 로지스틱 회귀분석으로 스태킹하는 방법은 0.99409 의 정확도를 내었고, 이 점수는 우승을 하기에 충분했습니다. 

캐글 예시 : KDD-cup 2014

이 코드로 나는 Yan Xu의 모델을 더 향상시킬 수 있었습니다. 그녀의 모델은 스태킹 없이 0.605 정도의 AUC를 기록했지만, 스태킹을 하면서 0.625 정도로 향상되었습니다. 

 

비선형 모델들의 스태킹 (Stacking with non-linear algorithms)

스태킹에 주로 사용되는 유명한 비선형 모델들은 GBM, KNN, NN, RF, ET 등등이 있습니다. 다중 분류 문제에서 원래의 feature 들을 비선형 스태킹 방법으로 결합하는것은 놀라울 정도의 성능 향상을 가져왔습니다. 분명히 첫 단계의 예측들은 가장 높은 순위의 'feature importance' 를 기록하고 있었고, 도움이 되는것을 확인할 수 있습니다. 비선형 알고리즘들은 원래의 feature 들과 meta-model의 변수들의 유용한 관계를 잘 찾아냅니다. 

 

캐글 예시 : TUT Headpose Estimation Challenge

 TUT Headpose Estimation 대회는 multiclass 분류 문제이면서 multi-label 분류 문제라고 볼 수 있습니다. 

각각의 라벨 값에 대해서 다른 앙상블 모델을 학습시켰습니다. 다음 표는 각각의 모델의 결과물과, 각각의 클래스 확률을 ExtraTrees 로 스태킹하였을 때의 점수 향상을 보여줍니다. 

MODEL        |             PUBLIC MAE             |                 PRIVATE MAE

Random Forests 500 estimators 6.156 6.546
Extremely Randomized Trees 500 estimators 6.317 6.666
KNN-Classifier with 5 neighbors 6.828 7.460
Logistic Regression 6.694 6.949
Stacking with Extremely Randomized Trees 4.772 4.718

우리는 스태킹을 함으로써 각각의 모델의 결과보다 약 30%(! 씩이나) 좋은 결과를 얻을 수 있었습니다. 

이 결과에 대해서는 다음 글을 읽어보시면 됩니다 : Computer Vision for Head Pose Estimation: Review of a Competition.

 

코드 예시

이 링크에서 out-of-fold 예측값을 만드는 함수를 찾을 수 있습니다. 결과물을 numpy 의 horizontal stack (hstack) 을 사용해서 blending 데이터셋을 만들면 됩니다. 

Feature weighted linear stacking

Feature-weighted linear stacking 은 새로 만든 'meta-features' 들을 새로운 모델 예측으로 스태킹을 합니다. 이 새로운 스태킹 모델은  특정한 feature 값에 대해서 어떤 0단계 모델이 (원 : base predictior) 가장 좋은 모델인지를 학습할 것이라고 기대할 수 있습니다. 새로운 스태킹 모델(마지막 모델)로는 선형 알고리즘을 사용해서 마지막 모델의 결과가 관찰하기 단순하고 빠른 값을 낼 수 있게끔 합니다. 

Vowpal Wabbit 으로 우리는 'feature-weighted linear stacking' 의 형태를 별도의 설치 없이 구현할 수 있습니다. 우리가 가진 학습 데이터가 다음과 같이 생겼다고 가정해봅시다. 

1  |  f           f_1 : 0.55        f_2 : 0.78        f_3 : 7.9        |    s   RF : 0.95        ET :  0.97      GBM : 0.92

우리는 f-피쳐공간과 s-피쳐공간간에 -q fs(역주 : f 값 * s값 하나씩의 곱) 을 추가하면서 2차함수의 형태를 가진 변수간 상관관계를 추가할 수 있습니다.

f-피쳐공간의 이름들은 만들어진 'meta-features' 가 될 수도 있고, 원래의 'feature' 일 수도 있습니다. (역주 : 아무튼 f 는 스태킹 과정 상으로 s 보다 한단계 낮은 의미라는 뜻 같습니다.)

 

Quadratic linear stacking of models

이 이름은 이름 붙여진 것이 없어서 제가 새로 만들어낸 이름입니다. 'feature-weighted linear stacking' 과 상당히 유사하지만, 차이점은 모델들의 예측 결과들로부터 새로운 조합을 만들어낸다는 것입니다. 이것은 수많은 실험들 속에서 점수를 향상시켰고, Modeling Women’s Healthcare Decision competition 대회의 DrivenData 에서 특히 두드러졌습니다.

위에서 생각했었던 같은 예시를 다시 적어본다면 : 

1  | f        f_1 : 0.55       f_2 : 0.78       f_3 : 7.9       |     s   RF : 0.95      ET : 0.97       GBM : 0.92

우리는 2차원의 변수 상호작용인 -q ss 를 만들어서 학습을 시킬 수도 있습니다. (역주 : 예를 들면 : RF와 GBM의 예측값의 곱)

그리고 이 방법은 feature_weighted linear stacking (-q fs, -q ss) 와 쉽게 결합할 수 있고, 둘 다 성능 향상을 기대할 수 있을 것입니다.

 

Feature weighted Linear Stacking

(역주 : 번역을 하긴 했지만 아직 예시를 찾아보지 않아서 이해가 잘 되지 않네요.

이미지 출처 -(Feature-Weighted Linear Stacking(2009) arxiv.org/pdf/0911.0460.pdf#page=3))

따라서 당신은 여러 가지 기본 모델들을 만들어야합니다. 실험을 하기 전에, 이중 어떤 모델이 마지막에 사용될 meta model 에 도움이 될 지 모릅니다. 2단계 스태킹 모델의 예시를 살펴본다면, base model 들은 약한 모델(원 : weak base model)이 선호되고 있습니다. 

그런데 왜 이렇게 base model 들을 열심히 튜닝하는것입니까? 여기서 튜닝은 모델들의 다양성을 확보하는데 사용될 뿐입니다. 마지막 단계에서 어떤 모델이 유용할지는 모릅니다. 그리고 그 마지막 단계는 '선형' 단계가 될 가능성이 높습니다. 튜닝도 필요 없고, 약간의 sparsity 를 위한 파라미터 한 개 정도 가진 모델이지요.  - Mike Kim, Tuning doesn’t matter. Why are you doing it?

 

Stacking classifiers with regressors and vice versa

(역주 : Regressor 들을 모아서 마지막에만 Classification을 사용한다던가, 혹은 그 반대의 경우)

스태킹을 한다면, 당신은 Regression 문제에도 Classifier들을 사용할 수 있고, 그 반대도 가능합니다. 예를 들면 이진 분류 문제에서 base ㅡmodel 로 quantile regression 을 사용할 수 있습니다. 좋은 스태킹 모델은 Regression모델들이 최고의 분류 성능을 주지 않더라도 그 예측값으로부터 정보를 뽑아낼 수 있어야 합니다. 

하지만 반대로 Regression 문제에서 Classifier 들을 사용하는것은 약간 더 까다롭습니다. 그래서 첫 번째 단계가 'binning' 이라고 하는 방법인데, 정답값(원 : y-label) 에 binning 을 균등한 간격으로 해주면, Regression 문제는 다음과 같이 다중 분류 문제로 바뀌게 됩니다. 

  • 20000 미만 :  class 1.
  • 20000 이상, 40000 미만 : class 2.
  • 40000 이상 : class 3.

각각의 클래스에 대한 확률을 구한다면 (마지막에 사용할) 스태킹 회귀 모델이 예측을 더 잘 할 수 있도록 도와줄 것입니다. 

“나는 절대, 절대로 out-of-fold 예측값 없이는 어디도 가지 않을거라고 배웠습니다. 내가 하와이를 가던, 어디 화장실을 가도 난 이 예측값들을 가져갈 겁니다. 내가 언제 2단계 혹은 3단계 'meta classifier' 들을 학습시켜야 할지 모릅니다. ” - T. Sharf

 

비지도학습으로 만든 feature 들의 스태킹 (Stacking unsupervised learned features)

우리가 꼭 스태킹을 할때 지도학습만을 사용해야한다는 규정은 그 어디에도 없습니다. 당연히 비지도학습 방법들도 사용할 수 있습니다. 

K-Means 클러스터링은 여기서 사용될 수 있는 인기있는 좋은 방법 중 하나입니다. Sofia-ML 에서는 여기에 적합한 빠른 K-Means 알고리즘을 구현해두었습니다. 

최근에 있었던 다른 방법 중 하나는 t-SNE를 사용하는 것입니다. 데이터를 2차원 혹은 3차원으로 축소시킨 후 이 결과물들을 비선형 스태킹 모델로 학습시키는 것입니다. 이 경우에는 holdout set을 확보해 두는것이 스태킹, 블렌딩 모두에서 안전한 방법일 것 같습니다. 여기서 Mike Kim이 t-SNE 벡터들을 XGBoost 로 부스팅한 솔루션을 볼 수 있습니다. ‘0.41599 via t-SNE meta-bagging‘.

Piotr 에서 Otto Product Classification Challenge 데이터를 t-SNE 로 시각화한 좋은 결과를 볼 수 있습니다. 

 

Online Stacking

나는 online stacking 의 아이디어를 생각해내기 위해 꽤 많은 시간을 보냈습니다. 첫번째로, 해시된 이진 표현으로부터 아주 작은 임의의 tree 기반 모델을 만들었습니다. 거기에 이 모델이 정확한 예측을 한 경우 이득(원 : profit) 을 더해주고, 틀린 경우에는 빼주었습니다. 그 후, 가장 좋은 트리와 (원 : most profitable) 가장 안좋은 트리(원 : least profitable)를 가져와서 변수 (원 : feature representation) 에 더해주었습니다. 

이 방법이 먹히기는 했는데 인공적인 데이터에서 만 먹혔습니다. 예를 들면, 이런 방식으로 만든 랜덤한 트리모델들을 선형 퍼셉트론으로 학습시켜도 비선형 문제인 XOR 문제를 학습하는데 성공했습니다. 하지만 다른 실생활 문제들에는 전혀 먹히지 않았습니다. 믿어주세요. 진짜 해봤는데 안돼요. 이 이후로 나는 새로운 알고리즘을 소개하는 논문을 볼 때 인공적인 데이터들로만 실험한 것을 보면 의심을 하기 시작했습니다.

하지만 이와 유사한 아이디어가 이 논문 (random bit regression)에서 성공했습니다. 여기서 보면, 여러 feature 들로부터 생성한 임의의 선형 함수를 여러 개 만들었고, 가장 좋은 것은 강한 정규화를 통해 찾아졌습니다. 여기서 나는 다른 데이터셋에서도 적용될 수 있는 성공적인 방법을 찾을 수 있었습니다. 이는 다음에 쓸 포스트 내용이 될 것입니다. 

이 online stacking 의 예시로는 광고 클릭 여부 예측 문제를 들 수 있습니다. 각 모델들은 그때그때 잘 작동되는 최근의 데이터로 학습됩니다. 따라서 데이터가 시간적인 관계가 있으면, Vowpal Wabbit 을 사용해서 전체 데이터를 학습시키고, 더 복잡하고 강력한 XGBoost 같은 툴을 사용해서 과거의 데이터를 학습시키는 방법을 사용할 수 있습니다. 이렇게 학습된 XGBoost 모델들의 예측값과 원래의 샘플들을 같이 스태킹하면, Vowpal Wabbit이 가장 잘 하는것을 제대로 시킬 수 있습니다. 손실 함수 최적화 말이지요. 

실제 세계는 복잡합니다. 그래서 서로 다른 모델을 많이 결합하면 이러한 복잡성을 포착할 수 있습니다. - Ben Hamner ‘Machine learning best practices we’ve learned from hundreds of competitions’ (video)

 

Everything is a hyper-parameter

스태킹, 블렌딩, 메타모델링을 할 때, 내가 하는 모든 행동들이 스태킹 모델에게는 일종의 하이퍼파라미터로 적용될 수 있다고 생각해야 합니다. 예를 들면:

  • 데이터를 스케일링하지 않는것. 
  • 데이터를 Standard Scaling 하는것. 
  • 데이터를 Minmax Scaling 하는 것. 

이 모든 것들은 앙상블 학습의 성능을 끌어올리기 위해 튜닝해야하는 추가적인 하이퍼파라미터일 뿐입니다. 마찬가지로, 사용할 base model 의 수 역시도 최적화해야할 하이퍼파라미터로 볼 수도 있습니다. Feature selection 이나 결측치 처리 역시도 다른 예시가 될 수 있습니다. 

이러한 'meta-parameter' 들을 튜닝하는데는 평소 알고리즘 튜닝에 사용하는 random search 나 gridsearch가 좋은 방법이 될 수 있습니다. Li

가끔씩은 XGBoost 가 KNN-Classifier 가 보는 것들을 보게 해주는것이 효과적일 때도 있다 . – Marios Michailidis

 

Model Selection

여기서 추가적으로 여러가지 앙상블 모델들의 결과를 조합해보면서 점수를 더 최적화할 수 있습니다.

  • 접근 방법은 수동으로 선택한 좋은 앙상블 결과들에 평균내기, 투표 방식, 순위 평균을 구해 보는 것입니다. 
  • 탐욕 알고리즘적인 방법 (원 : Greedy forward model selection (Caruana et al.)) -  3개 정도의 가장 좋은 모델을 시작으로, train set의 점수를 가장 많이 올려주는 다른 모델들을 하나씩 추가합니다. 복원 추출 개념을 적용하면, 한 개의 모델이 여러번 선택되어 추가될 수 있고, 이것은 가중치를 부여하는 효과가 있습니다. (원 : weighing)
  • 유전 알고리즘적인 방법 - 유전 알고리즘과 교차 검증 점수를 사용해서 선택할 모델들을 정합니다. 예시로 inversion의 솔루션을 볼 수 있습니다. ‘Strategy for top 25 position‘.
  • 나는 Caruana 의 방법에서 아이디어를 얻은 완전히 랜덤한 방법을 사용합니다. 먼저 랜덤한 앙상블 결과를 100개 정도 만들고, 비복원추출로 고른 몇 개의 결과를 골라내고, 이 결과 중 가장 좋은 결과를 고릅니다. 

 

자동화 (Automation)

Otto product classification 대회를 하면서 스태킹을 진행할 때, 나는 top 10 등의 자리에 빠르게 올라갔습니다. 거기에 더 많은 기본 모델들을 추가하고, 스태킹 앙상블들을 bagging 했다면 점수를 더 향상시켰을 수 있을겁니다.

7개의 base model 들을 6개의 스태킹 모델로 스태킹하는 정도가 되었을때, 나는 충격과 공포를 느꼈습니다. 이 모든 과정을 자동화할 수는 없을까? 이 복잡하고 느리면서 다루기 힘든 모델들을 계속 만지는 것은 내가 좋아하는 '빠르고 단순한' 기계 학습의 영역에서 완전히 벗어나있는 일이었습니다. 

나는 대회의 남은 기간동안 이 스태킹 과정을 자동화 시키는 일에 시간을 쏟았습니다. 기본 모델로는 랜덤한 알고리즘들을 랜덤한 파라미터들로 학습을 시켰습니다. Scikit-learn의 API 를 가지는 Wrapper 들을 VW, Sofia-ML, RGF, MLP and XGBoost 등과 함께 사용했습니다. 


The first whiteboard sketch for a parallelized automated stacker with 3 buckets

스태킹 모델로는 SVM, Random Forests, ExtraTrees, GBM, XGBoost 들을 랜덤한 파라미터로, 기본 모델들의 랜덤한 집합에 학습을 시켰습니다. 마지막으로, 이 스태킹 모델들은 각각의 fold-prediction 이 더 낮은 점수를 갱신할 때 그 값을 평균내었습니다. 

이렇게 자동으로 만들어진 스태킹 모델은, 대회 마감 약 일주일 전에 57등을 기록하고 있었습니다. 이는 나의 최종 앙상블 모델에 기여하게 되었습니다. 기존에 내가 하던 방법과 다른 점이라고는 모델을 튜닝하고, 고르는 것에 전혀 시간을 쓰지 않았다는 점입니다. 코드를 실행하고, 자고 일어나면 점수가 올라 있죠. 

이렇게 만들어진 자동 스태킹 모델은 아무 튜닝이나 모델 선택을 하지 않고도, 3000여 명의 경쟁자들 중에서 상위 10%의 성적을 거두었습니다. 

이러한 자동화된 스태킹 모델은 내 가장 큰 관심 분야중 하나입니다. 여기에 관한 다음 몇 개의 글들도 기대해주세요. 이 자동 스태킹 모델은 TUT Headpose Estimation challenge 에서 가장 좋은 결과를 보여주었습니다. 이 'black-box' 솔루션이, 요즘 잘나가는 전문가들이 이 대회를 위해서 만든 특수 목적 알고리즘을 만든 것들을 모두 부숴버렸습니다.

주목할 만한 점은 : 이 대회는 다중 분류 문제입니다. 예측값들은 "yaw", "pitch" 두 가지를 모두 맞춰야합니다. (역주 : 한글로는 롤, 피치, 요 라고 하는데, 물체의 회전움직임, 혹은 방향을 3개 축으로 표현하는 방법이다 라고 보시면 됩니다.)

머리의 자세를 표현하려면, 이 "yaw" 와 "pitch" 들은 서로 관계가 있습니다. "yaw" 를  집중적으로 맞추는 모델들을 스태킹하는것이 "pitch" 값의 정확도도 올려주었고, 그 반대도 마찬가지였습니다. 흥미롭네요. 

CV 점수를 고려할 때, 이 CV 점수들의 표준편차도 고려할 수 있습니다. 아무래도 표준편차가 작은 모델이 더 안전한 모델입니다. 이 다음에는 모델의 복잡도와 메모리 사용량, 그리고 코드 실행 시간을 최적화할 수 있습니다. 마지막으로는 각 예측 결과물들에 대한 상관관계를 추가해볼 수 있습니다. 코드가 자동으로 앙상블을 수행할 때, 상관관계가 적은 예측값들을 자동으로 선호할 수 있도록 말이지요. 

이 자동화된 스태킹 파이프라인은 병렬화 및 분산화가 가능합니다. 그렇게 되면, 단일 노트북 컴퓨터로 실행해도 빠른 속도와 성능 향상을 기대할 수 있습니다. 

Contextual bandit optimization 은 gridsearch 를 대신할 수 있는 방법입니다. 알고리즘이 좋은 파라미터와 모델을 사용하기 시작하고, 전에 사용했던 SVM 이 메모리가 부족했던 경우 등등을 기억하기를 원합니다. 스태킹에 관한 이 추가 사항은 다음에 더 자세히 살펴보겠습니다.

그 동안, MLWave 의 깃헙 저장소의 미리보기를 한번 보세요. “Hodor-autoML“.

 

Otto Product Classification Challenge 의 1등, 2등 수상자들은 1000개 이상의 다른 모델들의 앙상블을 수행했습니다. 솔루션은 여기에서 확인할 수 있습니다. 1위 솔루션, 2위 솔루션

Why create these Frankenstein ensembles?

(역주 : 이런 괴물같은 앙상블을 왜 하는 것일까요?)

이렇게 천 개가 넘는 모델들을 만들고 스태킹하고 조합하는 것이 당신이 보기에는 무익하고, 미친 소리처럼 들릴 지 모릅니다. 하지만 이런 괴물같은 앙상블들은 다 쓸모가 있습니다. :

  • 캐글같은 대회를 우승할 수 있습니다.
  • 한번의 시도로 잘나가는 최신 벤치마크들을 이길 수 있습니다. 
  • 모델을 만든 후, 이 좋은 성적과 단순하고 만들기 쉬운 모델의 성적을 비교해볼 수 있습니다. 
  • 언젠가 지금 수준의 컴퓨터들과 클라우드 컴퓨팅 자원은 아주 약한 날이 올 것입니다. 그 때에 대비해서 준비할 수 있습니다
  • 이렇게 앙상블한 모델에서 얻은 지식을 다시 단순하고 얕은 모델에게 전달할 수 있습니다. (Hinton’s Dark Knowledge, Caruana’s Model Compression)
  • 모든 기본 모델들이 제 시간안에 학습이 끝나지 않아도 됩니다. 한 개의 모델이 없어도, 앙상블 모델은 좋은 예측값을 만드는 데에 큰 지장이 없습니다. 그런 점에서 앙상블은 아주 graceful 한 degradation(역주 : 도저히 어떻게 번역해야 할지 모르겠습니다.) 의 형태를 소개합니다. 
  • 자동화된 대규모 앙상블은 어떠한 튜닝이나 모델 선택을 하지 않아도 오버피팅에 강력하고, 정규화(regularization)의 성질이 있습니다. 일반인들도 충분히 사용할 수 있습니다. 
  • 현재까지 머신러닝 알고리즘의 성능을 향상시키는 가장 좋은 방법입니다. 아마도 human ensemble learning에 관해서 무언가를 알려줄 수도 있습니다. 
  • 정확도를 1% 정도 올리는 것이 돈을 투자하는 일에 있어서는 큰 손실을 볼 것을 줄여주기도 할 것입니다. 더 심각한 예시로, 헬스케어 관련해서는 목숨을 살리는 일에 도움을 줄 수도 있습니다. 

 

 


 

 

Update: Thanks a lot to Dat Le for documenting and refactoring the code accompanying this article. Thanks to Armando Segnini for adding weighted averaging. Thanks a lot everyone for the encouraging comments. My apologies if I have forgotten to link to your previous inspirational work. Further reading at “More is always better – The power of Simple Ensembles” by Carter Sibley, “Tradeshift Benchmark Tutorial with two-stage SKLearn models” by Dmitry Dryomov, “Stacking, Blending and Stacked Generalization” by Eric Chio, Ensemble Learning: The wisdom of the crowds (of machines)by Lior Rokach, and “Deep Support Vector Machines” by Marco Wiering.

Terminology: When I say ensembling I mean ‘model averaging’: combining multiple models. Algorithms like Random Forests use ensembling techniques like bagging internally. For this article we are not interested in that.

The intro image came from WikiMedia Commons and is in the public domain, courtesy of Jesse Merz.

Cite

If you use significant portions or methods from this article in a scientific paper, report, or book, please consider attributing with:

or, if you prefer a more authoritative reference:

  • Michailidis, Marios. (2017). Investigating machine learning methods in recommender systems (Thesis). University College London.

For other, less formal, material, such as blogs or educational slides, a simple link will suffice to satisfy Creative Commons 3.0 attribution.

The resource URL will remain static and the page hosted on this site for the foreseeable future.

반응형
Posted by Jamm_
글 번역2020. 9. 8. 19:16
반응형

원글은 다음 링크의 글입니다. 

 

Kaggle Ensembling Guide | MLWave

Model ensembling is a very powerful technique to increase accuracy on a variety of ML tasks. In this article I will share my ensembling approaches for Kaggle Competitions. For the first part we look at creating ensembles from submission files. The second p

mlwave.com

이 글은 위 문서에 대한 자의적인 한글 번역본입니다. 번역을 하면서 이해한대로 설명을 더 추가할것이고, 아무리 시도해도 이해가 되지 않은 부분은 빠진 내용이 있을 수 있습니다. 영어 실력의 부족으로 번역이 매끄럽지 않을 수 있습니다. 댓글로 번역, 내용을 지적해주시면 감사한 마음으로 수정할 수 있도록 하겠습니다. 본문이 매우, 매우, 매우 긴 관계로 몇 편의 글로 끊어서 올릴 수 있습니다. 

번역상 '나' 는 필자가 아니라, 원 글의 저자임을 밝힙니다.

 


 

Kaggle Ensembling Guide

모델 앙상블은 여러 가지의 머신러닝 문제의 정확도를 올릴 수 있는 아주 강력한 방법입니다. 이번 글에서는 내가 캐글 대회에서 사용했던 앙상블 방법들을 공유하고자 합니다. 

첫번째 파트에서는 우리가 만든 제출 파일들로 앙상블하는 방법을 먼저 볼 것이고, 두번째 파트로는 스태킹(stacked generalization) 과 블렌딩(blending) 방법으로 앙상블을 하는 것을 볼 것입니다. 

'왜 앙상블이 일반화 오류를 줄여주는가?' 에 대한 답이 글에 포함되어있고, 마지막으로는 여러가지 앙상블 방법들을 보여줄 것이고, 스스로 시도해 볼 수 있도록 결과물과 코드들 역시 공유할 것입니다. 

"이것이야말로 당신이 머신러닝 대회를 우승할 수 있는 방법입니다. 다른 사람들의 결과물을 가져와서 앙상블하는것." 
- Vitaly Kuznetsov (NIPS2014)

 

제출 파일들로 앙상블 만들기

앙상블을 시도하는데 가장 편리하고 기본적인 방법은 캐글에 제출하는 csv 파일들을 사용하는 것입니다. test 데이터에 대한 예측값들만 필요하고, 새로운 모델을 학습시킬 필요가 없습니다. 이건 이미 존재하는 모델들의 예측값을 앙상블하기 가장 빠른 방법이고, 팀을 만들어 앙상블 하는 과정에 적합합니다.

투표 방법 (Voting Ensemble)

처음으로 시도할 만한 것은, 단순한 투표 방식입니다. 왜 모델 앙상블 방법이 에러를 줄여주고, 낮은 상관관계를 가진 예측값들에 더 잘 작동하는지 볼 것입니다.

우주에 관한 임무를 실행할 때에는 모든 신호(signal)들이 순서대로 잘 배치되는것이 중요합니다. 우리가 가진 신호가 다음과 같은 이진 문자열이라 해봅시다.

1110110011101111011111011011

만약 신호가 다음과 같이 망가졌다면, (1 한개가 0으로 바뀐 상황):

1010110011101111011111011011

하나의 생명을 잃을 수도 있습니다. 

에러 수정 코드에서 방법을 찾을 수 있습니다. 가장 단순한 방법은 repetition-code 라는 방법입니다. 이는 신호를 같은 크기의 덩어리로 여러 번 반복하고, 다수결 투표 방식을 취합니다. 

Original signal: 1110110011

Encoded: 10,3 101011001111101100111110110011

      Decoding: 1010110011

                       1110110011

                       1110110011

Majority vote: 1110110011

신호가 망가지는 것은 흔하게 발생하는 것은 아닙니다. 그래서 이러한 다수결 투표 방식이 망가진 신호를 '망가지게' 할 가능성은 더더욱 적습니다. 

이런식으로 신호가 망가지는것을 100% 예측할 수 없지는 않습니다. 따라서 이러한 방식으로, 망가진 신호를 원래의 신호로 고쳐질 수 있습니다.  

 

머신러닝에서의 예시

우리가 10개의 샘플을 가지고 있다고 가정합시다. 이 샘플들의 정답(ground truth)은 모두 1입니다. 

1111111111

그리고 우리는 세 개의 이진 분류기 (binary classifiers) 들이 있습니다. 각각의 모델은 70%의 정확도를 가지고 있습니다. 이 세 개의 분류기를 70%확률로 1을 뱉고, 30%의 확률로 0을 뱉는 난수 생성기로 볼 수도 있습니다. 이러한 랜덤 모델들도 다수결 투표 방법으로 78%의 정확도를 가지도록 할 수 있습니다. 약간의 수학을 더하자면 : 

3명의 다수결 투표 방식으로는 4가지의 결과값을 기대할 수 있습니다.

세개 모두 정답 : 0.7 * 0.7 * 0.7 = 0.3429

두개만 정답 :  0.7 * 0.7 * 0.3 + 0.7 * 0.3 * 0.7 + 0.3 * 0.7 * 0.7 = 0.4409

한개만 정답 : 0.3 * 0.3 * 0.7 + 0.3 * 0.7 * 0.3 + 0.7 * 0.3 * 0.3 = 0.189

셋 다 틀림 : 0.3 * 0.3 * 0.3 = 0.027

우리는 여기서 약 44%라는 많은 경우에 대해서 다수결 투표가 오답을 정답으로 고칠 수 있는 것을 알 수 있습니다. 이러한 투표는 정답률을 78%까지 올려줍니다. (0.3429 + 0.4409 = 0.7838)

 

투표 구성원 수

여러 번 코드가 반복될 수록 에러 수정 능력이 증가합니다. 마찬가지로, 앙상블 방법도 많은 앙상블 구성원이 있을 때 일반적으로 더 좋은 결과를 줍니다. 

위에서의 예시를 다시 가져온다면, 3개가 아니라 5개의 70% 정확도를 가진 모델을 앙상블한다면, 약 83%정도의 정확도를 기대할 수 있습니다. 한두 개의 오답은 66% 정도의 다수결 투표로 고쳐질 수 있습니다 (0.36015 + 0.3087)

 

상관관계 (Correlation)

KDD-cup 2014에 참가하면서 팀을 꾸렸는데, Marios Michailidis (KazAnova) 가 아주 특이한 것을 제안했습니다. 그는 우리가 제출했던 모든 파일의 피어슨 상관계수를 계산해서 상관관계가 적은 괜찮은 모델들을 모았습니다. 그리고 이들의 평균을 내서 제출하는 것 만으로 우리는 50등수를 올릴 수 있었습니다. 확실히 상관관계가 적은 결과물들이 앙상블 된 것이, 상관관계가 높은것들의 앙상블보다 좋은 결과를 보였는데, 왜일까요?

위에서와 마찬가지로 3가지의 모델 결과물들을 생각해봅시다. 예측해야 하는 정답은 여전히 1 들입니다.

1111111100 = 80% accuracy

1111111100 = 80% accuracy

1011111100 = 70% accuracy.

이 세 개의 모델은 상당히 상관관계가 있어서, 다수결 투표를 적용해도 전혀 발전이 없습니다 : 

1111111100 = 80% accuracy (다수결 투표 결과)

하지만, 성능은 좀 덜하지만, 낮은 상관관계가 있는 모델들을 생각해보면 다음과 같습니다. 

1111111100 = 80% accuracy

0111011101 = 70% accuracy

1000101111 = 60% accuracy

다수결 투표를 하면:

1111111101 = 90% accuracy (다수결 투표 결과)

엄청난 발전입니다. 앙상블 구성원의 상관관계가 낮을수록, 에러 수정 능력이 더 향상되는 것 같습니다. 

 

캐글 예시 : Forest Cover Type Predictions

다수결 투표 방식은 측정공식(evaluation metrics)이 더 정확한 답을 요구할 수록  빛을 발합니다. 예를 들면 '정확도' (accuracy) 가 있습니다.

 forest cover type prediction 대회는 UCI Forest CoverType dataset 을 활용하고 있습니다. 변수의 갯수는 54개, 정답에는 6개의 클래스가 있습니다.  우리는 일단 아주 간단한 모델 을 만들었습니다. 500개의 트리가 있는 랜덤 포레스트 모델입니다. 일단 이 모델을 몇 개 만든 이후, 가장 좋은 1개의 모델을 골랐습니다. 이 상황에서는 ExtraTreesClassifier로 만든 모델이 가장 좋았네요.  

 

가중 투표 (Weighing)

그리고 우리는 가중 투표 방식을 취했습니다. 이유는, 우리는 일반적으로 '더 잘 맞추는 모델' 에 더 가중치를 주고 싶습니다. 그래서 '가장 잘 맞추는 모델' 에게 3표를 주고, 나머지 4개의 모델에는 한표씩을 주었습니다. (역주 : 총 모델은 5개지만, 투표 수는 7표겠네요.) 이유는 다음과 같습니다 : 

'좋지 않은 모델'들이 가장 좋은 모델을 이길 수 있는 방법은 그들이 가진 예측값(오답)들을 모아서 확실한 다른 답(정답)을 내는 것이기 때문입니다. 

우리는 이 앙상블이 '가장 잘 맞추는 모델' 의 몇개의 잘못된 선택을 바로잡을 수 있을 것이라 기대할 수 있습니다. 아주 약간의 성능 개선이 있겠지요. 그것이 민주주의를 잃어버리고, 플라톤의 <국가>를 만든 것에 대한 우리의 벌칙이라 할 수 있겠습니다.

(역주 : <국가> 를 읽어보지 않아서 어떤 비유인지 잘 모르겠네요. 직역하긴 했지만 이상합니다.)

“모든 도시는 서로 전쟁중에 있는 두 도시를 포함한다.” 플라톤  <국가>

아래 표는 위에서 언급한 5개의 모델을 학습시키고, 가중 투표 방식을 적용한 결과물을 보여줍니다.

GradientBoostingMachine 0.65057
RandomForest Gini 0.75107
RandomForest Entropy 0.75222
ExtraTrees Entropy 0.75524
ExtraTrees Gini (Best) 0.75571
Voting Ensemble (Democracy) 0.75337
Voting Ensemble (3*Best vs. Rest) 0.75667

 

캐글 예시 : CIFAR-10 Object detection in images

CIFAR-10 은 정확도를 보는 또 다른 다중 분류 문제입니다. 우리 팀의 리더인 Phil Culliton이 Graham 박사의 좋은 모델 구조를 채용할 수 있는 모델을 만들었습니다. 그 후 90%의 정확도가 넘는 30개의 모델에 대해 다수결 투표 방식을 적용했습니다. 이중 가장 좋은 모델의 정확도는 0.93170 이었습니다. 

다수결 투표를 한 결과물의 점수는 0.94120 이었고, 이는 인간이 분류할 수 있는 정확도를 뛰어넘었습니다. 

 

코드 예시

우리는 MLWave의 깃헙 저장소에 샘플 투표 코드를 사용할 수 있게 두었습니다. 이것은 캐글 제출 파일 디렉토리에서 실행되고, 새로 결과물을 제출합니다. Update: Armando Segnini 가 투표에 가중치를 추가하였습니다. 

10개의 신경망을 만들고, 예측 결과를 평균해라. 이것은 상당히 단순한 방법이지만 결과를 상당히 향상시킬 수 있다. 

혹자는 왜 평균을 내는 것이 이렇게 도움이 되는가 신기해 할 수도 있지만, 평균이 효과가 있는 이유는 아주 단순하다. 
두 개의 모델이 70%의 정확도를 내고 있다고 가정하자. 그 두 모델의 합의는 옳을 때도 있지만 옳지 않다면, 어느 한쪽이 옳은 경우가 종종 있다. 이때 평균을 낸 결과물은 정답에 더 많은 가중치를 둔 결과가 된다. 

이 효과는 모델이 '맞출 것' 은 확실히 맞추지만 '틀린 것' 에 대해서 애매하게 찍고있는 경우 더 잘 작동하게 된다. 
Ilya Sutskever A brief overview of Deep Learning.

 

평균 (Averaging)

평균은 분류, 회귀 문제에 상관 없이, 측정 공식(AUC, squared error, log loss) 에 따라서도 별 상관 없이 다양한 분야의 문제에서 잘 작동하는 편입니다. 

각각의 모델의 결과물을 더해서 평균을 내는 것 이상의 대단한 것도 없습니다. 캐글에서 종종 "Bagging submissions" 라고 하는 것이 이것을 의미합니다.  평균을 내는 것은 종종 오버피팅을 줄여줍니다. 당신은 몇 개의 클래스를 '부드럽게' 분리해내고 싶을겁니다. (원 : smooth seperation) 하지만 단일 모델은 그 '결정 경계' (원 : edges) 가 그렇게 부드럽지 않을 수 있지요. 

 

이 캐글 대회(Don’t Overfit!) 을 보면 (역주 : 사진이 안들어가서 문장을 약간 수정했습니다), 검정색 선이 초록색 선보다 더 좋다고 볼 수 있습니다. 초록색 선은 데이터의 노이즈를 더 학습한 상태입니다. 여러개의 초록색 선을 구해서 평균을 낸다면, 우리가 얻을 결과값은 검정 선에 더 가까워질 것입니다. 

우리가 항상 기억해야 할 것은, 절대 학습데이터를 '외워'서는 안된다는 것입니다. 새로 보게 될 데이타에서도 '일반적으로' 잘 작동하는 모델을 만들어야 합니다. (데이터를 외울거면 더 효율적인 방법들이 있습니다)

 

캐글 예시 : Bag of Words Meets Bags of Popcorn

이 대회는 영화 감정 분석 대회 입니다. 이 전 게시물에서 퍼셉트론을 이용해서 95.2 의 AUC를 얻는 것에 대해 쓴 적이 있습니다. 

퍼셉트론은 '선형 분류' 모델입니다. 이건 참 환영할만한 특성이지만, 퍼셉트론은 데이터를 분류할 수 있게 되면 더이상 학습을 하지 않는다는 사실을 알아야 합니다. 이는 새로운 데이터에 대해서 '최고의 결정 경계' (원 : best seperation) 을 찾아주지는 않을 것입니다. 

우리가 5개의 퍼셉트론 모델을 랜덤한 가중치로 초기화하고, 그 예측 결과물의 평균을 구한다면 어떨까요? 우리는 test 데이터에 대해 성능 향상을 가져왔습니다.

PUBLIC AUC 점수

Perceptron 0.95288
Random Perceptron 0.95092
Random Perceptron 0.95128
Random Perceptron 0.95118
Random Perceptron 0.95072
Bagged Perceptrons 0.95427

위 결과는 앙상블 방법이 (임시적으로) 데이터의 세부적인 내용들과 특정 머신러닝 알고리즘의 내부 작동 원리들을 연구해야 하는 당신을 구했습니다. 만약 앙상블이 잘 된다면, 좋은것이고, 잘 되지 않더라도 크게 손해나는 것은 없습니다. 

그리고 당신은 10개의 '똑같은'  선형회귀 모델을 평균내는 것에 대한 페널티도 받지 않습니다. 한개의 '올바르지 못한 교차검증 방법으로 인해 과적합된 예시를 평균내는 것 조차도 평균의 다양성을 추가하는 것이기 때문에 약간의 성능 향상을 가져올 수도 있습니다. 

코드 예시

단순 평균 예시 코드를 깃헙에 올려두었습니다. csv 파일들의 경로를 인풋으로 받고, 평균된 결과물을 출력합니다. Update: Dat Le 가 기하 평균 예시 코드를 추가했습니다. 기하평균은 단순 평균(원 : plain average, 역주 : 산술평균)보다 더 좋을 수도 있습니다.  

 

순위 평균 (Rank averaging)

서로 다른 여러개의 모델의 결과물들의 평균을 구할때 몇 가지의 문제점들이 생길 수 있습니다. 모든 예측 모델이 완벽하게 보정되지 않았다는것인데, 각각은 예측해야 할 확률값들을 지나치게 과신하거나 불신할 수 있으며, 예측값들은 특정 범위에 대충 흩어져 있는 모양새일 수 있습니다. 아주 극단적인 제출 파일의 예시를 하나 적어보자면 다음과 같습니다:

Id,Prediction 

1,0.35000056 

2,0.35000002 

3,0.35000098 

4,0.35000111

이런 예측은 측정공식이 AUC 처럼 경계값(원 : threshold) 을 기반으로 하거나, 순위를 매기는 방식이라면 리더보드에서는 결과가 좋게 나올 것입니다. 하지만 이렇게 생긴 다른 모델의 예측값과 평균을 구한다면: 

Id,Prediction 

1,0.57 

2,0.04 

3,0.96 

4,0.99

결과는 전혀 바뀌지 않을 것입니다. 

(역주 : 각 행의 값들이 거의 비슷해서, 평균을 내도 두번째 파일의 값들의 분포와 비슷해집니다. 평균을 해서 값이 변해서 오답을 고치는 능력이 적어지겠네요.)

우리의 해결책은 예측값들의 '순위' 를 매기는 것입니다. 그리고 이 순위들의 평균을 구합니다. 

Id,Rank,Prediction 

1,1,0.35000056 

2,0,0.35000002 

3,2,0.35000098 

4,3,0.35000111

이렇게 평균을 낸 순위를 0과 1 사이의 값으로 normalize 하면, 아마 예측 결과물은 균등 분포(원 : even distribution)를 이룰 것입니다. 순위 평균의 결과를 보자면 : 

Id,Prediction 

1,0.33 

2,0.0 

3,0.66 

4,1.0

(역주 : 결과물을 보시면, 1 * 0.35000056  |  0 * 0.35000002  |  2 * 0.35000098  |  3 * 0.35000111 로 되어 있습니다)

 

Historical rank.

랭킹은 test 데이터셋이 필요합니다. 하지만 새로운 1개의 샘플에 대해서만 예측하기를 원한다면 어떻게 해야할까요? 아마도 예전에 쓰던 (역주 : 모델 검증에 사용하던) test 셋에 섞어서 랭킹을 구할 수도 있겠지만, 이것은 솔루션의 복잡도를 증가시킬 것입니다. 

한 가지 방법은 'Historical rank' 를 사용하는 것입니다. 우리가 원하는 새로운 샘플의 예측값이 '0.35000110' 이라면, test 셋의 예측값들 중에서 이 예측값과 가장 가까운 값을 찾아, 그 순위를 가져옵니다. (이 경우의 순위는 '3'을 가져오겠네요. 값이 0.35000111 로 가장 비슷합니다.)

캐글 예시 : Acquire Valued Shoppers Challenge

이 순위 평균 방식은 순위를 예측하는 경우나, AUC 같은 경계값이 있는 측정공식, 그리고 Average Precision at k 와 같은 'search-engine quality metrics' 에 잘 작동하는 편입니다. 

Shopper challenge 의 목표는 해당 고객이 다음에도 다시 올 가능성이 높은지 순위를 매기는 것입니다. 

우리 팀은 첫 번째로 여러 개의 'Vowpal Wabbit' 모델들과 'R GLMNet model' 의 평균을 구했습니다. 그 후에 순위 평균 방법을 적용해서 성능을 향상시켰습니다. 

MODEL     |          PUBLIC        |          PRIVATE

Vowpal Wabbit A 0.60764 0.59962
Vowpal Wabbit B 0.60737 0.59957
Vowpal Wabbit C 0.60757 0.59954
GLMNet 0.60433 0.59665
Average Bag 0.60795 0.60031
Rank average Bag 0.61027 0.60187

나는 전에 Avito challenge 에서 순위 평균으로 엄청난 성능 향상이 있었다는 것에 대해 글을 쓴 적이 있습니다. 

 

코드 예시

간단한 순위 평균 코드 를 MLWave 의 깃헙 저장소에 추가되었습니다. 

대회가 참 좋은 이유로는, 정말 여러 가지의 모델링 방법들이 있고, 적용할 수 있는 테크닉들도 다양한데, 우리는 어떤게 효과적이었는지 절대 미리 알 수가 없다는 점입니다. - Anthony Goldbloom Data Prediction Competitions — Far More than Just a Bit of Fun

 

From ‘How Scotch Blended Whisky is Made’ on Youtube

 

 

 

 

....계속...

 

Part 2 링크 : 

 

Kaggle Ensembling Guide (MLWave) / 한글 번역 / Part. 2 - (Stacking, Blending Ensemble)

Part 1 링크 : Kaggle Ensembling Guide(mlwave) / 캐글 앙상블 가이드 한글 번역 / Part. 1 원글은 다음 링크의 글입니다. Kaggle Ensembling Guide | MLWave Model ensembling is a very powerful technique to..

jamm-notnull.tistory.com

 

반응형
Posted by Jamm_
반응형

대회가 끝이 났다. 사실 끝이 난지는 좀 됐는데, 차일피일 미루다가 코드 리뷰를 이제야 쓰게 되었다. 

총 699팀 참가 신청, 315팀 제출, 그 중에서 17위. 처음으로 Top 10% 안에 들어서 랭킹 포인트...를 받았다!

결론을 말하자면, 내가 처음으로 랭킹 포인트를 받은 대회라서 기억에 오래 남을 것 같다. 캐글에 도전하면 세계의 고수들한테 매일같이 얻어터지고(?) 돌아오던 나였는데, 처음으로 상금은 아니지만 대회 보상으로 무언가를 받았다! 대회 끝나고, Private 리더보드가 공개되자마자 인스타에 기쁨의 절규글을 올리던 기억이 아직도 난다. 

 

생체 광학 데이터 분석 AI 경진대회

출처 : DACON - Data Science Competition

dacon.io

데이콘에서 무슨 이벤트? 라고 해서 후드집업이었나 를 준다 해서 바로 올렸지만, 블로그에 사적으로 코드를 올리는 것은 (내 코드여도) 뭔가 문제 될 수도 있을 것 같아서 혼자 겁먹고 기다리다가 드디어 쓴다. (약관 안 읽어보고 회원가입하다가 뒤통수 맞는 그런 느낌적인 느낌..?)

나름 도메인 지식을 찾아본다고 하긴 했는데, 다른 사람들의 코드를 보니까 좀 더 공부를 많이 해 볼걸 그랬다. 더이상 떠오르는 게 없어서 다짜고짜 스태킹 앙상블부터 시작했는데, 돌아보니까 이게 최고의 시간낭비이며, 점수를 더 올리지 못했던 원인인 것 같다. 'Good models means Good Features!'라는 캐글 짤방을 지나가다가 어디서 본 적이 있는 것 같은데, 이 대회로 피쳐 엔지니어링의 중요성을 다시 제대로 느낀 것 같다. 다음엔 더 잘해봐야지. 포기하지 말고...

대회 데이터에 대한 설명으로는...

 

생체 광학 데이터 분석 AI 경진대회

출처 : DACON - Data Science Competition

dacon.io

여기에 내가 생각한 대로 적어놓았다. 적외선 분광분석법을 이용해서, 뇌 내 성분(hhb, hbo2, ca, na)의 농도를 파악하는 것이다. 


 

1. Load Data

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

import time
import warnings ; warnings.filterwarnings('ignore')
from tqdm import tqdm

from sklearn.preprocessing import Normalizer
from sklearn.multioutput import MultiOutputRegressor
from scipy.stats import norm

from sklearn.model_selection import cross_val_score, train_test_split, cross_val_predict
from sklearn.metrics import mean_absolute_error
import random
import shap
import optuna

from lightgbm import LGBMRegressor
from xgboost import XGBRegressor
from catboost import CatBoostRegressor

from sklearn.tree import ExtraTreeRegressor

패키지를 불러온다. 

train = pd.read_csv('data/train.csv', index_col='id')
test = pd.read_csv('data/test.csv', index_col='id')
submission = pd.read_csv('data/sample_submission.csv', index_col='id')

feature_names=list(test)
target_names=list(submission)

Xtrain = train[feature_names]
Xtest = test[feature_names]

Ytrain=train[target_names]
Ytrain1=Ytrain['hhb']
Ytrain2=Ytrain['hbo2']
Ytrain3=Ytrain['ca']
Ytrain4=Ytrain['na']

처음에 대회 데이터를 보고 바로 신경망으로 접근해야겠다고 생각하고 MLP 모델을 만들기 시작했었다. 그런데 아무리 해도 점수가 1.6~1.7 이하로 잘 나오지가 않아서 Tree 모델 4개를 만들어야겠다고 생각했다. (그리고 이 생각이 들고 얼마 후에 데이콘에서 Baseline 코드가 올라왔는데, xgboost로 4번 fit 해서 제출하는데 내 신경망 점수를 바로 짓밟아버렸다...) 난 정말 신경망을 못 만드나 보다. 

아무튼 타겟변수가 4개라서 코드 쓰다가 꼬이거나 헷갈리지 않게 조심해야 할 것 같았다. 

 


 

2. Validation Strategy

base_params = {'num_leaves': 102, 'min_data_in_leaf': 82, 'n_estimators': 1998, 
               'learning_rate': 0.1070227129787253, 'colsample_bytree': 0.6692544288719608, 
               'subsample': 0.7459504136217274, 'reg_alpha': 0.0002735563924007907, 
               'reg_lambda': 0.0029420097457405594}

base_model = LGBMRegressor(n_jobs=-1, objective='l1', silent=False, subsample_freq=1, random_state=18, 
                           **base_params)

multi_model = MultiOutputRegressor(base_model)

기본 모델로 LightGBM과 sklearn의 MultiOutputRegressor 를 사용했다. sklearn 의 MultiOutputRegressor는 타겟 값이 여러 개일때 인자로 받은 한개의 모델을 여러 번 fit 시켜서 여러개 column 들을 뱉는 구조이다. 여기서는 타겟변수가 4개니까, 4번 학습을 한다. 파라미터를 대충 튜닝해서 살짝 underfitting 상태가 될 수 있도록 가볍게 튜닝을 하고, EDA 과정 중에서 반복적으로 사용했다. 이 'multi_model' 의 점수가 좋아지면 채택하고, 좋아지지 않으면 폐기하고 하는 식으로 반복했다. 

def model_scoring_cv(model, x, y, cv=10, verbose=False, n_jobs=None):
    start=time.time()
    score=-cross_val_score(model, x, y, cv=cv, scoring='neg_mean_absolute_error', verbose=verbose, 
                          n_jobs=n_jobs).mean()
    stop=time.time()
    print(f"Validation Time : {round(stop-start, 3)} sec")
    return score

model_scoring_cv 함수를 만들어서 10 fold 교차 검증을 할 수 있도록 했다. 여러번 제출한 결과, 10 fold 교차 검증 점수가 리더보드 점수와 크게 차이가 나지 않아서 이 점수를 믿고 사용하기로 결정했다. 

def train_test_identify(train_df, test_df, calculate_importance=True):
    model=LGBMClassifier(importance_type='gain')
    train_data=train_df.copy()
    test_data=test_df.copy()
    
    train_data['isTrain']=1
    test_data['isTrain']=0
    
    all_data=pd.concat((train_data, test_data))
    target=all_data['isTrain']
    all_data=all_data.drop(columns='isTrain')
    pred=cross_val_predict(model, all_data, target, cv=10)
    if calculate_importance == None:
        pass

    elif calculate_importance==False:
        print(classification_report(target, pred))
    
    else:
        print(classification_report(target, pred))
        model.fit(all_data, target)
        explainer=shap.TreeExplainer(model)
        shap_values=explainer.shap_values(all_data)
        shap.summary_plot(shap_values, all_data)
    return f1_score(target, pred)

Train 데이터와 Test 데이터의 차이를 보고자 만든 Adversial validation 함수이다. Train 데이터에 대해서는 1을 타겟값으로 가지고, Test 데이터는 0을 타겟값으로 가지고, 이를 머신러닝으로 예측한다. F1 Score와 AUC를 구해서, 이 값이 0.5에서 크게 벗어나면, 내가 방금 한 데이터 전처리가 Target Leakage를 가져왔구나 생각하고 수정했다. 


 

3. EDA

3.1 ) Fill NA

src_list=['650_src', '660_src', '670_src', '680_src', '690_src', '700_src', '710_src', '720_src', '730_src', 
          '740_src', '750_src', '760_src', '770_src', '780_src', '790_src', '800_src', '810_src', '820_src', 
          '830_src', '840_src', '850_src', '860_src', '870_src', '880_src', '890_src', '900_src', '910_src', 
          '920_src', '930_src', '940_src', '950_src', '960_src', '970_src', '980_src', '990_src']

dst_list=['650_dst', '660_dst', '670_dst', '680_dst', '690_dst', '700_dst', '710_dst', '720_dst', '730_dst', 
          '740_dst', '750_dst', '760_dst', '770_dst', '780_dst', '790_dst', '800_dst', '810_dst', '820_dst', 
          '830_dst', '840_dst', '850_dst', '860_dst', '870_dst', '880_dst', '890_dst', '900_dst', '910_dst', 
          '920_dst', '930_dst', '940_dst', '950_dst', '960_dst', '970_dst', '980_dst', '990_dst']
          
alpha=Xtrain[dst_list]
beta=Xtest[dst_list]

for i in tqdm(Xtrain.index):
    alpha.loc[i] = alpha.loc[i].interpolate()
    
for i in tqdm(Xtest.index):
    beta.loc[i] = beta.loc[i].interpolate()
    
alpha.loc[alpha['700_dst'].isnull(),'700_dst']=alpha.loc[alpha['700_dst'].isnull(),'710_dst']
alpha.loc[alpha['690_dst'].isnull(),'690_dst']=alpha.loc[alpha['690_dst'].isnull(),'700_dst']
alpha.loc[alpha['680_dst'].isnull(),'680_dst']=alpha.loc[alpha['680_dst'].isnull(),'690_dst']
alpha.loc[alpha['670_dst'].isnull(),'670_dst']=alpha.loc[alpha['670_dst'].isnull(),'680_dst']
alpha.loc[alpha['660_dst'].isnull(),'660_dst']=alpha.loc[alpha['660_dst'].isnull(),'670_dst']
alpha.loc[alpha['650_dst'].isnull(),'650_dst']=alpha.loc[alpha['650_dst'].isnull(),'660_dst']

beta.loc[beta['700_dst'].isnull(),'700_dst']=beta.loc[beta['700_dst'].isnull(),'710_dst']
beta.loc[beta['690_dst'].isnull(),'690_dst']=beta.loc[beta['690_dst'].isnull(),'700_dst']
beta.loc[beta['680_dst'].isnull(),'680_dst']=beta.loc[beta['680_dst'].isnull(),'690_dst']
beta.loc[beta['670_dst'].isnull(),'670_dst']=beta.loc[beta['670_dst'].isnull(),'680_dst']
beta.loc[beta['660_dst'].isnull(),'660_dst']=beta.loc[beta['660_dst'].isnull(),'670_dst']
beta.loc[beta['650_dst'].isnull(),'650_dst']=beta.loc[beta['650_dst'].isnull(),'660_dst']

Xtrain[dst_list] = np.array(alpha)
Xtest[dst_list] = np.array(beta)

가만히 보면 dst column 들에 결측치가 있었다. 이 결측치가 수상한 것이, 650nm의 값은 있고 660nm 은 비어있고, 670nm 은 있고, 하는 식이었다. 중간에도 텅 비어있었다. 그래서 행 별로 결측치를 interpolate 해서 사용했다. 

3.2 ) rho

for col in dst_list:
    Xtrain[col] = Xtrain[col] * (Xtrain['rho'] ** 2)
    Xtest[col] = Xtest[col] * (Xtest['rho']**2)

빛의 측정 강도는 측정 거리의 제곱에 반비례하기 때문에, 측정 거리를 제곱해서 곱해주었다. 이렇게 되면, 같은 파장대의 빛을 쏘더라도, 어떤 실험에서는 측정값이 크고, 어떤 실험에서는 측정값이 작게 나올 수 있는 상황을 보정해줄 수 있다고 생각했다.

3.3 ) ratio

epsilon=1e-10    #prevent division by zero

for dst_col, src_col in zip(dst_list, src_list):
    dst_val=Xtrain[dst_col]
    src_val=Xtrain[src_col] + epsilon
    delta_ratio = dst_val / src_val
    Xtrain[dst_col + '_' + src_col + '_ratio'] = delta_ratio
    
    dst_val=Xtest[dst_col]
    src_val=Xtest[src_col] + epsilon
    
    delta_ratio = dst_val / src_val
    Xtest[dst_col + '_' + src_col + '_ratio'] = delta_ratio
    
print(Xtrain.shape, Xtest.shape)

적외선 분광분석법 실험은 흡수 분광분석 실험이므로, 처음 쏜 빛에 비해서 특정 파장이 몇% 감소했는지를 확인하기 위해 원래 파장과 측정 파장의 비율을 변수로 추가했다. 

3.4 ) gap

from sklearn.preprocessing import Normalizer
normalizer=Normalizer()

all_data = pd.concat((Xtrain, Xtest))
data = all_data.copy()

all_data[src_list] = normalizer.fit_transform(all_data[src_list])
Xtrain[src_list] = all_data[:len(Ytrain)][src_list]
Xtest[src_list] = all_data[len(Ytrain):][src_list]

del all_data

gap_feature_names=[]
for i in range(650, 1000, 10):
    gap_feature_names.append(str(i) + '_gap')

alpha=pd.DataFrame(np.array(Xtrain[src_list]) - np.array(Xtrain[dst_list]), columns=gap_feature_names, index=train.index)
beta=pd.DataFrame(np.array(Xtest[src_list]) - np.array(Xtest[dst_list]), columns=gap_feature_names, index=test.index)

Xtrain=pd.concat((Xtrain, alpha), axis=1)
Xtest=pd.concat((Xtest, beta), axis=1)

print(Xtrain.shape, Ytrain.shape, Xtest.shape)

마찬가지로 원래 파장과 측정된 파장의 차이를 변수로 추가했다. 

실험을 하면서, 데이터의 Normalization 이 필요할 것 같다고 생각했었다. 원래 파장은 값이 잘 나와있는 반면, 측정 파장은 대부분 값이 막 1e-10까지 작아서, 비교했을 때 너무 형편없는 수준이 되지 않을까... 해서, Normalize 하고 비율을 구하고, 차이를 구해보고, 아니면 차이를 구하고 Normalize 해보고 했는데, 이렇게 Normalize 하고 차이를 구하는 게 Validation 점수가 제일 잘 나왔었다. 알고 했다기보다는, 검증 점수 잘 나오는 방향으로 계속 데이터를 수정했다고 봐야 맞을 것 같다. 

3.5 ) DFT

alpha_real=Xtrain[dst_list]
alpha_imag=Xtrain[dst_list]

beta_real=Xtest[dst_list]
beta_imag=Xtest[dst_list]

for i in tqdm(alpha_real.index):
    alpha_real.loc[i]=alpha_real.loc[i] - alpha_real.loc[i].mean()
    alpha_imag.loc[i]=alpha_imag.loc[i] - alpha_real.loc[i].mean()
    
    alpha_real.loc[i] = np.fft.fft(alpha_real.loc[i], norm='ortho').real
    alpha_imag.loc[i] = np.fft.fft(alpha_imag.loc[i], norm='ortho').imag

    
for i in tqdm(beta_real.index):
    beta_real.loc[i]=beta_real.loc[i] - beta_real.loc[i].mean()
    beta_imag.loc[i]=beta_imag.loc[i] - beta_imag.loc[i].mean()
    
    beta_real.loc[i] = np.fft.fft(beta_real.loc[i], norm='ortho').real
    beta_imag.loc[i] = np.fft.fft(beta_imag.loc[i], norm='ortho').imag
    
real_part=[]
imag_part=[]

for col in dst_list:
    real_part.append(col + '_fft_real')
    imag_part.append(col + '_fft_imag')
    
alpha_real.columns=real_part
alpha_imag.columns=imag_part
alpha = pd.concat((alpha_real, alpha_imag), axis=1)

beta_real.columns=real_part
beta_imag.columns=imag_part
beta=pd.concat((beta_real, beta_imag), axis=1)


Xtrain=pd.concat((Xtrain, alpha), axis=1)
Xtest=pd.concat((Xtest, beta), axis=1)
print(Xtrain.shape, Ytrain.shape, Xtest.shape)

이산푸리에 변환을 적용한 값을 추가해주었다. 여기서 실수를 한 것이, 푸리에 변환 이후 나오는 결과물 (복소수) 에 abs 를 씌우면 되는걸, 괜히 실수부와 허수부로 쪼개서 변수를 70개를 추가해버렸다. 물론 안해봐서 정확한 결과는 모르지만, 아무리 생각해도 실수였던 것 같다. 대회 끝나고, 다른 대회의 다른 사람이 코드 공개한 걸 보았는데, 복소수에 abs 씌우면 되는걸.... 난 왜 이렇게 했을까, 대회가 끝나고 알게돼서 어쩔 수 없었다. 

처음에 푸리에 변환을 여기서 써도 되나 싶어서 열심히 검색을 했었다. 시중에는 수많은 FTIR 장비들이 돌아다니고 있었고, 시계열 데이터가 아닌 경우에 푸리에변환을 사용하는 경우들을 확인할 수 있었고, 아 괜찮겠다 싶어서 바로 적용했다. 그리고 그 코드를 EDA 결과물이라고 코드 공유를 했는데... '왜 시계열이 아닌데 푸리에변환 썼냐, 뭔지도 모르는 데이터를 사용하는 건 개오바 아니냐, 열 별로 값을 보면 정규분포 아니냐' 하고 태클을 거시는 한 분이 있었는데, 이제 말하지만 이 분 때문에 너무 스트레스를 받았고 코드 그냥 삭제할까 싶었고, 괜히 올렸다 생각만 정말 많이 들었었다. 댓글 달면서 쓸 시간 없는데.. 코드 한 줄이라도 더 쓸 시간인데.. 

3.6 ) Remove src

Xtrain=Xtrain.drop(columns=src_list)
Xtest=Xtest.drop(columns=src_list)

print(Xtrain.shape, Ytrain.shape, Xtest.shape)

원래 파장인 src 열들을 지워주었다.

3.7 ) Target Variable 

figure, ((ax1, ax2), (ax3, ax4)) = plt.subplots(nrows=2, ncols=2)
figure.set_size_inches(14, 14)
sns.distplot(Ytrain['hhb'], ax=ax1, fit=norm)
sns.distplot(Ytrain['hbo2'], ax=ax2, fit=norm)
sns.distplot(Ytrain['ca'], ax=ax3, fit=norm)
sns.distplot(Ytrain['na'], ax=ax4, fit=norm)

4개 타겟 변수 분포 확인

대부분 정규분포에 가깝게 모여있는 것을 확인했다. 로그를 취할까 말까 하다가 그냥 학습하기로 했다. 

3.8 ) Normalize

normalizer=Normalizer()
dst_features=[]
gap_features=[]
ratio_features=[]
real_features=[]
imag_features=[]

for col in list(Xtrain):
    if col[-4:] == '_dst':
        dst_features.append(col)
    elif col[-4:] == 'atio':
        ratio_features.append(col)
    elif col[-4:] == '_gap':
        gap_features.append(col)
    elif col[-4:] == 'real':
        real_features.append(col)
    elif col[-4:] == 'imag':
        imag_features.append(col)
    else:
        pass
print(len(dst_features + gap_features + ratio_features + real_features + imag_features) +1 == len(list(Xtrain)))

all_data = pd.concat((Xtrain, Xtest))
all_data[dst_features] = normalizer.fit_transform(all_data[dst_features])
all_data[imag_features] = normalizer.fit_transform(all_data[imag_features])
all_data[real_features] = normalizer.fit_transform(all_data[real_features])
all_data[gap_features] = normalizer.fit_transform(all_data[gap_features])

Xtrain = all_data[:len(Ytrain)]
Xtest = all_data[len(Ytrain):]

print(Xtrain.shape, Ytrain.shape, Xtest.shape)
#(10000, 176) (10000, 4) (10000, 176)

변수들을 구분해서 Normalize 했다. 아무래도 스케일이 맞아야 더 잘 학습이 될 것 같았다. 결과적으로 총 176개의 변수로 학습을 하기로 결정했다. 


 

4. Feature Selection

Xtrain1 = Xtrain.copy()
Xtrain2 = Xtrain.copy()
Xtrain3 = Xtrain.copy()
Xtrain4 = Xtrain.copy()

Xtest1 = Xtest.copy()
Xtest2 = Xtest.copy()
Xtest3 = Xtest.copy()
Xtest4 = Xtest.copy()

타겟변수가 4개이므로, 4개의 데이터 사본을 만들었다. 각각의 타겟 변수에 따라서 주로 사용할 파장 영역이 다를 것 같다 생각해서 Feature Selection을 다르게 진행하기 위해서였다. 

def apply_chromosome(chromosome, x_train):
    df = x_train.copy()
    for col, apply in zip(x_train.columns, chromosome):
        if apply == 0:
            df = df.drop(columns=col)
    return df


def scoring(model, x_train, y_train):
    return -cross_val_score(model, x_train, y_train, cv=5, scoring='neg_mean_absolute_error').mean()


def make_chromosome_score(chromosome, model, x_train, y_train):
    data= apply_chromosome(chromosome, x_train)
    value = scoring(model, data, y_train)
    return value


def choose_chromosomes(chromosomes_list, scored_list, factor, num_choose):
    chromosome_pool = chromosomes_list.copy()
    score_list = scored_list.copy()
    
    chosen_chromosome=[]
    
    new_score_array=np.array(score_list) + np.random.normal(0, np.array(score_list).std() * factor, size=len(score_list))
    new_score_list = list(new_score_array)
    
    
    for i in range(num_choose):
        select_index = np.argmin(new_score_list)
        selected = chromosome_pool[select_index]
        del chromosome_pool[select_index]
        del new_score_list[select_index]
        
        new_score_array = np.array(new_score_list)
        
        chosen_chromosome.append(selected)
    return chosen_chromosome
    
def crossover(chromosome_list, score_list, next_pool_size, factor, num_choose):
    child_pool = []
    while len(child_pool) < next_pool_size:
        best_parents=choose_chromosomes(chromosome_list, score_list, factor=factor, num_choose=num_choose)
        parents = random.sample(best_parents, 2)
        mom = parents[0]
        dad = parents[1]
        inherit = np.round(np.random.uniform(low=0, high=1, size=len(mom)))
        child = []

        for dna, i in zip(inherit, range(len(inherit))):
            if dna==0:
                child.append(mom[i])
            else:
                child.append(dad[i])
        child_pool.append(child)
    return child_pool

def mutant(pool, mutation_rate, mutation_size):
    to_be_mutant = np.round(np.random.uniform(low=0, high=1, size=len(pool)) - (0.5 - mutation_rate))
    which_child = np.where(to_be_mutant==1)[0]
    for child_index in which_child:
        mutation_info = np.round(np.random.uniform(low=0, high=1, size=len(to_be_mutant))-(0.5-mutation_size))
        mutation_info = np.where(mutation_info ==1)[0]

        for is_mutated in mutation_info:

            if pool[child_index][is_mutated]==0:
                pool[child_index][is_mutated] = 1
            elif pool[child_index][is_mutated]==1:
                pool[child_index][is_mutated] = 0
    return pool
    
    
def next_generation(model, x_train, y_train, first_gen_chromosome, next_pool_size, factor, num_choose_parents, 
                   mutation_rate, mutation_size):
    score_list=[]
    for chromosome in tqdm(first_gen_chromosome):
        score = make_chromosome_score(chromosome, model, x_train, y_train)
        score_list.append(score)
    print("Calculating Current Generation's Score")
        
    current_generation_best_chromosome = first_gen_chromosome[np.argmin(score_list)]
    current_generation_best_score = np.min(score_list)
    current_generation_mean_score = np.mean(score_list)


    print(current_generation_best_chromosome)
    print(f"\nBest Score : {current_generation_best_score}")

    print("Generating Child pool")
    child_pool = crossover(first_gen_chromosome, score_list, next_pool_size=next_pool_size, 
                           factor=factor, num_choose=num_choose_parents)
    print("Generating Mutation childs")
    next_generation_chromosomes = mutant(child_pool, mutation_rate=mutation_rate, mutation_size=mutation_size)
    
    print('Done!')
    return next_generation_chromosomes, current_generation_best_chromosome, \
                current_generation_best_score, current_generation_mean_score
                
def feature_select_ga(model, x_train, y_train, num_epoch, pool_size, factor=0.1, 
                      num_choose_parents=3, mutation_rate=0.05, mutation_size=0.05, plot_score = True):
    
    #factor : percentage of noise added to calculated score
    #if too large, bad scoring parents could be selected since score order changes
    
    #mutation_rate : percentage of childs to be mutated
    #mutation_size : percentage of genes to be mutated
    
    pool_size = pool_size
    pool=[]
    
    for i in range(pool_size):
        chromosome = np.round(np.random.uniform(low=0, high=1, size=x_train.shape[1]))
        pool.append(chromosome)
    
    best_features=[]
    best_scores=[]
    mean_scores=[]
    
    for i in tqdm(range(num_epoch)):
        pool, gen_best_features, gen_best_score, gen_mean_score = next_generation(model, x_train, y_train, pool, 
                                                                            next_pool_size=pool_size, 
                                                                            factor = factor, 
                                                                            num_choose_parents=num_choose_parents, 
                                                                            mutation_rate=mutation_rate, 
                                                                            mutation_size=mutation_size)
        best_features.append(gen_best_features)
        best_scores.append(gen_best_score)
        mean_scores.append(gen_mean_score)


    
    final_score_list=[]
    for chromosome in tqdm(pool):
        score = make_chromosome_score(chromosome, base_model, x_train, y_train)
        final_score_list.append(score)

    last_generation_best_chromosome = pool[np.argmin(final_score_list)]
    last_generation_best_score = np.min(final_score_list)
    last_generation_mean_score = np.mean(final_score_list)

    best_features.append(last_generation_best_chromosome)
    best_scores.append(last_generation_best_score)
    mean_scores.append(last_generation_mean_score)
    
    if plot_score==True:
        figure, ax1 = plt.subplots(nrows=1, ncols=1)
        figure.set_size_inches(10, 7)
        sns.lineplot(data=np.array(best_scores), ax=ax1, label='best')
        sns.lineplot(data=np.array(mean_scores), ax=ax1, label='mean')
        
    best_chromosome = best_features[np.argmin(best_scores)]

    df = apply_chromosome(best_chromosome, x_train)
    print(f"best_features = {list(df)}")
    return df

유전 알고리즘이란 것을 처음 들어보았다. 나무위키에 쓰여있는 유전 알고리즘 설명을 보고 대충 구현해 보았다. 사용하는 변수는 1, 사용하지 않는 변수는 0을 주고, 최고의 [1,0,0,0,1,1,1,1,1,0....]을 찾은 다음에 데이터에 적용시키면 각각의 타겟변수에 대해 필요한 변수를 잘 선택할 수 있을 거라고 생각했다. 

#Xtrain1 = feature_select_ga(base_model, Xtrain1, Ytrain1, pool_size=15, num_epoch=20,
#                           num_choose_parents=4, mutation_rate=0.1, mutation_size=0.1)
#Xtest1 = Xtest1[list(Xtrain1)]
best_features = ['rho', '650_dst', '680_dst', '770_dst', '790_dst', '810_dst', '830_dst', '840_dst', '850_dst', '860_dst', '880_dst', '890_dst', '930_dst', '960_dst', '980_dst', '650_dst_650_src_ratio', '660_dst_660_src_ratio', '690_dst_690_src_ratio', '720_dst_720_src_ratio', '760_dst_760_src_ratio', '770_dst_770_src_ratio', '800_dst_800_src_ratio', '810_dst_810_src_ratio', '830_dst_830_src_ratio', '840_dst_840_src_ratio', '850_dst_850_src_ratio', '860_dst_860_src_ratio', '870_dst_870_src_ratio', '920_dst_920_src_ratio', '930_dst_930_src_ratio', '940_dst_940_src_ratio', '990_dst_990_src_ratio', '650_gap', '700_gap', '720_gap', '790_gap', '800_gap', '820_gap', '840_gap', '860_gap', '910_gap', '920_gap', '930_gap', '940_gap', '950_gap', '960_gap', '980_gap', '660_dst_fft_real', '670_dst_fft_real', '680_dst_fft_real', '690_dst_fft_real', '720_dst_fft_real', '740_dst_fft_real', '780_dst_fft_real', '800_dst_fft_real', '810_dst_fft_real', '840_dst_fft_real', '850_dst_fft_real', '870_dst_fft_real', '890_dst_fft_real', '910_dst_fft_real', '920_dst_fft_real', '930_dst_fft_real', '950_dst_fft_real', '650_dst_fft_imag', '670_dst_fft_imag', '730_dst_fft_imag', '770_dst_fft_imag', '780_dst_fft_imag', '840_dst_fft_imag', '850_dst_fft_imag', '860_dst_fft_imag', '890_dst_fft_imag', '900_dst_fft_imag', '920_dst_fft_imag', '960_dst_fft_imag', '980_dst_fft_imag', '990_dst_fft_imag']
Xtrain1 = Xtrain1[best_features]
Xtest1 = Xtest1[best_features]
print(Xtrain1.shape, Ytrain1.shape, Xtest1.shape)


#Xtrain2 = feature_select_ga(base_model, Xtrain2, Ytrain2, pool_size=15, num_epoch=20, 
#                            num_choose_parents=4, mutation_rate=0.1, mutation_size=0.1)
#Xtest2 = Xtest2[list(Xtrain2)]
best_features = ['rho', '670_dst', '730_dst', '750_dst', '770_dst', '780_dst', '790_dst', '800_dst', '830_dst', '870_dst', '890_dst', '900_dst', '950_dst', '960_dst', '970_dst', '980_dst', '990_dst', '670_dst_670_src_ratio', '730_dst_730_src_ratio', '740_dst_740_src_ratio', '750_dst_750_src_ratio', '780_dst_780_src_ratio', '790_dst_790_src_ratio', '800_dst_800_src_ratio', '830_dst_830_src_ratio', '840_dst_840_src_ratio', '850_dst_850_src_ratio', '890_dst_890_src_ratio', '910_dst_910_src_ratio', '920_dst_920_src_ratio', '930_dst_930_src_ratio', '950_dst_950_src_ratio', '970_dst_970_src_ratio', '990_dst_990_src_ratio', '650_gap', '670_gap', '690_gap', '770_gap', '780_gap', '790_gap', '800_gap', '810_gap', '820_gap', '960_gap', '980_gap', '990_gap', '660_dst_fft_real', '670_dst_fft_real', '730_dst_fft_real', '750_dst_fft_real', '770_dst_fft_real', '800_dst_fft_real', '810_dst_fft_real', '830_dst_fft_real', '880_dst_fft_real', '930_dst_fft_real', '950_dst_fft_real', '960_dst_fft_real', '970_dst_fft_real', '980_dst_fft_real', '990_dst_fft_real', '650_dst_fft_imag', '670_dst_fft_imag', '680_dst_fft_imag', '700_dst_fft_imag', '720_dst_fft_imag', '730_dst_fft_imag', '740_dst_fft_imag', '750_dst_fft_imag', '780_dst_fft_imag', '790_dst_fft_imag', '840_dst_fft_imag', '850_dst_fft_imag', '860_dst_fft_imag', '870_dst_fft_imag', '880_dst_fft_imag', '920_dst_fft_imag', '930_dst_fft_imag', '940_dst_fft_imag', '950_dst_fft_imag', '960_dst_fft_imag']
Xtrain2 = Xtrain2[best_features]
Xtest2 = Xtest2[best_features]
print(Xtrain2.shape, Ytrain2.shape, Xtest2.shape)


#Xtrain3 = feature_select_ga(base_model, Xtrain3, Ytrain3, pool_size=15, num_epoch=20, 
#                           num_choose_parents=4, mutation_rate=0.1, mutation_size=0.1)
#Xtest3 = Xtest3[list(Xtrain3)]
best_features = ['rho', '670_dst', '680_dst', '700_dst', '750_dst', '760_dst', '780_dst', '800_dst', '810_dst', '820_dst', '840_dst', '850_dst', '860_dst', '880_dst', '910_dst', '930_dst', '940_dst', '950_dst', '960_dst', '970_dst', '980_dst', '650_dst_650_src_ratio', '670_dst_670_src_ratio', '690_dst_690_src_ratio', '710_dst_710_src_ratio', '720_dst_720_src_ratio', '740_dst_740_src_ratio', '750_dst_750_src_ratio', '770_dst_770_src_ratio', '830_dst_830_src_ratio', '840_dst_840_src_ratio', '850_dst_850_src_ratio', '860_dst_860_src_ratio', '870_dst_870_src_ratio', '900_dst_900_src_ratio', '950_dst_950_src_ratio', '960_dst_960_src_ratio', '980_dst_980_src_ratio', '680_gap', '690_gap', '740_gap', '760_gap', '770_gap', '790_gap', '800_gap', '820_gap', '830_gap', '840_gap', '860_gap', '870_gap', '890_gap', '910_gap', '920_gap', '940_gap', '960_gap', '980_gap', '990_gap', '710_dst_fft_real', '720_dst_fft_real', '730_dst_fft_real', '740_dst_fft_real', '750_dst_fft_real', '760_dst_fft_real', '770_dst_fft_real', '780_dst_fft_real', '810_dst_fft_real', '820_dst_fft_real', '830_dst_fft_real', '840_dst_fft_real', '850_dst_fft_real', '860_dst_fft_real', '870_dst_fft_real', '920_dst_fft_real', '930_dst_fft_real', '950_dst_fft_real', '970_dst_fft_real', '980_dst_fft_real', '660_dst_fft_imag', '670_dst_fft_imag', '690_dst_fft_imag', '720_dst_fft_imag', '730_dst_fft_imag', '740_dst_fft_imag', '750_dst_fft_imag', '770_dst_fft_imag', '780_dst_fft_imag', '790_dst_fft_imag', '800_dst_fft_imag', '810_dst_fft_imag', '830_dst_fft_imag', '860_dst_fft_imag', '880_dst_fft_imag', '890_dst_fft_imag', '930_dst_fft_imag', '940_dst_fft_imag', '950_dst_fft_imag', '960_dst_fft_imag', '980_dst_fft_imag']
Xtrain3 = Xtrain[best_features]
Xtest3 = Xtest[best_features]
print(Xtrain3.shape, Ytrain3.shape, Xtest3.shape)


#Xtrain4 = feature_select_ga(base_model, Xtrain4, Ytrain4, pool_size=15, num_epoch=20, 
#                           num_choose_parents=4, mutation_rate=0.1, mutation_size=0.1)
#Xtest4 = Xtest4[list(Xtrain4)]
best_features = ['rho', '670_dst', '680_dst', '690_dst', '720_dst', '740_dst', '750_dst', '760_dst', '780_dst', '790_dst', '830_dst', '840_dst', '850_dst', '870_dst', '880_dst', '890_dst', '900_dst', '910_dst', '920_dst', '950_dst', '970_dst', '980_dst', '650_dst_650_src_ratio', '700_dst_700_src_ratio', '710_dst_710_src_ratio', '720_dst_720_src_ratio', '730_dst_730_src_ratio', '740_dst_740_src_ratio', '760_dst_760_src_ratio', '770_dst_770_src_ratio', '780_dst_780_src_ratio', '800_dst_800_src_ratio', '810_dst_810_src_ratio', '830_dst_830_src_ratio', '840_dst_840_src_ratio', '850_dst_850_src_ratio', '860_dst_860_src_ratio', '870_dst_870_src_ratio', '880_dst_880_src_ratio', '890_dst_890_src_ratio', '910_dst_910_src_ratio', '920_dst_920_src_ratio', '930_dst_930_src_ratio', '980_dst_980_src_ratio', '660_gap', '680_gap', '690_gap', '700_gap', '710_gap', '770_gap', '780_gap', '850_gap', '860_gap', '880_gap', '900_gap', '910_gap', '920_gap', '930_gap', '950_gap', '650_dst_fft_real', '670_dst_fft_real', '680_dst_fft_real', '690_dst_fft_real', '710_dst_fft_real', '720_dst_fft_real', '740_dst_fft_real', '760_dst_fft_real', '810_dst_fft_real', '840_dst_fft_real', '850_dst_fft_real', '860_dst_fft_real', '890_dst_fft_real', '900_dst_fft_real', '940_dst_fft_real', '970_dst_fft_real', '980_dst_fft_real', '660_dst_fft_imag', '670_dst_fft_imag', '710_dst_fft_imag', '720_dst_fft_imag', '730_dst_fft_imag', '770_dst_fft_imag', '780_dst_fft_imag', '790_dst_fft_imag', '810_dst_fft_imag', '870_dst_fft_imag', '890_dst_fft_imag', '910_dst_fft_imag', '940_dst_fft_imag', '950_dst_fft_imag', '970_dst_fft_imag']
Xtrain4 = Xtrain4[best_features]
Xtest4 = Xtest4[best_features]
print(Xtrain4.shape, Ytrain4.shape, Xtest4.shape)

#(10000, 78) (10000,) (10000, 78)
#(10000, 81) (10000,) (10000, 81)
#(10000, 98) (10000,) (10000, 98)
#(10000, 91) (10000,) (10000, 91)

4개 데이터에 대해서 모두 적용하면 약 21시간이 걸린다. 뭐라고? 그래서 매번 실행할 수가 없어서 실행하고, 결과물을 복사해두었다. 각각의 타겟별로 총 4개의 Xtrain 들을 만들었다. 


 

5. Build Models

5.1 ) LightGBM

lgb_params1 = {'n_estimators': 2785, 'learning_rate': 0.012256313719980687, 'num_leaves': 73, 
               'colsample_bytree': 0.6424334465587705, 'subsample': 0.6084147966276774, 
               'reg_alpha': 4.334078134974237e-05, 'reg_lambda': 0.00024427748977268426, 'min_data_in_leaf': 16}
lgb1 = LGBMRegressor(subsample_freq=1, silent=False, random_state=18, **lgb_params1)
lgb_score1 = model_scoring_cv(lgb1, Xtrain1, Ytrain1)
print(f"lgb score 1 : {lgb_score1}")

#Validation Time : 312.063 sec
#lgb score 1 : 0.6450181806462878

lgb_params2 = {'n_estimators': 2994, 'learning_rate': 0.02064007127149768, 'num_leaves': 36, 
               'colsample_bytree': 0.9662203993996966, 'subsample': 0.8235831321555162, 
               'reg_alpha': 9.361670014148117e-10, 'reg_lambda': 0.014778229454777763, 'min_data_in_leaf': 69}
lgb2=LGBMRegressor(subsample_freq=1, silent=False, random_state=18, **lgb_params2)
lgb_score2 = model_scoring_cv(lgb2, Xtrain2, Ytrain2)
print(f"lgb score 2 : {lgb_score2}")

#Validation Time : 242.814 sec
#lgb score 2 : 0.47096186669621254

lgb_params3 = {'n_estimators': 2351, 'learning_rate': 0.0142123934679665, 'num_leaves': 60, 
               'colsample_bytree': 0.8200484609355502, 'subsample': 0.8682071101415381, 
               'reg_alpha': 0.019504059762055263, 'reg_lambda': 6.760777234202785e-07, 'min_data_in_leaf': 58}
lgb3=LGBMRegressor(subsample_freq=1, silent=False, random_state=18, **lgb_params3)
lgb_score3 = model_scoring_cv(lgb3, Xtrain3, Ytrain3)
print(f"lgb score 3 : {lgb_score3}")

#Validation Time : 301.473 sec
#lgb score 3 : 1.519919905631499

lgb_params4 = {'n_estimators': 2036, 'learning_rate': 0.010105984741650555, 'num_leaves': 262, 
               'colsample_bytree': 0.5481503221078929, 'subsample': 0.6283957235662508, 
               'reg_alpha': 1.3143634189790955e-06, 'reg_lambda': 1.4769566595629849e-05, 
               'min_data_in_leaf': 86}
lgb4=LGBMRegressor(subsample_freq=1, silent=False, random_state=18, **lgb_params4)
lgb_score4 = model_scoring_cv(lgb4, Xtrain4, Ytrain4)
print(f"lgb score 4 : {lgb_score4}")

#Validation Time : 141.399 sec
#lgb score 4 : 1.0987161424910443

print(f"LightGBM Mean CV Score : {(lgb_score1 + lgb_score2 + lgb_score3 + lgb_score4)/4}"
#LightGBM Mean CV Score : 0.933654023866261

4개의 LightGBM 모델을 만들었다. 마찬가지로 XGBoost와 CatBoost 도 4개씩 만들었다. 

5.2 ) XGBoost

xgb_params1 = {'n_estimators': 2881, 'learning_rate': 0.011711857673636635, 'max_depth': 8, 
               'colsample_bytree': 0.9827808935047092, 
               'subsample': 0.5710690334832227, 'reg_alpha': 1.3854966003147966, 
               'reg_lambda': 0.0003816097499332342}
xgb1 = XGBRegressor(verbosity=0, random_state=18, objective = 'reg:squarederror', eval_metric='mae', 
                    **xgb_params1, 
                   sampling_method='gradient_based', tree_method='gpu_hist'
                   )
xgb_score1 = model_scoring_cv(xgb1, Xtrain1, Ytrain1)
print(f"xgb score 1 : {xgb_score1}")

#Validation Time : 974.623 sec
#xgb score 1 : 0.6597893721626996

xgb_params2 = {'n_estimators': 2865, 'learning_rate': 0.043801989516317016, 'max_depth': 4, 
               'colsample_bytree': 0.8778900801725762, 'subsample': 0.7274550283257443, 
               'reg_alpha': 2.792469294881351e-07, 'reg_lambda': 18.480038009887284}
xgb2 = XGBRegressor(verbosity=0, random_state=18, objective = 'reg:squarederror', eval_metric='mae', 
                    **xgb_params2, 
                   sampling_method='gradient_based', tree_method='gpu_hist', 
                   )
xgb_score2 = model_scoring_cv(xgb2, Xtrain2, Ytrain2)
print(f"xgb score 2 : {xgb_score2}")

#Validation Time : 232.204 sec
#xgb score 2 : 0.4805120802893638

xgb_params3 = {'n_estimators': 2643, 'learning_rate': 0.031712179695614046, 'max_depth': 5, 
               'colsample_bytree': 0.7424966681183006, 'subsample': 0.787669908095534, 
               'reg_alpha': 0.0019021769478904957, 'reg_lambda': 0.6331937967083731}
xgb3 = XGBRegressor(verbosity=0, random_state=18, objective = 'reg:squarederror', eval_metric='mae', 
                    **xgb_params3,
                    sampling_method='gradient_based', tree_method='gpu_hist', 
                   )
xgb_score3 = model_scoring_cv(xgb3, Xtrain3, Ytrain3)
print(f"xgb score 3 : {xgb_score3}")

#Validation Time : 315.204 sec
#xgb score 3 : 1.5351869589180944

xgb_params4 = {'n_estimators': 2692, 'learning_rate': 0.0304561991789567, 'max_depth': 6, 
               'colsample_bytree': 0.561225558419653, 'subsample': 0.7109372329820719, 
               'reg_alpha': 6.551680601955312e-08, 'reg_lambda': 4.484664174506758}
xgb4 = XGBRegressor(verbosity=0, random_state=18, objective = 'reg:squarederror', eval_metric='mae', 
                    **xgb_params4, 
                   sampling_method='gradient_based', tree_method='gpu_hist', 
                   )
xgb_score4 = model_scoring_cv(xgb4, Xtrain4, Ytrain4)
print(f"xgb score 4 : {xgb_score4}")

#Validation Time : 426.062 sec
#xgb score 4 : 1.107904721993923

print(f"XGBoost Mean CV Score : {(xgb_score1 + xgb_score2 + xgb_score3 + xgb_score4)/4}")
#XGBoost Mean CV Score : 0.9458482833410202

진짜 느려 터졌다. 

5.3) CatBoost 

cat_params1 = {'iterations': 2681, 'depth': 7, 'learning_rate': 0.08663866615470771, 
               'random_strength': 9.235074797628041, 'l2_leaf_reg': 8.164833371246262, 
               'bagging_temperature': 0.8220097439681222}
cat1 = CatBoostRegressor(loss_function='MAE', random_seed=18, verbose=False, **cat_params1,
                         task_type='GPU'
                        )
cat_score1 = model_scoring_cv(cat1, Xtrain1, Ytrain1)
print(f"CatBoostScore1 : {cat_score1}")

#Validation Time : 785.542 sec
#CatBoostScore1 : 0.6847231512252785

cat_params2 = {'iterations': 2769, 'depth': 6, 'learning_rate': 0.17889638008330497, 
               'random_strength': 9.283130620824029, 'l2_leaf_reg': 43.78952651533997, 
               'bagging_temperature': 0.7378285753465266}
cat2 = CatBoostRegressor(loss_function='MAE', random_seed=18, verbose=False, **cat_params2,
                         task_type='GPU'
                        )
cat_score2 = model_scoring_cv(cat2, Xtrain2, Ytrain2)
print(f"CatBoostScore2 : {cat_score2}")

#Validation Time : 609.407 sec
#CatBoostScore2 : 0.4901726900123041

cat_params3 = {'iterations': 2843, 'depth': 7, 'learning_rate': 0.2677960942055296, 
               'random_strength': 5.280805075485188, 'l2_leaf_reg': 6.178243639724696, 
               'bagging_temperature': 0.03590951004104092}
cat3 = CatBoostRegressor(loss_function='MAE', random_seed=18, verbose=False, **cat_params3,
                         task_type='GPU'
                        )
cat_score3 = model_scoring_cv(cat3, Xtrain3, Ytrain3)
print(f"CatBoostScore3 : {cat_score3}")

#Validation Time : 938.061 sec
#CatBoostScore3 : 1.5602005106402956

cat_params4 = {'iterations': 2911, 'depth': 7, 'learning_rate': 0.0923974192869855, 
               'bagging_temperature': 0.04030601548502897, 'random_strength': 8.50411971804888}
cat4 = CatBoostRegressor(loss_function='MAE', random_seed=18, verbose=False, **cat_params4,
                         task_type='GPU'
                        )
cat_score4 = model_scoring_cv(cat4, Xtrain4, Ytrain4)
print(f"CatBoostScore4 : {cat_score4}")

#Validation Time : 899.926 sec
#CatBoostScore4 : 1.1108542319122767

print(f"CatBoost Mean CV Score : {(cat_score1 + cat_score2 + cat_score3 + cat_score4) / 4}")
#CatBoost Mean CV Score : 0.9614876459475388

느리고 결과도 안 좋다. 


 

6. Ensemble

models_hhb=[lgb1, xgb1, cat1]
models_hbo2=[lgb2, xgb2, cat2]
models_ca=[lgb3, xgb3, cat3]
models_na=[lgb4, xgb4, cat4]

train_preds_hhb = pd.DataFrame(data=None, index=train.index)
train_preds_hbo2 = pd.DataFrame(data=None, index=train.index)
train_preds_ca = pd.DataFrame(data=None, index=train.index)
train_preds_na = pd.DataFrame(data=None, index=train.index)

test_preds_hhb = pd.DataFrame(data=None, index=test.index)
test_preds_hbo2 = pd.DataFrame(data=None, index=test.index)
test_preds_ca = pd.DataFrame(data=None, index=test.index)
test_preds_na = pd.DataFrame(data=None, index=test.index)

모델과 예측 결과를 담을 데이터프레임을 새로 만들어주고, 

for bag_train, bag_test, x, y, xtest, models in zip(
                                [train_preds_hhb, train_preds_hbo2, train_preds_ca, train_preds_na], 
                                [test_preds_hhb, test_preds_hbo2, test_preds_ca, test_preds_na], 
                                [Xtrain1, Xtrain2, Xtrain3, Xtrain4],
                                [Ytrain1, Ytrain2, Ytrain3, Ytrain4], 
                                [Xtest1, Xtest2, Xtest3, Xtest4],
                                [models_hhb, models_hbo2, models_ca, models_na]):

    for model, names in tqdm(zip(models, ['lgb', 'cat', 'xgb'])):
        bag_train[f"{names}_{i}"] = cross_val_predict(model, x, y, cv=5)
        model.fit(x, y)
        bag_test[f"{names}_{i}"] = model.predict(xtest)
        
print(train_preds_hhb.shape, train_preds_hbo2.shape, train_preds_ca.shape, train_preds_na.shape)
print(test_preds_hhb.shape, test_preds_hbo2.shape, test_preds_ca.shape, test_preds_na.shape)

#(10000, 3) (10000, 3) (10000, 3) (10000, 3)
#(10000, 3) (10000, 3) (10000, 3) (10000, 3)

train 데이터에 대해서 5 fold 교차 검증으로 예측값을 만들고, test 데이터에 대해서는 전부를 이용해서 학습하고 예측을 해서 결과물을 모았다. 

from sklearn.ensemble import ExtraTreesRegressor
et1_params = {}
et2_params = {'n_estimators': 84, 'max_depth': 6, 'min_samples_split': 15, 'min_samples_leaf': 1}
et3_params = {'n_estimators': 67, 'max_depth': 6, 'min_samples_split': 161, 'min_samples_leaf': 1}
et4_params = {'n_estimators': 83, 'max_depth': 6, 'min_samples_split': 158, 'min_samples_leaf': 1}


et1 = ExtraTreesRegressor(**et1_params)
et2 = ExtraTreesRegressor(**et2_params)
et3 = ExtraTreesRegressor(**et3_params)
et4 = ExtraTreesRegressor(**et4_params)

ets_list=[et1, et2, et3, et4]

mean_score = 0
for model, x, y, xtest in zip(ets_list, 
                       [train_preds_hhb, train_preds_hbo2, train_preds_ca, train_preds_na], 
                       [Ytrain1, Ytrain2, Ytrain3, Ytrain4], 
                       [test_preds_hhb, test_preds_hbo2, test_preds_ca, test_preds_na]):
    mean_score = mean_score + model_scoring_cv(model, x, y, n_jobs=1)
    model.fit(x, y)
    pred = model.predict(xtest)
    submission[y.name] = pred
    
print(f"Mean CV Score : {mean_score / 4}")
submission.head()

예측 결과물들을 가벼운 ExtraTreesRegressor를 사용해서 최종 결과를 만들었다. 최종 교차 검증 결과는 0.93309. 

교차검증 점수는 LightGBM 단일 모델과 크게 차이가 나지 않았는데, 리더보드 점수가 LightGBM 단일 모델은 0.956, 스태킹 모델은 약 0.945 정도여서, 어느 정도 아주 미약하게나마 일반화 성능(?)을 개선시켰다고 판단하고 최종 예측 결과로 사용했다. 

submission.to_csv('Final ETS Ensemble CV 0_93309.csv')

 


 

7. 결과

Public LB에서는 0.94532로 21위로 기록되어 있었는데, Private LB 에서는 0.92553으로 17위로 올라와서 대회를 마무리할 수 있었다. 

대회 종료 이후 다른 사람들의 코드를 보았는데, 다들 정말 피쳐 엔지니어링에 신경을 많이 쓴 것 같았고, 단일 모델 dart 로도 점수가 나보다 더 잘 나왔다. 보고 현타가 좀 왔었는데, 정말 내가 쓸데없는 짓 하느라 시간을 많이 끌었구나 생각이 정말 정말 정말로 많이 들었다. 그래도, 상위 10% 안에 드는 것이 목표였는데, 목표도 달성했고, 처음으로 무언가 상금은 아니지만 리턴을 받은 대회여서 너무 좋았다!

진짜 사람들 개잘하는거같다 너무 빡세다

반응형
Posted by Jamm_