주저리주저리...

더보기

관정 장학생 활동을 나름

 

(내 성격을 생각해보면 그러는 것도 쉽지 않았을 텐데 용케도 했구나 하는 생각이 든다.

 

이제는 연식도 좀 있어서 다른 사람들이 부담스러울 것 같아 약간 아쉽지만 그리 적극적으로 하고 있지는 않다.)

 

열심히 하며 남은 것은 가끔 안부인사를 나눌 수 있는 멋진 사람들이다.

 

그중 모임을 기획하고 진행하는 데에 있어 개인적으로는 정말 큰 지지를 받았다고 생각하고,

 

항상 감사하게 생각하는 분이 있었는데

 

그분께서 사정상 자신이 속해있던 관정 독서모임을 그만둘 수도 있는 상황이 되셔서

 

전체 공지로 대타(?)를 구하는 상황이 있었다.

 

사실 관정 모임에 적극적으로 참여한 역사가 꽤나 길었기 때문에

 

그 사이에서 일찍 일어나기 모임이라든지, 독서 모임이라든지 하는 것이 있다는 것을 어렴풋이 알고 있었고

 

(누군가에게 들은 소리지만

 

1. 소모임의 주제가 굉장하다. 2. 그런 소모임이 잘 유지되는 것이 더 굉장하다.라고 한다.)

 

좋은 사람들과의 관계를 더욱 돈독히 하기 위해 해 볼까 고민하던 찰나도 몇 번 있었다.

 

물론 고민에 그칠뿐이었지만.

 

 

최근 힘든 시기를 보내고, 여러 방면으로 리프레시를 해야겠구나 하는 생각이 들었다.

 

다행히도 너무 기쁘고 감사한 일도 여럿 생기고, 자신감을 많이 회복했지만 뭔가 새로운 도전 또한 해보고 싶었다.

 

그래서 이번에 기회가 생겼을 때는 아주 약간 망설이기는 했지만, 거의 바로 떡밥을 물었던 것 같다.

 

모임 자체가 상당히 폐쇄적이고 깐깐하게 운영되는 것을 보고 약간 놀랐지만,

 

(한 번 참여해보고 나니 기우였다. 다음에는 간식거리 하나 들고 가는 것으로 ㅎㅎ)

 

아무래도 나는 후발 주자의 입장이기에 여러 가지를 감안해야 하는 것도 있다.

 

책을 제대로 읽어본지가 꽤 되었기 때문에 물론 마음의 준비도 많이 필요했지만

 

한때 (좀 멀다) 책을 끼고 살았던 입장에서 다시 템포를 찾는 것 또한 그렇게 어렵지는 않을 것 같다.

 

아무튼 그렇다.

파리대왕 / 민음사 / 윌리엄 골딩

 

상당히 오랜만에 소설을 읽었는데, 개인적으로 좋아하는 테마인 디스토피아/조난을 주제로 한다.

 

(모임에서 이 소설이 디스토피아 장르라기보다는, 디스토피아로 가는 과정을 그렸다는 의견이 있었는데 충분히 동의.)

 

일단 읽으면서 아쉬웠던 부분을 먼저 나열하자면, 내가 민감한 건지는 모르겠지만 번역 말투가 상당히 거슬렸고

 

(특히 this나 we 같은, 한국어로 옮길 때 깔끔하게 생략이 가능한 부분들을 욱여넣은 느낌. 원문을 본 적은 없지만...)

 

섬의 모습을 묘사하는 데에 있어 어려운 한자어가 많이 등장해서 나중에는 그 부분은 거의 포기하고 읽은 정도.

 

꽤 오래된 영화가 있던데 보면 도움이 될라나?

 

다 읽고 나서 나무위키를 훑어보는데, 나랑 비슷한 느낌을 받은 사람이 몇 있다는 거에 안도했다.

 

혹시 독서 부족으로 독해력이 너무 떨어져서

 

다른 사람들이 별 상관하지 않는 부분을 나만 민감하게 받아들이는 건가 싶었는데 그나마 다행이다.

 

굳이 여기다가 전체 줄거리를 정리할 필요는 없겠고, 인상 깊었던 주제만 몇 개 정리해 놓아야지.

 

 

파리대왕에서 작가는 노골적(?)으로 상징적인 사물을 여럿 등장시키는데,

 

그중에서도 소라라는 개념이 흥미로웠다.

 

개인적으로 소라가 인상 깊었던 이유는 대부분의 상징물들 (봉화, 오두막, 안경 등)이

 

소설 속에서 꼭 필요하고 대체 불가능한 실용적인 역할을 하는 반면 

 

(아무래도 공돌이 마인드?)

 

소라는 그 자체로는 아무 쓸모가 없고, 질서를 상징하는 일종의 약속과 가까운 역할을 하기 때문인 것 같다.

 

물론 소라를 불어서 나는 소리로 회의를 소집하기는 하지만,

 

그런 장치는 소라가 갖고 있는 질서라는 개념을 재확인시켜주는 이벤트에 불과한 것 같다는 게 내 생각.

 

소라가 없으면 그냥 목으로 큰 소리를 내면 되니까.

 

 

이야기 초반에서 소라는 상당히 중요한 역할을 하는데,

 

모두의 암묵적인 합의를 통해 소라가 있는 사람만 목소리를 낼 수 있다는 약속이 그것이다.

 

소라가 권력이나 법을 상징하는 것은 아닐까 생각도 해보았는데,

 

필요하다면 누구든 소라를 받아 발언을 할 수 있다는 점에서 권력과는 조금 거리가 있고

 

(사실 들고 있는 사람이 주기 싫은 내색을 하는 경우도 있긴 했지만)

 

실제로는 소라를 들고 있는 사람이 아니더라도 다들 비교적 자유롭게 말했으며

 

이에 대한 물리적 제재 등이 전혀 없었다는 점에서 법이랑도 약간 차이가 있는 느낌이다.

 

 

개인적으로 흥미로웠던 것은, 이런 소라를 통한 질서가 비교적 오래 유지되었다는 점이다.

 

이 소설이 독특한 점은 아주 어린아이들의 입장에서 여러 사건이 전개된다는 것인데

 

소설 초중반까지는 실제로 이득을 가져다주는 다른 존재들보다도

 

소라가 더욱 강한 권력을 가지고 있던 것으로 기억한다.

 

일반적으로 어릴수록 즉각적이고 눈에 보이는 리턴이 있는 것을 선호하지 않을까 생각을 했는데

 

그 부분에 대해서는 조금 더 생각이 필요한 것 같다.

 

 

게으름을 부리다 보니 모임이 끝난 이후에 마저 작성하게 되었는데,

 

다른 사람의 의견들 중 흥미로웠던 것은 돼지에 대한 다양한 관점이다.

 

그중 특히 "돼지의 이름이 마지막까지 등장하지 않는다"라는 점이 기억에 강하게 남는데,

 

이름이라는 것이 단순한 상징을 넘어 공동체 내에서 개인의 자아를 상징한다고 생각했을 때

 

소년들의 생존에 은근한 기여를 한 돼지가 마지막까지 이름 없는 모습으로 떠나간 것은 생각할 부분이 많다.

 

개인적으로는 돼지에게 절대적으로 부족했던 것이 매력, 카리스마였다고 보는데

 

사실 TRPG 형식의 보드게임 (많이는 안 해봤지만)을 하다 보면 비슷한 능력치가 종종 등장한다.

 

저게 도대체 무슨 쓸모야 라고 생각했던 시절이 있었지만

 

파리대왕을 보면서 인간의 매력이란 무엇일까 하는 얕은 생각을 조금 하게 되었다.

 

소설 도입부만 봐도 돼지의 모습은 전형적인 안경잡이 뚱보처럼 그려지는데,

 

이후의 성격 묘사 등이 굳이 등장하지 않더라도

 

앞으로 저 인물이 소설 내에서 어떤 취급을 받을지 뻔한 느낌을 받았다.

 

실제 취급 또한 다르지 않았고.

 

쉽게 말해 돼지라는 인물은 무인도라는 야생의 상황에서 본능적으로 사람을 끌어당기는 매력이 부족했고,

 

이에 온갖 수모를 겪었다고 생각하고 있었다.

 

 

모임에서 이런 얘기를 하니까 상당히 흥미로운 답변을 들었는데,

 

매력이라는 것이 이성적으로는 정의하기 힘든, 어떤 본능적인 끌림이라고 한다면

 

상대적으로 매력적인 사람 (랠프나 잭)을 리더로 뽑는 것은 본능적인 선택이 된다.

 

그런데 사실 리더를 뽑는다는 행위 자체는 이성적으로 집단의 질서를 유지하기 위한 행위이고,

 

이렇게 이성적인 행위를 가장 본능적인 발상으로 결정하는 것 자체가 굉장히 재밌는 상황이라는 것이다.

 

(실제로는 더 멋지게 말씀을 해주셨는데 기억 + 정리하는 능력이 부족하다... ㅎㅎ)

 

사실 무인도까지 가지 않고 주변만 봐도 많이 있는 일이고,

 

가깝게는 학급 반장, 모임장 등 소규모의 집단을 대표하는 사람에서부터

 

중요한 자리를 담당하는 정치인들까지, 본능적인 끌림으로 리더를 뽑는 과정이 적지 않다는 생각을 했다.

 

 

아무튼.

 

이야기는 자연스럽게 지도자의 자질, 다른 인물이 지도자가 되었으면 어떨까 하는 얘기로 넘어갔는데,

 

돼지는 패스. 아무리 생각해도 리더의 상은 아니다.

 

그러면 소설에 등장하는 주된 인물은 아무래도 랠프와 잭이 되겠는데,

 

나는 두 인물 모두에 회의적인 입장이었다.

 

랠프의 경우 지도자에게 있어 중요하게 생각하는 자질인

 

사람을 다루고 관리하며 적재적소에 사용하는 능력이 부족했던 것 같다.

 

특히나 이 부분에 있어서는 돼지가 랠프를 아득하게 웃돌고,

 

랠프의 결정적인 과실은 이런 돼지를 끝까지 자기 사람으로 만들지 못한 것이다.

 

(이름과도 연관되는 내용? 마지막까지 랠프는 돼지의 이름을 묻지 않았다.)

 

무인도라는 야생의 상황에서 사냥을 주도하며

 

짐승들을 잡아온 잭은 의외로 정상적인 지도자가 아니었을까? 하는 의견도 나왔는데

 

이 부분에 대해서는 작가가 여러모로 설계를 재밌게 했다는 생각이 들었다.

 

일반적으로 조난 계열의 장르에서 식량 확보는 굉장히 중요한 주제 중 하나인데,

 

파리대왕에서는 대충 과일을 따먹으며 생존하였다 같은 묘사가 상당히 많이 등장하여 의아했다.

 

쉽게 말해 잭으로 대표되는 사냥 대가 굳이 힘을 들여 멧돼지를 잡아올 필요는 없었다는 것인데

 

이러한 사냥 행위 때문에 많은 근본적 갈등이 유발되고 결국 공동체의 분열이 일어나게 되었다.

 

이렇게 생각하면 잭은 전통적인 "힘"을 상징한다기보다는,

 

폭력과 무질서를 담당하고 있다고 생각하는 게 조금 더 깔끔한 생각인 것 같다.

 

의외로 많은 분들이 이에 공감해주셔서 그 후의 얘기도 깔끔하게 흘러갔고.

 

 

이거 말고도 다양한 주제에 대해서 여러 관점으로 얘기를 했지만,

 

(더 정리하기에는 조금 귀찮다.

 

조금 잡설이지만 나는 많은 경우 전화보다는 텍스트 매체를 선호하는데,

 

요즘 들어 말로 하는게 훨씬 쉽고 간단하다는 느낌을 받을 때가 여럿 있다.)

 

일단 첫 번째 모임은 내가 생각했던 느낌으로 잘 마친 것 같다.

 

아무래도 다 좋은 분들이다 보니 다양한 의견에 대해 이를 수용하고 분석하는 능력,

 

필요하다면 적절한 반론을 제시하는 능력 등이 훌륭하셔서

 

편하게 맞출 수 있었다는 느낌.

 

바로 다음이 내가 준비해야 하는 차례라서 부담이 안된다고 하면 거짓말이지만

 

눈치껏 잘해봐야지.

이전 글:

2021.05.06 - [논문 읽기] - [CVPR 2021] LIIF: Learning Continuous Image Representation with Local Implicit Image Function (1) - 배경 지식

 

[CVPR 2021] LIIF: Learning Continuous Image Representation with Local Implicit Image Function (1) - 배경 지식

2021.05.04 - [코딩/PyTorch] - PyTorch로 MATLAB imresize (bicubic interpolation) 구현하기 (2) - resampling (interpolation) 잡담: 더보기 Academic 한 관점에서 연구를 하며 힘든 순간 중 하나는 내가 나름..

sanghyun.tistory.com

 

이전 글에도 정리한 것처럼, resampling을 사용하지 않는 이상 CNN 내부에서 이미지의 해상도를 임의로 조작하기는 쉽지 않다.

 

그러면 가장 간단하게 드는 생각이, 아예 resampling을 CNN 내부에서 사용하는 것인데

 

이를 제안한 방법이 Meta-SR이다.

 

이렇게 하면, 허무할 정도로 쉽게 임의의 scale에 대응하는 SR 모델을 구현할 수 있는데

 

나는 이게 뭐가 귀찮다고 무시했는지 모르겠다.

 

물론 novelty가 전혀 없다는 것은 아니고, NN이나 bicubic 같은 handcrafted interpolation 연산을 사용하는 것이

 

sub-optimal 할 수 있으므로 (실험으로 보였다.) resampling kernel 또한 scale에 맞춰 dynamic filter 형식으로 만들어낸다.

 

여담이지만, 나는 제목의 Meta라는 단어를 썩 달갑게 생각하지는 않는데

 

아주 넓은 관점에서의 meta-learning은 맞지만 dynamic filter를 조금 더 강조하는 게 맞지 않았나 싶고

 

실제로 리뷰를 했었다면 그 점을 지적했을 것 같다.

 

일단 자세한건 이후 LIIF와의 비교에서 정리하고.

 

 

아무튼.

 

LIIF는 여러 모로 재밌는 (흥미로운?) 논문인데, 내가 생각하는 독특한 점 중 하나는

 

방법론 자체에 거의 novelty가 없음에도 참신한 writing으로 좋은 리뷰를 받았다는 것이다.

 

(참신한 글은 뭔가 있어 보이기는 하니까.)

 

LIIF에서는 이미지를 neural implicit function으로 나타내고자 하는데,

 

여기서 내가 살짝 헷갈렸던 부분은 implicit function에 대한 정의이다.

 

수학적으로 implicit function (음함수)는 $f(x_1, x_2, \dots, x_n) = 0$으로 표현되는,

 

$y = f(x_1, x_2, \dots, x_n)$처럼 하나의 변수로 정리되지 않는 함수 (explicit function)를 말한다.

 

(물론 재주껏 정리할 수 있는 경우도 있지만.)

 

그런데 요즘 인기를 끌고 있는 neural implicit function 분야 같은 경우, implicit function에 대한 정의가 조금 다르다.

 

논문마다 약간 컨셉이 다르긴 하지만, 기본적으로 discrete 한 데이터 (이미지, voxel 등) $f$ 에서

 

continuous 한 query $(x, y)$에 대한 값 $c$를 반환하는 형태의 formulation을 neural implcit function이라고 하는 것 같았다.

 

즉, 일반적인 이미지는 $c = f(x, y)$가 정수 좌표에 대해서만 정의되어 있지만

 

neural implicit function은 이를 실수 좌표계로 확장하기 위한 수단이라고 볼 수 있다.

 

 

여담이지만 아무리 봐도 수학적인 implicit function의 정의와는 상당한 괴리가 있어 뒷조사(?)를 좀 해보았는데

 

optimization 문제

 

$\text{find}~\Phi: x \mapsto \Phi (x) \\ \text{subject to}~\mathcal{C}(a(x), \Phi(x), \dots) = 0,\\ \forall x \in \Omega, m = 1, \dots, M.$

 

이 있을 때 $\Phi$가 $\mathcal{C}$를 통해 implicit 하게 정의된다는 점에서 implicit function이라는 이름이 붙은 것 같다.

 

(참조: SIREN - Implicit Neural Representations with Periodic Activation Functions)

 

실제 neural network에서 $\mathcal{C}$는 loss function이라고 볼 수 있고,

 

한 마디로 정리하자면 일반적인 CNN 학습과 동일하게

 

neural implicit function을 찾는것은 어떤 데이터 domain $\Omega$에서 loss를 minimize 하는 mapping을 찾는 것이다.

 

굳이 이런식으로 정의를 해야 했는지는 조금 의문이지만

 

공부가 부족해서 완전히 이해를 못하는 것으로 생각하고 일단 넘어간다.

 

 

본론으로 돌아와서,

 

LIIF에서는 임의의 이미지에 대해서 $s = f_\theta (z, x)$를 배우는 것을 목적으로 하는데,

 

$x$는 이미지의 2D 좌표, $f_\theta$는 parametric function (당연히 neural network),

 

그리고 $s$는 해당 좌표에서의 픽셀 값이다.

 

일반적인 이미지는 $x$가 정수 쌍일 때만 정의되지만,

 

implicit function을 학습하는 목적은 이를 실수 domain으로 확장하는 것이기에

 

$x$는 적당한 범위 (이미지 밖은 별 의미가 없으니까). 내의 실수 쌍 아무거나 가능하다.

 

그럼 $z$는 무엇인가 싶은데 image-specific한 prediction을 만들기 위한 일종의 coding이라고 생각하면 될 것 같다.

 

쉽게 말해 $s = f_\theta (z, x)$에서 $s$가 어느 이미지의 픽셀인지를 명시해주는 벡터이다.

 

 

당연한 얘기지만 이런 $z$는 우리가 implicit representation을 얻고 싶은 이미지로부터 얻고,

 

가장 간단한 방법은 CNN feature를 사용하는 것이다.

 

자세한 정리는 조금 뒤에.

 

LIIF에서 주의해야할 notation은 $f_\theta$와 $f_\theta(z, \cdot)$의 차이인데,

 

$f_\theta$의 경우 일반적인 이미지에 대해서 $z$를 받아 implicit representation을 만들어주는 연산이고

 

$f_\theta (z, \cdot)$의 경우 어떠한 이미지 ($z$와 관련된)에 대해 conditioning 된 implicit representation이다.

 

사실 implicit representation은 여러 목적으로 사용이 가능한데, 위의 SIREN 논문에 잘 정리되어 있지만

  • Sample 수가 아주 많은 (고해상도?) discrete data를 compact 하게 나타낼 수 있음
  • Discrete data의 continuous한 representation을 사용하여 여러 수학적인 이점을 취함.
    미분이라든지...
  • 이러한 성질을 이용해 inverse problem을 푸는 데에 discrete data보다 효율적일 수 있음.

등이다.

 

LIIF는 implicit representation을 이용해 image SR 문제를 해결하려고 하는데,

 

엄밀히 말하면 위의 사항 모두와 관계가 있다고 볼 수 있다.

 

기존 SR 방법들과의 차이점은

 

컨셉 자체를 discrete domain에서 continuous domain으로 옮긴 것인데, 어떤 discrete 이미지가 있을 때,

 

해당 이미지의 continous representation을 구하고

 

이를 resampling 하면 SR 결과를 얻을 수 있는 것이 아닌가 하는 발상을 도입한 것이다.

 

 

구체적으로 어떤 방식으로 SR을 수행하는지에 대해 정리하자면,

 

우선 LR 이미지와 타겟 (SR) 영상의 해상도가 주어지면 mapping을 통해 타겟 영상의 모든 픽셀이

 

LR 이미지의 어느 좌표에 대응하는지를 구한다.

 

Mapping은 아래에 따로 정리했다.

 

2021.05.03 - [코딩/PyTorch] - 파이토치 PyTorch로 MATLAB imresize (bicubic interpolation) 구현하기 (1) - 이미지 resizing 배경 지식

 

PyTorch로 MATLAB imresize (bicubic interpolation) 구현하기 (1) - 이미지 resizing 배경 지식

[코드] 일반적으로 Image Super-Resolution (SR) 모델을 학습할 때는 많은 양의 high-resolution (HR) 이미지를 준비한 뒤 이를 임의의 downsampling 방법으로 줄여 input low-resolution (LR)을 만든다. 그 후 이..

sanghyun.tistory.com

당연히 LR 이미지에서 대응되는 좌표는 정수가 아니라 실수일 수 있고, 이를 어떻게 구현하는지가 중요하다.

 

논문에서는 아래와 같은 수식으로 이 과정을 표현하는데,

 

$I^{(i)} (x_q) = f_\theta (z^\ast, x_q - v^\ast)$이다.

 

식이 갑자기 어지러워진 느낌인데, 하나씩 정리하면 $I^{(i)} (x_q)$는

 

continuous representation의 $x_q$ 위치에 대응하는 픽셀 값이다.

 

따라서 $I^{(i)}$는 continuous하게 표현되는 이미지라고 생각하면 될 것 같다.

 

논문에서 $z^\ast$의 정의는 조금 번잡한데, 우선 가지고 있는 $H \times W$ discrete 이미지를

 

feature encoder $E_\phi$에 통과시켜서 2D CNN feature $M \in \mathbb{R}^{H \times W \times D}$를 얻는다.

 

$v^\ast$는 우리가 원하는 query coordinate $x_q$로부터 가장 가까운 포인트의 좌표다.

저자들 말에 의하면 z_11이 x_q가 주어졌을 때 z^*라고 한다. 개인적으로 정말 끔찍한 시각화의 대표적인 예시라고 생각하지만... 출처는 논문.

$\lfloor \cdot \rceil$이 element-wise 반올림 연산이라고 할 때, $v^\ast = \lfloor x_q \rceil$이라고 해도 무방할 듯.

 

$z^\ast$는 $M[v^\ast] \in \mathbb{R}^{D}$이다.

 

즉, $v^\ast$에 해당하는 위치의 feature 값이라고 생각하면 된다.

 

물론, $M$ 자체가 CNN feature이다 보니, $z^\ast$에 어느 정도 context가 반영되어 있는 것은 당연하다.

 

논문 제목 'Local Implicit Image Function'의 local이 상징하는 것도 동일.

 

식을 다시 정리해보면, reference feature $z^\ast$를 기준으로 offset을 구한 뒤 ($x_q - v^\ast$),

 

이를 기반으로 해당 offset에 대응하는 위치의 픽셀 값을 계산하는 방식이다.

 

말로 설명하자면 "이 위치의 feature가 $z^\ast$인데 여기서 $x_q - v^\ast$만큼 떨어진 위치에서는 무슨 색인가?"

 

라고 할 수도 있고.

 

 

아무튼 위의 식을 열심히 이해하고 나면, 그게 끝이다.

 

별거 없다고 생각할 수 있지만 정말로...

 

이렇게 한 픽셀의 색을 구하는 방법을 알면 당연히 모든 픽셀의 색을 구할 수 있고

 

모든 output 픽셀에 대해서 mapping으로 구한 $x_q$ 좌표들을 일일이 넣어서 계산해주고

 

잘 늘어놓으면 SR 결과가 나온다는 얘기다.

 

물론 더욱 좋은 결과를 만들기 위한 몇 가지 테크닉이 존재하는데, 이해하고 나면 상당히 허무하다.

 

특히 네이밍 센스의 경우 (나도 이름을 잘 짓는 편은 아니지만) 있어 보이려고 좀 선을 넘을뻔한 것들이 보이는데 후술.

 

 

가장 먼저 feature unfolding이다.

 

쉽게 말해 $f_\theta$에 $z^\ast$만 넣는 게 아니라,

 

$M$에서 $z^\ast$에 이웃한 (가로세로 대각선) 8방향 feature도 같이 넣겠다는 것이다.

 

같이 들어가는 feature들은 기존 $z^\ast$에 concat 되고, 따라서 $f_\theta$에 들어가는 벡터의 크기가 9배가 된다.

 

아무래도 $z^\ast$가 원래 CNN feature인 만큼 redundancy가 많을 것 같지만...

 

이후 실험 결과를 보면 아주 조금의 성능 향상이 있다고 한다.

 

도대체 이런 대단하지 않은 기법에 왜 feature unfolding이란 이름이 붙었는지 조금 뒤져보니까,

 

공식 코드에서 구현상의 편의를 위해 해당 기법과 관련한 부분에

 

PyTorch의 nn.functional.unfold 함수를 사용해서라는 결론이 나왔다...

 

아무튼 별거 아닌 테크닉 #1.

 

 

두 번째는 local ensemble이다.

 

상당히 헷갈리는 표현이라고 생각하는데, 일단 논문에 정리된 식은 이렇다.

 

$I^{(i)}(x_q) = \sum_{t \in \left\{ 00, 01, 10, 11 \right\}}{ \frac{S_t}{S} \cdot f_\theta (z^\ast_t, x_q - v^\ast_t) }.$

 

위에 첨부된 그림과 함께 보면 좋은데,

 

우선 $\left\{ 00, 01, 10, 11 \right\}$는 각각 $x_q$를 기준으로 좌상, 우상, 좌하, 우하의 $z^\ast$를 표현하기 위한 인덱스이다.

 

$S_t$는 $x_q$를 기준으로 $z^\ast_t$가 만드는 사각형을 4 분할한다고 할 때, $z^\ast_t$의 반대 방향에 있는 사각형의 넓이이며

 

$S = \sum_t{S_t}$인 normalization factor이다.

 

논문에서는 이렇게 하면 만들어진 이미지의 smoothness를 보장하는 데에 도움을 준다고 한다.

 

설명이 쓸데없이 어렵게 되어있지만, 사실 저렇게 기준 포인트의 반대방향의 넓이를 contribution factor로 쓰는 것은

 

bilinear interpolation과 동일하므로, 어렵지 않게 이해할 수 있다.

 

4개의 $f_\theta$ 값을 구하고, 원하는 위치에서 bilinear interpolation 된 값을 쓴다 라고 하면 간단했을 텐데...

 

오히려 본문에서는 bilinear interpolation 얘기가 쏙 빠져있어 이게 뭔가 새로운 방법인가 하는 착각을 불러일으킨다.

 

실험에서 약간의 성능 향상이 있기는 한 걸로 나타났다.

 

아무튼 별거 아닌 테크닉 #2.

 

 

마지막으로 cell decoding이다.

 

이 또한 상당히 있어 보이는 이름인데, 쉽게 말해 output 이미지의 크기를 $2 \times 2$ (맞나?)로 normalize 할 때

 

(저 근본 없어 보이는 크기는 좌표계를 -1 ~ 1로 normalize 해서 나온다.)

 

$f$에 output 픽셀의 크기를 같이 넣어준다는 것이다.

 

cell은 output pixel에 대응하는 것 같고,

 

decoding은 implicit representation을 통해 픽셀 값을 얻는 과정을 그렇게 표현한 것 같다.

 

가령 output 이미지가 $400 \times 300$이라면 $[\frac{2}{400}, \frac{2}{300}]$을 $x$와 함께 넣어준다는 뜻.

cell decoding 설명. 출처는 논문.

여담이지만 이 부분만 다른 사람이 쓴 것 같은 게, notation이 이전 섹션과는 약간 다르다.

 

검수할 때 빼먹은 듯?

 

그건 별개로 두더라도, 내가 생각하는 cell decoding의 가장 큰 문제는

 

Meta-SR 등에서 이미 제안된 기법임에도 불구하고 완전 새로운 이름을 붙이고 citation을 하지 않아

 

마치 자신들이 cell decoding이라는 새로운 테크닉을 개발한 것처럼 보이게 한다는 점이다.

 

비교는 다음 포스트에 정리해야지.

 

암튼 이것까지 해서 별거 아닌 테크닉 #3까지가 LIIF method의 일부이다.

잡담:

더보기

Academic 한 관점에서 연구를 하며 힘든 순간 중 하나는

 

내가 나름 기발하다고 생각했던 발상이 이미 논문으로 나와 있는 경우다.

 

지금까지 생각해 낸 아이디어 중 70% 정도는 아마 이렇게 묻히지 않았나 싶다.

 

나머지 30% 중, 귀찮음, 시간 없음, 그냥 안 함 등의

 

이유로 실제로 구현까지 가는 아이디어는 전체의 10% 정도?

 

흥미롭게도 이렇게 남겨진 20%의 아이디어들은 누군가가 곧 정리해서 논문으로 내버린다.

 

그럴 때 드는 생각은 크게 2가지인데,

  1. 별로 못 쓴 논문인 경우에는 내가 얼른 할 걸.
  2. 잘 쓴 경우에는 고래싸움에 괜히 뛰어들지 않기를 잘했다.

간단히 말해 "내 좋을 대로 생각한다"라는 의미이지만

 

아무튼 비슷한 분야를 연구하는 사람들 (에디슨 vs. 테슬라?)은 역사적으로도 항상 비슷한 생각을 해왔다.

 

 

사실, continuous 한 관점에서의 SR을 생각한 건 2018년 가을이었는데

 

이경무 교수님의 수업 프로젝트 주제를 정하다 보니

 

기존의 SR 알고리즘들이 항상 고정된 스케일만 다루는 것이 조금 아쉽다는 생각이 들었다.

 

그래서 다양한 방법으로 사용 가능한 continuous-scale SR 모델을 만들어보자!라는 proposal을 제출했는데

 

이유는 모르겠지만 다른 것에 꽂혀서 (+ 귀찮아서?) 주제를 바꿨던 기억이 났다.

 

그 직후, Meta-SR이라는 paper가 CVPR 2019에 accept 됐다는 소식을 듣고

 

아쉬운 감정과 다행스러운 감정이 교차했다.

 

 

아무튼.

 

지난 CVPR에는 Meta-SR 등의 arbitrary-scale SR 모델을

 

homography 등의 일반 transformation에 사용할 수 있지 않을까 하는 생각으로

 

SRWarp라는 확장된 개념을 제안했다.

 

솔직히 아주 마음에 드는 결과는 아니었지만 (자기 연구가 아주 마음에 드는 사람이 얼마나 있을까?)

 

뭔가 최대한 빨리 해야겠다는 촉이 와서 서두를 수 있었고, 다행스럽게도 좋은 리뷰를 받아 accept 되었다.

 

이와 동시에 이웃 연구실의 김미정 님이 혹시 이런 건 관심 없으시냐고 논문을 하나 보내주셨는데,

 

그게 이번에 정리해 놓을 LIIF다.

 

(평소에 개인적으로 이야기를 해본 적도 거의 없는데

 

어찌 내 관심 주제를 잘 기억하시고 알려주신 거에 대해 상당히 놀랐고 감사했다.)

 

잘 뜯어보니, Meta-SR이랑 큰 차이는 없음에도 

 

해당 work이 나온 뒤에 내 SRWarp를 내는 것은 꽤 난이도가 있겠구나 하는 생각이 들어서

 

이번에 서두르기를 참 잘했다는 생각이 든다.

 

개인적인 사정으로 가뜩이나 힘들었던 연초를 덕분에 잘 버틸 수 있었던 것 같다.

 

기왕 리뷰도 잘 나왔는데 spotlight이라도 하나 받았으면 더 좋았겠지만.

 

 

개인적으로 생각하는 SR 관련 연구의 최종 목표는 유저가 자유롭게 사용 가능한 application을 개발하는 것이다.

 

이를 위해서는 아직도 많은 관문들이 남아있는데, 그중 하나가 output의 크기를 자유롭게 조절하는 것이다.

 

의외로 딥러닝 쪽 초기 연구인 SRCNN이나 VDSR 등에서는 이러한 고민이 굉장히 적었는데,

 

Bicubic 보간법 등을 사용해 일단 input을 원하는 크기로 조절하고

 

그 후 SR 모델을 적용시키는 접근법을 채택했기 때문이다.

 

예를 들어 $128 \times 128$ input을 $300 \times 300$으로 만들고 싶다면 큰 고민 없이

 

일반적인 resizing 알고리즘으로 input을 $300 \times 300$으로 만든 뒤에 모델을 적용한다는 것이다.

 

물론 효과적인 솔루션이긴 하지만, 여기에는 크고 작은 두 가지의 문제가 있다.

  1. input이 커지면 크기의 제곱에 비례하여 계산량이 올라간다.
    예를 들어 input이 가로세로 3배 커지면 계산량은 제곱인 9배만큼 늘어나는데,
    말도 안 되는 수치라는 걸 알 수 있다.
  2. 아주 치명적인 건 아니지만, 보간법을 미리 사용하는 것 자체가 sub-optimal 하다.
    Bicubic 보간법을 예로 들자면, 0~255 바깥의 값을 만들어내는 경우가 비일비재하며
    input data를 어떤 식으로든 건드는 과정이기 때문에 해당 resizing 알고리즘이
    optimal choice가 아닌 경우에 단점이 될 수 있다.

 

이러한 한계들을 어느 정도 해결해주는 것이 ESPCN에서 제안된 sub-pixel convolution (혹은 pixel shuffle)이다.

 

Sub-pixel convolution. W. Shi, et al., “Real-Time Single Image and Video Super-Resolution Using an Efficient Sub-Pixel Convolutional Neural Network,” in CVPR 2016.

사실 transposed convolution과 큰 차이는 없다는 주장도 있지만,

 

아무튼 ESPCN 이후로 SR 모델들은 input의 크기를 조절해서 원하는 크기의 output을 만들기보다는

 

특정 레이어를 사용해서 중간 feature의 크기를 조절하는 것으로 훨씬 효율적인 계산을 구현했다.

 

그런데 이런 레이어들은 CNN이 regular 한 grid 위에서 정의되어 있다는 점 때문에 본질적인 한계를 갖는데,

 

정수배의 scale이 아니면 적용하기 힘들다는 것이다.

 

그러니까 효율적인 알고리즘을 구현하고자 새로운 레이어를 개발했는데,

 

이전에 아무렇지도 않게 사용하였던 arbitrary-scale 개념을 쓸 수 없어졌다는 trade-off가 생겨버렸다.

 

그런데 연구의 관점에서는 computation을 절약하는 게 (scale이 클 때는 order of magnitude 수준으로)

 

조금 더 중요하기도 하고, 만약 arbitrary-scale SR 모델이 필요하다면 적당히 사용할 수 있는 대체제가 있기 때문에

 

Sub-pixel convolution은 암묵적인 표준으로 인정받게 되었다.

 

 

여기서 말하는 대체재라는 것은, 만약 $\times r$의 SR 결과가 필요하다면

 

$s \gt r$인 임의의 $\times s$ 모델을 사용해서 우선 적당히 큰 output을 만들고,

 

이를 bicubic 보간법 등으로 줄여서 최종 결과를 얻는 것이다.

 

당연히 어느 정도 잘 작동하지만 근본적인 한계는 있다.

 

SR은 구조상 scale이 커질수록 문제의 난이도가 급격히 어려워진다.

 

예를 들어 $\times 4$와 $\times 8$은 단순히 2배의 scale 차이가 난다고 생각할 수도 있지만,

 

$\times 4$ 모델은 하나의 픽셀로부터 16개의 픽셀을 생성해야 하는 반면

 

$\times 8$ 모델은 그보다 48개나 많은 64개의 픽셀을 생성해야 한다.

 

이 말은 곧 $\times 8$ 모델의 성능에는 어느 정도 한계가 있을 수밖에 없다는 뜻이고

 

만약 $\times 2.5$의 결과가 필요한데 $\times 8$ 모델을 적용하고 이를 축소시키고자 하면,

 

애초에 $\times 8$ 모델이 생각보다 쓸만한 결과를 만들지 못할 수도 있다.

 

Meta-SR paper에서는 조금 더 다양한 셋업에서 이러한 SR 후 resizing 파이프라인이

 

sub-optimal 하다는 것을 실험적으로 보였다.

 

 

다시 말해, $\times 2.5$의 결과가 필요하면 해당 scale을 고려하여 모델을 학습하는 것이

제일 좋은 결과를 준다는 것인데, 여기서 또 고려할 사항들이 생긴다.

  • 모델이 임의의 scale로 확장된 순간, 기존의 딥 러닝 기반 SR 방법들에서 일반적으로 채택하던
    하나의 모델 = 하나의 scale의 가정이 깨지게 된다.
    별다른 이유가 있는 것은 아니고, $\times 2.5, \times 2.51, \times 2.52, \cdots$ 모델들을 일일이 만들어 놓을 수는 없으니까.
  • 그러면 이제 하나의 모델이 여러 (임의의) scale을 처리할 수 있어야 할 텐데,
    당연히 이 방법을 우선 고민해야 하고
  • 애초에 임의의 scale 개념이 일반적인 CNN에서는 straightforward하지 않은 개념인데,
    이를 어떻게 다룰지도 고민을 해야 한다.

LIIF에서는 이러한 요소들을 'implicit function'이라는 개념을 통해서 한방에 해결한다.

 

사실 이런 문제에 대한 솔루션을 처음으로 내놓은 것은 Meta-SR이고

 

의외로 Meta-SRLIIF를 꼼꼼히 비교해보면 writing이나 설명하는 관점이 많이 다를지언정

 

실제 아이디어를 구현하는 방식에는 큰 차이가 없는 것을 알 수 있다.

 

자세한 얘기는 다음 포스트에 정리하도록 하고,

 

일단 Meta-SRLIIF의 근간이 되는 배경 지식을 좀 정리할 필요가 있는데, 아래 포스트들을 참고.

 

2021.05.03 - [코딩/PyTorch] - PyTorch로 MATLAB imresize (bicubic interpolation) 구현하기 (1) - 이미지 resizing 배경 지식

2021.05.04 - [코딩/PyTorch] - PyTorch로 MATLAB imresize (bicubic interpolation) 구현하기 (2) - resampling (interpolation)

 

 

이전 글:

2021.05.03 - [코딩/PyTorch] - PyTorch로 MATLAB imresize (bicubic interpolation) 구현하기 (1) - 이미지 resizing 배경 지식

 

PyTorch로 MATLAB imresize (bicubic interpolation) 구현하기 (1) - 이미지 resizing 배경 지식

[코드] 일반적으로 Image Super-Resolution (SR) 모델을 학습할 때는 많은 양의 high-resolution (HR) 이미지를 준비한 뒤 이를 임의의 downsampling 방법으로 줄여 input low-resolution (LR)을 만든다. 그 후 이..

sanghyun.tistory.com

그래서 img[-0.12, -0.12]는 무엇인가?

 

사람에 따라서는 음의 좌표에 거부감이 들 수 있으므로... img[0.12, 0.12]가 무엇인지를 찾는 문제로 바꿔서 생각해도 상관없고.

 

 

아무튼 이 개념은 내가 학부시절 때 열심히 듣고, 좋아했던 수업 중 하나인

 

신호 및 시스템 Signals and Systems를 수강하면 어느 정도 감이 온다.

 

그런데 아무래도 수업에서는 학술적인 이론을 위주로 공부하다 보니, ideal reconstruction을 비중 있게 다루고

 

나머지 방법들에 대해서는 조금 소홀한 면이 있다고 느꼈다.

 

신호 및 시스템 정리를 지금 와서 하기에는 좀 그렇고,

 

이상적인 경우 우리가 갖고 있는 discrete $H \times W$ 이미지로부터 완벽한 continuous 신호를 복원할 수 있지만,

 

이게 가능한 경우는 현실적으로 극히 드물다.

 

따라서 다양한 트릭을 사용해서 continuous 이미지를 근사해서 나타내게 되는데, 이를 resampling이라고 하고

 

비전 분야에서는 interpolation (보간법)이라는 표현을 더 많이 사용하는 것 같다.

 

 

가장 쉽게 생각할 수 있는것은, 이전 포스트에도 언급했다시피 (0.12, 0.12)와 가장 가까운 점의 값을 사용하는 것이다.

 

이를 굉장히 직관적인 네이밍으로 nearest neighbor (NN)라고 하는데,

 

조금만 생각해보면 (0.12, 0.12)를 포함하는 (-0.5, -0.5)를 왼쪽 상단 꼭짓점,

 

(0.5, 0.5)를 오른쪽 하단 꼭짓점으로 하는 정사각형 내부의 모든 위치가 (0, 0)의 값을 참조하게 된다는 것을 알 수 있다.

 

(경곗값을 포함시키는지 여부는 별로 중요하지 않다.)

 

아래 그림은 $5 \times 5$ 이미지(?)에서 임의의 실수 좌표값에 대해 NN interpolation 기반으로 값을 취득한다면

 

어떤 값이 나오는지를 도식화한 것이다.

Nearest neightbor interpolation. 출처는 위키피디아.

사실 NN interpolation은 continuous 이미지의 근사 형태라고 생각하기에는 조금 힘든 게,

 

저렇게 해서 나오는 결과는 계단식으로 discontinuous 하기 때문이다.

 

만약 이미지의 색상 값을 위치 $(x, y)$에 대한 함수로 나타낸다면 (implicit representation? ㅎㅎ)

 

NN interpolation으로 얻는 함수는 임의의 점에서 미분 불가능하거나, 미분 값이 0이다.

 

비전 분야에서는 아무래도 별 상관은 없지만

 

일단 보기에도 썩 좋지는 않고, 그래픽스 등 다른 분야에서는 수학적인 성질 또한 중요시 여기기에 많이 사용되지는 않는다.

 

굳이 수학적으로 표현하고자 하면 아래와 같을 듯 ($\lfloor \cdot \rceil$은 반올림.).

 

$q(x, y) = p( \lfloor x \rceil, \lfloor y \rceil )$.

 

여기서 $p$는 해당 위치에서의 색상 값 (정수 좌표에서 잘 정의되어 있는)이고

 

$q$는 우리가 색상 값을 알고 싶은 query 포인트라고 생각하면 되지만,

 

다른 application에서는 다른 의미를 가질 수도.

 

 

조금 더 나은 비주얼을 보여주면서 적당히 간단한 방법은 linear interpolation이다.

 

역시 이름이 직관적인데, 실수 좌표 (x, y)에 대해 query가 들어오면

 

해당 점을 둘러싼 4개의 정수 좌표를 가진 점들의 선형 결합으로 (x, y)에서의 색상 값을 만드는 형태이다.

 

Query point와 이를 둘러싼 4개의 점.

말로만 정리하면 조금 애매하기에 그림을 하나 만들었는데

 

일단 $x, y$가 정수일 때 같은 edge case는 쿨하게 무시해야지.

 

$\lfloor \cdot \rfloor$은 내림이다.

 

아무튼 저렇게 4개의 점에 대해서, 일단 위 2개와 아래 2개의 점들을 query의 $x$ 좌표를 기준으로 내분하고,

 

각각 $u_1, u_2$라고 한다.

 

편의상 $x - \lfloor x \rfloor = a, y - \lfloor y \rfloor = b$라고 두면

 

$u_1 = (1 - a) p_1 + a p_2,$

$u_2 = (1 - a) p_3 + a p_4,$

 

가 되고 여기서 $u_1$과 $u_2$를 query의 $y$ 좌표를 기준으로 한 번 더 내분해서 최종 색상 값을 얻는다.

 

$q = (1 - b) u_1 + b u_2.$

 

이렇게 $x$와 $y$ 방향으로 2번의 interpolation을 하기 때문에 bi-linear interpolation이라고도 부른다.

 

결과를 보면 NN interpolation 대비 장족의 발전을 이루었는데,

Bilinear interpolation. 출처는 위키피디아.

여전히 수학적인 특성은 좋지 못하다.

 

미분 값이 유의미한 정보를 담고 있기는 하지만,

 

정수 좌표 픽셀들의 위치에 뾰족점이 존재해서 항상 미분 가능하지는 않다.

 

 

비전 분야에서는 정말 별 의미가 없지만,

 

이렇게 수학적인 특성을 고려해서 interpolation을 구현하려면 상당히 문제가 복잡해진다.

 

Cubic interpolation 같은 경우는 항상 미분 가능한 특성을 지니도록 설계가 되었는데,

 

구하는 방법이 굉장히 복잡하다 (자세한 내용은 그래픽스로).

 

다행히 상당한 정확도와 괜찮은 효율로 cubic interpolation을 근사하는

 

cubic convolution (혹은 filtering?) 알고리즘이 있는데,

 

일반적으로 bicubic interpolation이라고 부르는 방법은

 

이러한 cubic convolution을 $x$와 $y$ 방향으로 2번 적용한 것이다.

 

Bilinear에서 주변 4개의 점들을 사용한 것에서 더욱 발전하여

 

Bicubic은 주변 16개의 점들을 사용한다.

 

어렵게 생각할 것 없이, query를 중심으로 $4 \times 4$ window를 배치하여

 

해당 window 안에 들어가는 점들의 linear combination으로 query 포인트의 색상을 정한다.

 

Bicubic interpolation에서의 query와 window.

식으로는 $q = \frac{1}{Z} \sum_{i=1}^{16}{w_i p_i}$이고, 여기서 $w_i$는 각 픽셀의 기여도, $Z = \sum{w_i}$이다.

 

그러면 중요한 것은 각 픽셀들의 상대적인 weight (혹은 contribution)을 어떻게 정하는지이다.

 

원리는 나도 모르겠지만 (cubic convolution과의 오차를 최대한 줄이는 방향으로 설계된 듯하다.)

 

아래와 같이 $w_i$를 계산한다.

 

$\begin{split}
w_i &= b\left( x_i - x \right) b\left( y_i - y\right), \text{where} \\
b\left( v \right) &= \begin{cases}
1.5\lvert v \rvert^3 - 2.5 \lvert v \rvert^2 + 1 & \text{for}\ \lvert v \rvert \leq 1, \\
-0.5 \lvert v \rvert^3 - 2.5 \lvert v \rvert^2 + 4 \lvert v \rvert - 2 & \text{for}\ 1 \lt \lvert v \rvert \leq 2, \\
0 & \text{otherwise}.
\end{cases}
\end{split}$

 

당연히 $(x_i, y_i)$는 $p_i$의 좌표이다.

 

라이브러리의 구현에 따라 계수들은 조금씩 변경될 수 있으며 (ex. OpenCV) 상기 계수들은 MATLAB 기준이다.

 

Interpolation 결과물을 보면 앞의 두 개보다 훨씬 둥글둥글하고 부드러운 모양인 것을 확인할 수 있다.

Bicubic interpolation. 출처는 위키피디아.

Query 포인트에서의 색상을 $z$축이라고 생각한다면, 각 interpolation 방법들을 아래와 같이 시각화하는 것도 가능하다.

Interpolation 방법들을 3D로 비교한 것. 출처는 위키피디아.

수학적으로 써먹기 힘들어 보이는 NN, Linear에 비해 Cubic이 상당히 부드럽게 표현되는 것을 알 수 있다.

 

 

사실 여기까지는 어딜 가도 잘 설명되어 있는 내용인데,

 

이상하게 이것만 가지고 구현을 하면 절대로 MATLAB과 같은 bicubic interpolation 결과를 얻을 수 없다.

 

가장 핵심적인 포인트는 antialiasing (AA)이다.

 

AA는 신호 처리에서 고주파 성분을 제한된 bandwidth로 표현할 때 생기는 artifact를 줄여주는 기법으로,

 

일반적으로 주어진 신호를 subsampling 하기 전에 low-pass filter (LPF)를 적용하는 것으로 구현된다.

 

MATLAB imresize는 이미지를 확대할 때는

 

다른 라이브러리들이랑 똑같은 방법으로 bicubic interpolation을 구현하는데,

 

축소할 때는 추가적인 AA 처리가 들어가고 이것이 차이의 결정적인 요인이다.

 

그러면 다른 라이브러리들은 왜 AA 처리를 안 하는가?라는 의문이 당연히 들지만,

 

일반적으로 NN을 제외한 bilinear, bicubic 등은 일종의 LPF라고 생각할 수 있으며

 

해당 interpolation 방법을 포함한 resizing은 AA를 포함했다고도 말할 수 있다.

 

그런데 축소 scale이 커지면 고정된 크기의 interpolation 커널 (bilinear, bicubic 등)들로는

 

artifact를 방지하기 쉽지 않을 때가 있다.

 

따라서 MATLAB에서는 독특한 방식으로 scale에 adaptive 하게 AA 처리를 하는데,

 

$s$배만큼 축소하는 경우

  1. Bicubic interpolation에서 window 크기를 가로세로 $s$배만큼 키운다.
    즉, 더 많은 주변 픽셀들이 query 포인트의 색상을 정하는 데에 사용된다.
  2. 확장된 window에 맞춰, $w_i = b\left( x_i - x \right) b\left( y_i - y \right)$ 대신 $w_i = b\left( \frac{x_i - x}{s} \right) b\left( \frac{y_i - y}{s} \right)$를 사용한다.

실제로 4배만큼 줄이는 경우를 비교해보면, antialiasing이 있을 때와 없을 때 차이가 상당히 많이 난다.

좌: MATLAB에서 AA 사용 / 우: MATLAB에서 AA 사용하지 않음. AA를 사용하지 않은 쪽이 훨씬 자글거린다.

코드상에서는

>> x_down = imresize(x, 1 / 4, 'bicubic', 'antialiasing', false);

이렇게 비활성화가 가능한데,

 

기본으로 켜져 있기 때문에 큰 의식을 하지 않은 것 같다.

 

 

아무튼 이정도면 실제 구현에 필요한 세부 사항은 다 명세를 했고,

 

어떻게 깔끔하게 코드에 담아내는지는 다음 포스트부터 정리해야지.

 

 

[코드]

 

일반적으로 Image Super-Resolution (SR) 모델을 학습할 때는

 

많은 양의 high-resolution (HR) 이미지를 준비한 뒤 이를 임의의 downsampling 방법으로 줄여

 

input low-resolution (LR)을 만든다.

 

그 후 이렇게 만들어진 pair들을 통해 supervised learning을 진행한다.

 

해당 방법을 real-world application에 적용하기에는 많은 문제가 있으나,

 

아무튼 전통적으로 내려오던 framework이기 때문에

 

어느 모델을 개발하던 이러한 과정은 최소 한 번 이상 거치게 된다.

 

그런데 SR 관련 연구를 하며 가장 거슬렸던 부분 중 하나는 저 임의의 downsampling 부분이다.

 

 

딥러닝이 대세로 나서기 이전에는 MATLAB이 비전 분야의 암묵적인 standard였던 것 같다.

 

그런데 여기서 문제는, MATLAB에서 이미지의 크기를 바꾸는 데에 사용하는 imresize 함수가

 

OpenCV나 다른 이미지 관련 라이브러리들과는 상당히 다른 구현을 가지고 있다는 점이다.

 

특히 문제가 되는 것은 기본으로 적용되는 bicubic 보간법인데,

 

아무리 결과물을 비교하고 설정을 바꿔봐도 다른 라이브러리가 만드는 LR 이미지와는 상당한 차이가 있었다.

 

SR 분야의 벤치마크 이미지들은 거의 다 MATLAB으로 만들어졌기 때문에,

 

만약 다른 라이브러리로 LR 이미지들을 만들고 학습에 사용하는 경우

 

training-test distribution에 차이가 생겨 벤치마크 성능이 저하되고,

 

기껏 알고리즘을 잘 만들어놓고도 제대로 된 evaluation을 할 수가 없다.

 

그렇기 때문에 SR 연구에서는 오랫동안 이미지 resizing이 필요한 부분을 우선 MATLAB으로 모두 처리하고,

 

그 후 적절한 framework를 사용하여 추가적인 resizing 작업을 시행하지 않고 학습을 진행했다.

 

안타깝게도 MATLAB과 Python의 접착성은 별로 좋지 않기 때문에,

 

Python 코드가 도는 도중 다이내믹하게 MATLAB의 imresize를 호출해서 적절한 작업을 수행한다는 것은

 

시도할 염두를 내지도 못했고 이 때문에 작업 효율이 상당히 떨어진다는 느낌을 받았다.

 

 

이러한 문제를 좀 완화하고자 몇 사람이 numpy로 MATLAB-compatible 하다고 주장하는 imresize 함수를 구현했고

 

Github에서 어렵지 않게 찾아볼 수 있다.

 

그런데 실제로 확인해보니, 다양한 문제가 있었는데

  • 너무 느리다.
    numpy라서 기본적으로 GPU 연산을 지원하지 않을 뿐만 아니라, 최적화도 거의 되어있지 않다.
    imresize는 최소 수백, 수천번을 호출하는 함수인데
    이미지의 크기가 크면 눈에 띄게 성능이 저하되는 것을 느꼈다.
  • MATLAB-compatible하지 않다.
    공개된 코드를 직접 돌려서 확인해보니 MATLAB과 거의 비슷한 결과가 나왔지만,
    특히 boundary 부분에서 MATLAB과는 많이 달랐다.
    이유도 찾았는데, 이는 후술.
  • 미분 불가능.
    이는 큰 문제가 아니긴 한데 기본적으로 numpy로 구현되어 PyTorch layer 등에 넣을 수 없다.
    넣는 순간 computation graph가 깨져, PyTorch의 가장 큰 장점인 자동 미분을 사용할 수 없다.

개인적으로 저런 것들을 참고 쓰는 성격은 아니기 때문에,

 

작년에 (굉장히 늦은 타이밍이지만. 이렇게 정리 글을 쓰는 타이밍은 더 늦고.) 큰 맘먹고 직접 구현해봤다.

 

상당한 고생을 했지만 막상 만들고 나니 의외로 유용하게 쓰이기에, 잘 만들었다는 생각 또한 들었다.

 

 

본격적으로 구현 detail을 정리하기 전에,

 

이미지 resizing에 대한 기본 배경 지식을 정리해놓으면 나중에도 유용하게 참고할 수 있을 것 같다.

 

우선 좌표계를 정의하는 것이 정말 중요하다.

 

우리가 일반적으로 Python에서 $H \times W$ 이미지를 다룰 때는,

 

img[0, 0] (채널 생략)이 가장 왼쪽 상단, img[H - 1, W - 1]이 가장 오른쪽 하단의 픽셀을 의미한다.

 

수직, 수평 방향으로 이웃한 픽셀은 1만큼 떨어져 있기 때문에 이를 실제로 배치해보면 아래와 같다.

Python에서 이미지 픽셀의 레이아웃.

그런데 실제로 이미지를 이렇게 표현하면 상당히 난감한 것이, 이미지 (빨간 점선)의 크기가

 

$H \times W$가 아닌 $(H - 1) \times (W - 1)$이 나온다.

 

(딴소리지만, PyTorch의 interpolation 관련 함수에서 받는 align_corners 인수가 이러한 요인과 관련되어있다.

링크에서 조금 읽어보면 바로 느낌이 올 듯.)

 

따라서 편의상, 각 픽셀이 $1 \times 1$의 공간을 점유한다고 가정하면 (실제 픽셀의 정의와는 다르다!)

 

아래와 같이 이미지를 (-0.5, -0.5)와 (H - 0.5, W - 0.5) 사이의 영역에서 정의할 수 있다.

수정된 픽셀 레이아웃.

이렇게 정의한 이미지는 정상적으로 $H \times W$의 크기를 갖는다.

 

다른 방법도 다양하게 있겠지만, 이 방법이 가장 직관적인 것 같다.

 

사람에 따라서는 음의 영역에서 뭔가를 정의하고,

 

영역 경계의 좌표가 정수가 아니라는 것이 굉장히 불편할 수는 있을 것 같지만... (처음에는 나도 좀 불편했다.)

 

오히려 문제는 생각지도 못한 곳에서 발생했는데, 직접적으로 구현에 영향을 줄 정도는 아니었다.

 

기회가 되면 정리해야지.

 

 

아무튼 이렇게 기준을 정해놓으면 이후의 내용들을 정리하기가 비교적 편리하다.

 

이미지 resizing은 크게 두 가지 스텝으로 분리해서 볼 수 있는데, mapping과 resampling이다.

 

$h \times w$ input 이미지를 $H \times W$로 resizing 하고자 할 때 ($H > h$일 필요는 없다.),

 

생각해야 하는 것은 output 이미지의 [x, y] 위치가 input의 어디에 대응하는지?이다.

 

간편한 수식으로 어렵지 않게 변환이 가능하지만, 아래의 예시를 보면 조금 더 쉽게 이해가 가능하다.

 

$3 \times 3$에서 $4 \times 4$로 가는 경우를 생각하자.

 

일단 같은 크기의 정사각형 2개를 $3 \times 3$, $4 \times 4$로 쪼갠다.

3x3, 4x4로 쪼갠 정사각형.

두 정사각형을 겹쳐보면, output 이미지의 각 픽셀이

 

input 이미지의 어디에 대응하는지에 대한 비례 관계를 확인할 수 있다.

Output-input 대응 관계.

Boundary가 정수가 아니기 때문에, 계산이 조금 귀찮을 수는 있지만 구체적인 식은 아래와 같다.

 

$\textcolor{red}{x} = \frac{w(\textcolor{blue}{x'} + 0.5)}{W} - 0.5,$

$\textcolor{red}{y} = \frac{h(\textcolor{blue}{y'} + 0.5)}{H} - 0.5.$

 

여기서 $\textcolor{red}{x}$와 $\textcolor{red}{y}$는 input 이미지를 기준으로 한 좌표

 

$\textcolor{blue}{x'}$와 $\textcolor{blue}{y'}$는 output 이미지를 기준으로 한 좌표가 되는데

 

예를 들자면 output 이미지의 (0, 0)은 input 이미지의 (-0.12, -0.12)에 대응된다.

 

 

이제 드는 의문은, 그래서 (-0.12, -0.12)는 어디에 있는가?이다.

 

디지털 이미지는 regular grid 위에서 정의되기 때문에, img[0, 0]은 있어도 img[-0.12, -0,12]는 에러가 난다.

 

적당히 가장 가까운 픽셀의 값을 가져오면 되는 것 아닌가? 하는 의문이 들 수 있지만

 

(해당 방법이 nearest neighbor, 혹은 NN 보간법이다.)

 

그러면 자글자글한 계단 현상이 생기고 보기에 좋지 않다.

5x5 이미지를 NN과 bicubic으로 interpolation 한 결과. 출처는 위키피디아. https://en.wikipedia.org/wiki/Bicubic_interpolation

Bicubic 결과는 지나치게 뿌연 느낌이 든다고 생각할 수도 있지만, 뭐 일단은 그렇다.

 

이를 해결하기 위해서는 적절한 resampling이 필요한데, 다음 포스트에 정리해야겠다.

 

2021.05.04 - [분류 전체보기] - PyTorch로 MATLAB imresize (bicubic interpolation) 구현하기 (2) - resampling (interpolation)

 

PyTorch로 MATLAB imresize (bicubic interpolation) 구현하기 (2) - resampling (interpolation)

이전 글: 2021.05.03 - [코딩/PyTorch] - PyTorch로 MATLAB imresize (bicubic interpolation) 구현하기 (1) - 이미지 resizing 배경 지식 PyTorch로 MATLAB imresize (bicubic interpolation) 구현하기 (1) - 이..

sanghyun.tistory.com

 

이전 글:

2021.04.28 - [코딩/PyTorch] - 파이토치 PyTorch CUDA custom layer 만들기 (1) - CUDA 커널

2021.04.30 - [코딩/PyTorch] - 파이토치 PyTorch CUDA custom layer 만들기 (2) - 커널 빌드 및 Python 바인딩

2021.04.30 - [코딩/PyTorch] - 파이토치 PyTorch CUDA custom layer 만들기 (3) - setup.py

 

직접 구현한 PyTorch 연산에서 backward를 구현하기 위해서는 두 가지가 필요한데,

  1. back-propagation 되는 미분 값을 계산하는 코드
  2. autograd.Function을 사용한 wrapping

이다.

 

다행히 두 가지 모두 어렵지 않게 처리가 가능하다.

 

일단 chain rule을 사용하면, 임의의 변수 $x$와 $z$에 대해 아래와 같은 관계가 성립한다.

 

$\frac{\partial \mathcal{L}}{\partial x} = \frac{\partial \mathcal{L}}{\partial z} \frac{\partial z}{\partial x}$.

 

구현한 연산 $z = x + y$에서 $\frac{\partial z}{\partial x} = 1$이므로,

 

$\frac{\partial \mathcal{L}}{\partial x} = \frac{\partial \mathcal{L}}{\partial z}$이고 $x$ 대신 $y$를 넣어도 성립한다.

 

Backpropagation이란 $\frac{\partial \mathcal{L}}{\partial z}$를 알 때 $\frac{\partial \mathcal{L}}{\partial x}$와 $\frac{\partial \mathcal{L}}{\partial y}$를 구하는 것이므로 계산 끝.

 

CVPR에 사용하기 위해 구현한 코드는 몇 배 이상 복잡하고 잔머리도 많이 굴려야했는데,

 

가능하다면 Python 등을 활용해 미리 내 로직이 맞는지 점검하는 과정이 있어야 한다.

 

 

아무튼 이제 이걸 CUDA 커널에 적당히 프로그래밍하면 된다.

 

귀찮지만, 총 3개의 파일을 건드려야 한다.

 

우선 vecadd_kernel.cuh에 다음 Line들을 추가한다.

 //vecadd_kernel.cuh에 추가
void VecAddBackwardCuda(
    float* dx,
    float* dy,
    const float* dz,
    const int n
);

이번에는 dz를 받아 dx, dy를 구하는 것이 목적이므로 (1)과 비교하면 const의 위치가 바뀐 것을 주의해야겠다.

 

2021.04.28 - [코딩/PyTorch] - 파이토치 PyTorch CUDA custom layer 만들기 (1) - CUDA 커널

 

파이토치 PyTorch CUDA custom layer 만들기 (1) - CUDA 커널

종종 기발한 아이디어를 떠올리다 보면, "이걸 구현하기 위한 레이어가 파이토치에 있었나?" 라는 의구심이 든다. 다행히 세상이 좋아져서 대부분의 멋진 아이디어들은 쓰기 좋고 깔끔한 파이토

sanghyun.tistory.com

 

다음으로 vecadd_kernel.cuCUDA 커널을 호출하는 함수실제 CUDA 커널을 작성한다.

// vecadd_kernel.cu에 추가
__global__ void VecAddBackwardCudaKernel(
    float* __restrict__ dx,
    float* __restrict__ dy,
    const float* __restrict__ dz,
    const int n
)
{
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        dx[idx] = dz[idx];
        dy[idx] = dz[idx];
    }
    return;
}

void VecAddBackwardCuda(
    float* dx,
    float* dy,
    const float* dz,
    const int n
)
{
    int n_blocks = int((n + NUM_THREADS - 1) / NUM_THREADS);
    VecAddBackwardCudaKernel<<<n_blocks, NUM_THREADS>>>(dx, dy, dz, n);
    return;
}

실제 back-propagation이 구현이 CUDA 커널에서 어떻게 구현되었는지 참고하면 된다.

 

지난번에도 언급했지만, __restrict__는 in-place operation의 가능성이 있을 때는 사용하면 안 된다.

 

여기서도 지우는 게 맞는데 참고.

 

여담이지만 이 과정에서 문제 아닌 문제는 각 함수들의 이름을 짓는 게 썩 쉽지 않다는 것...

 

나는 Forward/Backward, Cuda, Kernel을 잘 조합해서 지었는데 (Forward는 생략했지만)

 

나중에 어떻게든 구분이 가능하게 재주껏 지으면 된다.

 

 

Makefile은 수정할 필요 없고, vecadd.cpp에도 backward를 추가해준다.

// vecadd.cpp에 추가
void VecAddBackward(
    torch::Tensor dx,
    torch::Tensor dy,
    const torch::Tensor dz
)
{
    int n = dx.numel();
    VecAddBackwardCuda(
        dx.data_ptr(),
        dy.data_ptr(),
        dz.data_ptr(),
        n
    );
    return;
}

PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
    m.def("vecadd", &VecAdd, "Vector add");
    m.def("vecadd_backward", &VecAddBackward, "Vector add backward");
}

새롭게 vecadd_backward 함수가 바인딩되는 것을 알 수 있다.

 

이렇게 해서 일단 구현 자체는 완료했고,

 

실제로 사용하려면 cuda_extension 바로 아래에 있는 Makefile을 통해서 빌드하고 호출하면 된다.

 

다만 이렇게 하는 경우 PyTorch의 가장 큰 강점 중 하나인 자동 미분이 안되므로

 

autograd.Function과 결합해주는 과정이 필요하다.

 

 

링크의 공식 documentation을 보면 잘 설명이 되어있는데, 적당히 필요한 정보를 취했다.

 

추가적으로, setup.py를 통해 작성한 autograd.Function을 패키지 안에 포함시켜

 

개별적인 파일 관리 없이 해당 패키지가 설치된 environment 내에서 자유롭게 import 할 수 있도록 했다.

 

이 과정은 이전 setup.py 작성 시에 다 고려를 했으므로 별도로 건드릴 내용은 없지만.

 

(setup.py의 packages=setuptools.find_packages() 부분)

 

 

아무튼 cuda_extension 폴더 아래에 (setup.py가 있는 위치) pyvecadd라는 폴더를 새로 만들고,

 

내용 없는 __init__.py를 해당 폴더에 추가하여 해당 폴더가 Python 패키지임을 명시해준다.

 

그다음 __init__.py가 있는 위치에  layer.py라는 파일을 아래와 같이 작성한다.

현재 폴더 구조. build, dist, *-info는 자동적으로 생성된 파일들.

# layer.py
import typing

import torch
from torch import autograd
from torch import nn

import vecadd_cuda


class VecAddFunction(autograd.Function):

    @staticmethod
    def forward(
            ctx: autograd.function._ContextMethodMixin,
            x: torch.Tensor,
            y: torch.Tensor) -> torch.Tensor:

        z = torch.empty_like(x)
        vecadd_cuda.vecadd(x, y, z)
        return z

    @staticmethod
    def backward(
            ctx: autograd.function._ContextMethodMixin,
            dz: torch.Tensor) -> typing.Tuple[torch.Tensor, torch.Tensor]:

        dx = torch.empty_like(dz)
        dy = torch.empty_like(dz)
        vecadd_cuda.vecadd_backward(dx, dy, dz)
        return dx, dy


vecadd = VecAddFunction.apply


class VecAddLayer(nn.Module):

    def __init__(self) -> None:
        super().__init__()
        return

    def forward(self, x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
        z = vecadd(x, y)
        return z

요즘 Python 코드를 작성할 때 가능하면 type hinting (참고)을 사용하려고 하는데, 좋은지는 잘 모르겠다.

 

절대 필수가 아니므로 귀찮으면 다 빼버려도 된다 (훨씬 깔끔하게 보일 수도?).

 

 

결코 코드가 긴 것은 아니지만, 어디서부터 정리해야 할지 제법 막막하다.

 

Line 8은 우리가 setup.py로 빌드한 vecaddvecadd_backward를 포함하고 있는 vecadd_cuda를 import 한다.

 

VecAddFunction은 실제 자동 미분 로직을 구현하는 class인데,

 

공식 사이트의 documentation이 잘 되어있어 그대로 따라 하면 된다.

 

몇 가지만 가볍게(?) 정리해놓자면,

  • Line 20과 30이 실제 우리가 C++에서 바인딩하고, setup.py를 통해 빌드한 함수를 호출하는 부분이다.
  • Line 15와 25의 ctx는 forward시에 사용한 값이 backward시에도 필요한 경우, 이를 저장해두는 버퍼이다.
    아마 나중에 실제 구현한 코드를 정리하며 다시 등장할 수도 있는데,
    지금은 굳이 백업이 필요 없으므로 사용되지는 않았다.
  • Line 19, 28, 29는 return에 사용되는 값들을 위한 메모리를 할당하는 부분이다.
    C++ 나 CUDA 레벨에서 메모리 관리를 하기 싫기 때문에 PyTorch 레벨에서 관리하도록 했다.
    따라서 forward 로직은 이제 input x, y 2개 만을 인수로 받아 새로운 output z를 return 한다.
    이전처럼 모양 빠지게 z를 미리 할당하고, vecadd_cuda.vecadd(x, y, z) 같이 할 필요가 없다는 뜻.
  • backward에서는 forward에서 ctx를 제외하고 입력받은 모든 인수에 대해 gradient를 계산해야 한다.
    따라서 x와 y에 대한 gradient인 dx, dy를 순서대로 return 하는데,
    텐서가 아니거나 상수 인수를 forward에서 전달받은 경우에는 해당 순서에 None을 return 하면 된다.
  • VecAddFunction은 추상적인 개념이고, 실제로는 Line 34에 정의한 vecadd를 호출하여 사용한다.
    그 아래의 VecAddLayer는 해당 연산을 custom layer로 구현한 class인데,
    vecadd <-> torch.nn.functional.conv1d
    VecAddLayer <-> torch.nn.Conv1d
    같은 관계로 생각할 수 있다.
    실제로 VecAddLayer 내부에서 vecadd를 호출하여 계산을 진행하는데,
    torch.nn.Sequential 같은 모듈을 활용하려면 이런 layer 형태를 쓰는 것이 유리하다.

 

이제 진짜 완성이다!

 

마지막으로 cuda_extension 아래에 있는 setup.py를 다시 실행해서

  • 새로 구현한 backward CUDA 커널 및 C++ 인터페이스
  • 해당 함수의 Python 바인딩
  • 해당 함수의 자동 미분을 구현하는 vecadd 폴더 (패키지) 아래에 포함된 layer.py 모듈

들을 현재 environment에 설치한다.

 

 

사용법은 아래와 같다.

  • vecadd.cpp 파일에 구현되어 Python에 바인딩된 함수를 바로 사용 (이제는 그럴 일이 거의 없지만)
>>> import vecadd_cuda
>>> vecadd_cuda.vecadd(x, y, z)
>>> vecadd_cuda.vecadd_backward(dx, dy, dz)

import 하는 패키지의 이름은 우리가 setuptools.setup에 지정했던 ext_modules의 name이다.

 

당연히 자동 미분을 지원하지 않으며, z와 dz에 대한 사전 할당이 필요하다.

  • 자동 미분이 지원되는 PyTorch (Python)식 함수 사용 (이걸로 쓰자)
>>> from pyvecadd import layer
>>> z = layer.vecadd(x, y)
>>> z.mean().backward()

그냥 우리가 cuda_extension 폴더 아래에 만든 pyvecadd 폴더가

프로젝트 폴더에도 똑같이 있다고 생각하고 사용해주면 된다 (그래서 이름을 잘 지어놓아야 한다...).

실제로는 environment에 설치되어 있기 때문에,

 

파일을 매번 옮겨놓지 않아도 되는 점이 내가 가장 마음에 드는 부분이다.

 

조금 구체적인 예시는 아래와 같다.

>>> import torch
>>> from pyvecadd import layer
>>> x = torch.randn(4, 4, requires_grad=True, device=torch.device('cuda'))
>>> y = torch.randn(4, 4, requires_grad=True, device=torch.device('cuda'))
>>> print(x)
tensor([[-0.3005, -1.2514,  0.0675, -1.6077],
        [-1.1321,  0.8361, -0.7227,  1.4445],
        [ 1.1019, -0.2202, -0.3690,  0.3014],
        [ 0.6847, -0.7965, -0.7134,  2.0782]], device='cuda:0',
       requires_grad=True)
>>> print(y)
tensor([[ 1.4736, -0.5728,  0.4896, -0.9496],
        [-0.0979,  0.0057,  0.7292, -0.5978],
        [ 1.5153, -0.2581,  0.0160,  0.8342],
        [ 0.1993,  0.2244,  0.0465, -0.7861]], device='cuda:0',
       requires_grad=True)
>>> z = layer.vecadd(x, y)
>>> print(z)
tensor([[ 1.1731, -1.8242,  0.5571, -2.5573],
        [-1.2300,  0.8418,  0.0065,  0.8467],
        [ 2.6172, -0.4784, -0.3530,  1.1356],
        [ 0.8840, -0.5722, -0.6669,  1.2921]], device='cuda:0',
       grad_fn=<VecAddFunctionBackward>)
>>> print(x + y)
tensor([[ 1.1731, -1.8242,  0.5571, -2.5573],
        [-1.2300,  0.8418,  0.0065,  0.8467],
        [ 2.6172, -0.4784, -0.3530,  1.1356],
        [ 0.8840, -0.5722, -0.6669,  1.2921]], device='cuda:0',
       grad_fn=<AddBackward0>)
>>> z.mean().backward()
>>> print(x.grad)
tensor([[0.0625, 0.0625, 0.0625, 0.0625],
        [0.0625, 0.0625, 0.0625, 0.0625],
        [0.0625, 0.0625, 0.0625, 0.0625],
        [0.0625, 0.0625, 0.0625, 0.0625]], device='cuda:0')
>>> print(y.grad)
tensor([[0.0625, 0.0625, 0.0625, 0.0625],
        [0.0625, 0.0625, 0.0625, 0.0625],
        [0.0625, 0.0625, 0.0625, 0.0625],
        [0.0625, 0.0625, 0.0625, 0.0625]], device='cuda:0')

본래의 목적인 custom layer로 사용하는 것도 가능.

>>> vecadd_layer = layer.VecAddLayer()
>>> zz = vecadd_layer(x, y)

 

아무튼 이렇게 해서 PyTorch CUDA custom layer 구현에 대한 설명은 정리가 끝난 것 같다.

 

라고 말하고 싶은데, 사실 구현 대상 함수가 굉장히 쉬웠기 때문에 고려하지 않은 많은 요인이 있다.

 

이후의 내용은 코딩 실력이 완벽하여 에러를 절대 내지 않는다면 굳이 참조할 필요는 없지만,

 

나는 아쉽게도 그렇지 못해서 조금 더 정리를 해둬야 할 것 같다.

 

To be continued...

이전 글:

2021.04.28 - [코딩/PyTorch] - 파이토치 PyTorch CUDA custom layer 만들기 (1) - CUDA 커널

2021.04.30 - [코딩/PyTorch] - 파이토치 PyTorch CUDA custom layer 만들기 (2) - 커널 빌드 및 Python 바인딩

 

이전 포스트에 이어 PyTorch (C++)와 PyTorch (Python)을 연결하는 방법을 정리해야겠다.

 

이 부분은 의외로 간단한것이, setup.py 템플릿을 조금만 손보면

 

PyTorch의 cpp_extension이라는 모듈로 어렵지 않게 해결이 가능하다.

 

 

원리는 생략하고 아래와 같이 setup.py를 작성하자.

 

참고로 나는 setup.py의 위치를 cuda 폴더 바깥으로 했다.

 

전체 폴더 구조는 대충 아래와 같다.

cuda_extension 폴더 아래 setup.py를 놓는다.

# setup.py
from os import path
import setuptools

from torch.utils import cpp_extension

def main() -> None:
    setuptools.setup(
        name='ext_vecadd',
        version='1.0.0',
        author='Your name',
        author_email='your@email.com',
        packages=setuptools.find_packages(),
        ext_modules=[cpp_extension.CppExtension(
            name='vecadd_cuda',
            sources=[path.join('cuda', 'vecadd.cpp')],
            libraries=[
                'vecadd_kernel',
            ],
            library_dirs=[path.join('.', 'cuda')],
            extra_compile_args=['-g', '-fPIC'],
        )],
        cmdclass={'build_ext': cpp_extension.BuildExtension},
    )
    return

if __name__ == '__main__':
    main()

당연한 소리지만 version, author, author_email은 취향대로.

 

 

여기서 중요하게 볼 부분은 setup 함수의 ext_modules 부분이다.

 

링크에서 공식 documentation을 보면 CppExtension 말고도 CUDAExtension이라는 함수도 존재하는데,

 

막상 써보니까 잘 동작하지 않았고 돌아돌아 결론을 내린게 CppExtension이었다.

 

CUDAExtension을 쓰면 별도의 Makefile로 shared object (.so)를 빌드하지 않아도 될 것 같은데,

 

잘 안되니까 나중에 다시 해봐야겠다.

 

CppExtension의 name 인수에는 나중에 Python에서 해당 모듈을 불러올때 사용하는 이름을 지정한다.

 

예를 들어, 위의 setup.py로 빌드한 모듈은 아래와 같이 불러올 수 있다.

import torch
import vecadd_cuda

반드시 Line 1이 선행되어야 하는 점에 주의하면, 큰 문제는 없다.

  • sources 인수는 빌드할 cpp 파일(들)을 지정한다.
    이미 vecadd.cpp에서 CUDA 커널을 호출하도록 되어있기 때문에, .cu 파일을 포함할 필요는 없다.
  • 대신에 우리가 빌드한 .so 파일을 libraries라는 인수에 포함시키는데,
    vecadd_kernel을 지정하면 자동으로 libvecadd_kernel.so가 빌드에 포함된다고 생각하면 된다.
  • library_dirs에는 해당 .so 파일이 포함되어 있는 폴더를 지정해주고,
  • extra_complie_args는 -g와 -fPIC를 넣어주면 완성.

이제 터미널에서 아래와 같은 커맨드로 전체 프로젝트를 빌드한다.

$ python setup.py install

여기서 주의할 점은, conda 등의 가상 환경을 사용 중인 경우 해당 environment에만 패키지가 설치된다는 것.

 

뭔가 더 해야할 것 같은 느낌이 들지만 의외로 지금 당장 작성한 CUDA 커널을 PyTorch (Python)에서 사용 가능하다.

 

우선 Python을 실행시키고, 아래와 같이 하면 테스트 가능.

>>> import torch
>>> import vecadd_cuda
>>> x = torch.randn(4, 4, device=torch.device('cuda'))
>>> y = torch.randn(4, 4, device=torch.device('cuda'))
>>> z = torch.zeros(4, 4, device=torch.device('cuda'))
>>> print(x)
tensor([[ 0.7651,  0.2747, -0.3275, -1.0251],
        [-0.5340, -0.8416,  0.2952,  0.7431],
        [-0.2698, -0.3798, -0.0225,  0.0245],
        [ 1.2056,  1.9831,  0.1669, -0.2347]], device='cuda:0')
>>> print(y)
tensor([[-1.0483,  0.6131, -0.2479,  0.3100],
        [ 0.6286,  0.4175, -0.4093, -1.5263],
        [ 0.6393,  1.3988, -0.0907,  0.1472],
        [-1.0677,  1.0088, -1.4845, -0.7323]], device='cuda:0')
>>> print(z)
tensor([[0., 0., 0., 0.],
        [0., 0., 0., 0.],
        [0., 0., 0., 0.],
        [0., 0., 0., 0.]], device='cuda:0')
>>> vecadd_cuda.vecadd(x, y, z)
>>> print(z)
tensor([[-0.2832,  0.8878, -0.5754, -0.7151],
        [ 0.0947, -0.4241, -0.1141, -0.7832],
        [ 0.3695,  1.0189, -0.1133,  0.1716],
        [ 0.1379,  2.9919, -1.3177, -0.9670]], device='cuda:0')
>>> print(x + y)
tensor([[-0.2832,  0.8878, -0.5754, -0.7151],
        [ 0.0947, -0.4241, -0.1141, -0.7832],
        [ 0.3695,  1.0189, -0.1133,  0.1716],
        [ 0.1379,  2.9919, -1.3177, -0.9670]], device='cuda:0')

random 수치들은 테스트할 때마다 다르게 나오겠지만, 아무튼 z = x + y가 잘 작동한다.

 

코드를 잘(?) 만들어놔서인지, 2D 텐서를 처리하는 데에도 큰 문제가 없다.

 

 

그런데 현재 상태로는 CUDA 커널이 수정될때마다 Makefile 후 setup.py를 install 하는 두 단계를 거쳐야 해서 조금 번거롭다.

 

기왕 하는거 두 개를 한 번에 하는 스크립트도 작성해놨다.

 

아래 내용을 setup.py가 있는 폴더에 Makefile 파일을 만들고 붙여넣으면 된다.

SUBDIRS = cuda

all: vecadd
    python setup.py install

vecadd:
    $(MAKE) -C cuda

clean:
    rm -rf build dist ./*.egg-info
    for dir in $(SUBDIRS); do $(MAKE) -C $$dir Makefile $@; done

역시나 야매지만 $(MAKE)와 -C라는 문법을 통해 sub directory의 Makefile을 실행하는 형식인가 보다.

 

clean의 경우 저것 말고도 여러 가지 방법이 있는데 내가 보기에 깔끔한 걸로 골랐다.

 

아무튼 이제 setup.py를 실행시키는 대신에, Makefile을 한 번만 실행하면 된다!

$ make

여기서 끝?

 

이면 참 좋겠지만, 몇 가지 불편한 사실들이 남아있다.

  1. z가 자동으로 할당되지 않는다.
    보통 우리는 x와 y를 가지고 있을 때, x + y가 z를 return 하기를 원하지
    z를 미리 할당하고 x + y를 채우는 것을 별로 선호하지 않는다.
  2. 예외 처리가 되어있지 않다.
    작성한 커널은 CUDA 텐서에 대해서만 작동하지만 CPU 텐서가 들어와도 아무 경고가 없다 (내가 가장 삽질한 부분).
    x + y가 정상적으로 계산되지 않는 것은 당연하고.
  3. backward()가 작동하지 않는다. = backpropagation이 불가능하다.
    사실 가장 중요한 부분이다.
    달리 말하면, custom operation으로의 기능은 하지만 custom layer로의 기능은 전혀 할 수가 없다.

1과 2는 그러려니 해도, 3은 조금 치명적이다.

 

조금 불행한 소식은 3 또한 CUDA 커널을 직접 작성해서 구현해야 한다는 점,

 

그나마 다행인 소식은 CUDA 커널만 추가로 작성하고

 

Makefile이나 vecadd.cpp, setup.py를 크게 고칠 필요는 없다는 점이다.

 

또한 backward가 사용 가능하게 하려면 autograd.Function을 상속받아야 하는데, 이를 통해 1과 2를 함께 해결 가능하다.

 

 

이전에 backward를 미리 작성해놓지 않은 이유는 사실 간단한데,

 

이 단계에서 구현한 연산이 정확히 동작하지 않는다면 backward 또한 제대로 동작 할리 만무하기 때문이다.

 

그러니까 일단 CUDA 커널을 제대로 작성했는지 PyTorch (Python) 인터페이스를 사용하여 편하게 확인하고,

 

문제가 있다면 고쳐서 완벽한 동작을 보장한 다음 나머지 잡일 (좀 많지만)을 처리하러 가면 된다.

 

다음 글:

2021.05.01 - [코딩/PyTorch] - 파이토치 PyTorch CUDA custom layer 만들기 (4) - autograd.Function과 backward 구현

 

파이토치 PyTorch CUDA custom layer 만들기 (4) - autograd.Function과 backward 구현

이전 글: 2021.04.28 - [코딩/PyTorch] - 파이토치 PyTorch CUDA custom layer 만들기 (1) - CUDA 커널 2021.04.30 - [코딩/PyTorch] - 파이토치 PyTorch CUDA custom layer 만들기 (2) - 커널 빌드 및 Python 바..

sanghyun.tistory.com

 

이전 글:

2021.04.28 - [코딩/PyTorch] - 파이토치 PyTorch CUDA custom layer 만들기 (1) - CUDA 커널

 

파이토치 PyTorch CUDA custom layer 만들기 (1) - CUDA 커널

종종 기발한 아이디어를 떠올리다 보면, "이걸 구현하기 위한 레이어가 파이토치에 있었나?" 라는 의구심이 든다. 다행히 세상이 좋아져서 대부분의 멋진 아이디어들은 쓰기 좋고 깔끔한 파이토

sanghyun.tistory.com

기본적인 CUDA 커널을 작성했으면, 두 가지의 작업 방향이 생긴다.

 

일단 당연히 커널을 빌드하긴 해야하는데, 그다음

  1. 빌드한 커널 검증.
  2. 빌드한 커널을 PyTorch에 연결.

의 선택지가 생긴다.

 

개발을 하거나 복잡한 프로그램을 만들어 본 경험이 있다면 당연히 1을 하고 2를 해야 하는 게 아닌가? 생각이 들 수 있지만

 

나 같은 경우는 CUDA 프로그래밍에 대한 정확한 이해가 없는 야매다 보니까 1에 대한 오버헤드가 엄청났다.

 

조금만 조심해서 2를 마치면 1을 PyTorch API로 할 수 있기 때문에, 일단 1을 스킵했다.

 

사실 퍼포먼스 분석 등을 하려면 1이 필수적이기에 결국 1을 좀 공부했는데

 

그냥 방학숙제 밀어 두기 정도의 게으름을 피운 셈이다.

 

 

아무튼 이전 글에서 작성한 vecadd_kernel을 빌드하는 것은 간단하다.

 

아래와 같이 cuda 폴더 아래에 Makefile을 만들었다.

NVCC = /usr/local/cuda-10.1/bin/nvcc
INCFLAGS = -I /usr/local/cuda-10.1/include
CUDAFLAGS = -shared -O2 -gencode=arch=compute_75,code=sm_75 -std=c++14
CUDAFLAGSADD = --compiler-options "-fPIC"

all: libvecadd_kernel.so

libvecadd_kernel.so: vecadd_kernel.cu
    $(NVCC) $(INCFLAGS) -o $@ -c $^ $(CUDAFLAGS) $(CUDAFLAGSADD)

clean:
    rm -f ./*.so*

안타깝게도 개인적으로 학부 시절 Visual Studio만을 사용하였고 (F5 만 누르면 만사가 해결된다.),

 

직접 Makefile을 작성해야 할 만한 대규모의 프로젝트를 진행하지 않았기 때문에

 

Makefile에 대한 이해도가 몹시 떨어진다.

 

 

아무튼 굳이 정리를 하자면,

  • NVCC: NVIDIA CUDA compiler가 설치된 위치.
  • INCFLAGS: CUDA include 파일들이 포함된 위치.
  • CUDAFLAGS: CUDA 컴파일 옵션. Compatibility 오류가 발생하면 -gencode=arch 부분을 건드리면 된다.
  • CUDAFLAGSADD: 해당 옵션을 주지 않으면 PyTorch에 바인딩시킬 수 없더라. 일단 이유는 모르지만 넣으라고 해서 넣음.

CUDA 프로그래밍을 하는데 당연히 CUDA는 설치가 되어있어야 한다.

 

10.1은 버전 맞춰서 맘대로 바꾸면 되고, CUDA 설치 시에 symbolic link를 만들었다면 cuda-10.1 대신 cuda만 넣어도 된다.

 

나머지 줄들은 그냥 이렇게 정의한 인수들을 죄다 집어넣고 libvecadd_kernel.so라는 shared object(.so)를 만드는 과정이다.

 

Makefile 매크로들도 야매로 배워서 썼는데,

  • $@: 현재 object 파일. 위에서는 libvecadd_kernel.so를 의미할 듯.
  • $^: 현재 target (libvecadd_kernel.so)이 의존하는 전체 대상 목록.
    위에서는 vecadd_kernel.cu를 써놨기 때문에 해당 파일을 가리킬 듯.

분명 더 깔끔하고 명확하게 작성할 수 있을 텐데, 언젠가 공부할 때가 올지 안 올진 모르겠다.

 

 

빌드를 하면 cuda 폴더에 libvecadd_kernel.so 파일이 생성된다.

$ make

 

이제 이렇게 만들어진 .so 파일을 PyTorch에서 호출할 수 있게 만들어야 한다.

 

아래 다이어그램을 리뷰해보면, vecadd.cpp와 setup.py가 필요하다는 것을 알 수 있는데

파이토치 CUDA custom layer 프로젝트 다이어그램.

한 마디로 정리하면, vecadd.cpp는 우리가 만든 CUDA 커널 (.so)과 PyTorch (C++)을 연결해주기 위해

 

setup.py에 제공되는 파트이다.

 

우선 이전처럼 cuda 폴더 아래에 vecadd.cpp를 아래와 같이 작성하자.

// vecadd.cpp
#include <torch/extension.h>
#include "vecadd_kernel.cuh"

void VecAdd(
    const torch::Tensor x,
    const torch::Tensor y,
    torch::Tensor z
)
{
    // Equivalent to nelement()
    int n = x.numel();
    VecAddCuda(
        x.data_ptr(),
        y.data_ptr(),
        z.data_ptr(),
        n
    );
    return;
}

PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
    m.def("vecadd", &VecAdd, "Vector add");
}

C++ 코드이지만 PyTorch 코드처럼 보이기도 하는데,

 

Line 2에서 include 한 torch/extension.h가 PyTorch (C++)의 인터페이스를 제공한다.

 

해당 헤더의 경우 별도로 설치할 필요는 없고, PyTorch (Python)을 설치하면 자동으로 딸려온다.

 

이는 그리 좋지 못한 소식일 수도 있는데, IDE를 사용하며 자동완성이나 함수 추천을 받을 수 없다는 뜻도 된다.

 

공식 사이트의 documentation이 형편없는 수준이므로, 필요한 함수가 있으면 재주껏 구글링 하자...

 

 

아무튼 vecadd.cpp에는 VecAdd라는 함수를 정의하는데,

 

인수의 자료형을 보면 알 수 있듯이 PyTorch (C++) 텐서를 받는다.

 

이 레벨부터는 상당한 추상화가 이루어진 상태이고 텐서들은 high-level 객체들이기 때문에

 

쌩 CUDA 코드에서 하기 귀찮았던 것들을 여기서 조금 만져주기 용이하다 (예외 처리라던가.).

 

다만 해당 사항들은 PyTorch (C++)와 친분이 있는 사람에 한정되므로

 

여기에서는 텐서 3개만 달랑 주어지면 CUDA 커널에 넘겨주기 위한 x 텐서의 크기를 구하는 코드만 들어갔다.

 

아쉬운 것은 C++와 Python 버전의 네이밍이 조금 다를 때가 있다는 것인데

 

예를 들어 Python 버전의 Tensor.nelement() 함수는 numel()로 바뀌어버렸다.

 

직관성은 내다 버린 디자인이라서 최소한의 전처리만 하도록 하자.

 

 

Line 5~20은 PyTorch (C++)와 CUDA 커널을 연결하는 부분인데,

 

아무래도 둘 다 C++을 기반으로 하기에 접착성이 상당히 좋은 편이다.

 

PyTorch 텐서를 data_ptr<float>()를 호출해서 float 포인터로 바꿔주면

 

바로 우리가 작성한 CUDA 커널을 호출하는 함수에 넘겨주는 것이 가능하다.

 

data_ptr()을 대신 사용하면 void 포인터를 얻을 수 있는데,

 

해당 텐서의 자료형이 C++ 기본 자료형이 아닌 경우에 유용하다.

 

(왜냐하면 vecadd.cpp는 CUDA 프로그램이 아니기 때문)

 

대표적인 예가 HalfTensor인데, CUDA에는 해당 자료형이 존재하지만 C++는 아니다.

 

이런 경우 일단 void 포인터를 CUDA 프로그램으로 넘겨주고, 이를 적당히 캐스팅해서 사용하면 된다.

 

만약 PyTorch 텐서가 GPU 상에 있으면, data_ptr()이 가리키는 주소도 GPU 상의 배열이기 때문에

 

cudaMemcpy 등의 지저분한 함수를 쓸 필요가 없는 점이 참 좋다.

 

z는 미리 할당해놓았는데 C++에서는 메모리 관리를 하나도 하지 않겠다는 의지가 들어있다.

 

나중에 PyTorch 인터페이스를 만들 때 다시 등장할 듯.

 

 

아무튼 이렇게 어렵지 않게 함수 작성이 완료된다.

 

마지막으로 이렇게 정의한 함수를 Python에서 호출할 수 있도록 바인딩해주는 과정이 필요한데,

 

Line 22~24의 형태를 그대로 사용하면 된다

 

def()의 인수들은 차례대로 우리가 Python에서 사용할 함수명, C++에 정의한 함수의 reference, 간단한 설명이다.

 

여기를 참고하면 될 듯한데, 아직까지는 봐도 별 의미가 없는 것 같다.

 

여러 개의 함수를 바인딩하려면 Line 23 아래에 같은 형식으로 추가적인 def를 넣어주면 된다.

 

이제 만든 함수를 Python에서 호출할 수 있도록 빌드해주면 되는데

 

별도의 Makefile을 작성하지 않고도 setup.py로 해결이 가능하다.

 

나에게 있어 정말 다행인 소식은 이제 CUDA와 C++ 코드를 보며 씨름하지 않고,

 

Python 레벨에서 남은 작업을 마무리하면 된다는 것이다.

 

(물론 튜닝이 필요하면 다시 돌아가야 한다...)

 

아무튼 setup.py와 추가적인 PyTorch 인터페이스 구현은 다음에 정리해야겠다.

 

다음 글:

2021.04.30 - [코딩/PyTorch] - 파이토치 PyTorch CUDA custom layer 만들기 (3) - setup.py

 

파이토치 PyTorch CUDA custom layer 만들기 (3) - setup.py

이전 글: 2021.04.28 - [코딩/PyTorch] - 파이토치 PyTorch CUDA custom layer 만들기 (1) - CUDA 커널 2021.04.30 - [코딩/PyTorch] - 파이토치 PyTorch CUDA custom layer 만들기 (2) - 커널 빌드 및 Python 바..

sanghyun.tistory.com

 

+ Recent posts