뉴럴 네트워크 시각화 프로젝트
지금 세계에서 가장 핫한 키워드를 하나 꼽으라면, AI일 것이다. 엔비디아가 AI를 등에 업고 나스닥 1위에 등극하는가 하면, AI 대가들이 노벨상을 받고, 각국이 너나할 것 없이 AI 기술 개발을 최우선 과제로 세우고 있다. 그래픽카드는 전략 물자로 분류되어 중국에 판매가 제한되고, 뉴스, 커뮤니티, 우리 부모님도 AI에 관해서 이야기한다. 그런데, 우리는 AI에 대해서 얼마나 잘 알고 있는가? 그래서 그게 뭔데?
이 프로젝트는 사실 내가 AI를 처음 공부하던 때부터 시작한 초장기 프로젝트다. 나는 ‘밑바닥부터 시작하는 딥러닝‘이라는 책으로 공부를 시작했다. 책 광고는 아니지만, 첫 시작으로 아주 훌륭한 책이다. 이 책에서는 넘파이를 이용해 바닥부터 뉴럴 네트워크를 구축하는 방법을 소개한다. 예제를 따라 하면서, 의아했다. 그냥 단순한 사칙연산에다가 더 단순한 활성화 함수를 추가했더니 이게 뉴럴 네트워크라는 것 아닌가? 뭐지? 이게 어떻게 숫자를 인식한다는 거지? 물론 인퍼런스만 짠 거지만, 이렇게 간단하다고? 믿을 수 없었다. 그래서 나는 정말로 바닥부터, 행렬 곱을 짜는 것부터 딥 러닝을 내가 직접 구현해 볼 계획을 세웠다.
1차 시도
모든 것을 내가 직접 구현해야 하니, 내게 가장 친숙한 언어로 시작했다. 그 당시에는 Processing을 가장 많이 사용했기 때문에 이를 선택했다. OpenGL을 직접 바닥부터 짜는 것보다 쉬우니 고른 것이기도 했다. 나중 이야기지만 이 선택, 별로였다.
시각화는 Processing으로 진행했지만, 학습까지는 자바로 짤 자신이 없었다. 그래서 학습은 그냥 PyTorch로 하고, 학습된 파라미터를 프로세싱으로 옮길 계획을 세웠다.
PyTorch로 기본적인 네트워크를 짰다. 처음엔 그냥 단순 MLP를 쌓고, 딥 러닝 첫 예제에서 많이들 사용하는 MNIST 데이터셋을 가져다가 학습시켰다. Binary Cross Entropy를 손실 함수로 사용해서 10개의 숫자 클래스에 대한 One-hot Value를 학습시켰고 손실이 그럭저럭 떨어지는 걸 확인했다. 그다음은 학습된 가중치와 바이어스를 .txt파일로 저장한 뒤, 이걸 프로세싱에서 불러와 파싱하도록 만들었다.
Tensor parseConvWeightsToTensor(String[] weights) {
int outChNum = weights.length;
int inChNum = 0;
int kernelWNum = 0;
int kernelHNum = 0;
// 각 차원의 크기를 계산
for (int i = 0; i < outChNum; i++) {
String[] inCh = weights[i].split("!");
inChNum = inCh.length;
for (int j = 0; j < inChNum; j++) {
String[] kernelW = inCh[j].split(",");
kernelWNum = kernelW.length;
for (int k = 0; k < kernelWNum; k++) {
String[] kernelH = kernelW[k].split(" ");
kernelHNum = kernelH.length;
}
}
}
// Tensor의 shape을 정의하고, Tensor 객체를 생성
int[] shape = {outChNum, inChNum, kernelWNum, kernelHNum};
Tensor tensor = new Tensor(shape);
// 파싱한 데이터를 Tensor에 저장
for (int i = 0; i < outChNum; i++) {
String[] inCh = weights[i].split("!");
for (int j = 0; j < inChNum; j++) {
String[] kernelW = inCh[j].split(",");
for (int k = 0; k < kernelWNum; k++) {
String[] kernelH = kernelW[k].split(" ");
for (int l = 0; l < kernelHNum; l++) {
// 값을 Tensor의 1차원 배열에 설정
tensor.set(Float.parseFloat(kernelH[l]), i, j, k, l);
}
}
}
}
return tensor;
}
그리고 기본적인 함수들을 짰다. 행렬 곱, 활성화 함수, 소프트맥스 함수 등등. 뭐 하나를 짤 때마다 그 계산 결과가 파이토치에서의 결과와 일치하는지 계속 확인했다. 시각화 부분을 제외한 연산 부분이 완성되었을 때, 너무 기쁘면서도 신기했다. 와! 정말로 사칙연산이랑 ReLU같은 터무니없이 단순한 함수로 딥 러닝이 작동하는구나!
// ReLU 함수는 놀라울 정도로 단순하다.
void _relu() {
for (int i = 0; i < data.length; i++) {
if (data[i] < 0) {
data[i] = 0;
}
}
여러분은 초등학교 때 사칙연산을 배우면서, 이걸로 숫자가 적힌 이미지를 분류하겠다거나, 이미지를 생성한다거나, 대화를 추론하겠다고 상상해 본 적 있는가? 나는 없다. 어쩌면 내가 수학에 문외한이었기 때문에 더 신기했던 걸 수도 있다. AI로 노벨 물리학상을 공동 수상한 제프리 힌튼은 미분을 여덟 살에 독학했다고 한다. 나는 초등학교 삼 학년 때 구구단을 못 외워서 엄마한테 두들겨 맞은 기억이 있다.
자 이제 연산은 완성했으니, 시각화할 차례다. 각 텐서들을 차원에 맞춰 박스 모양으로 그리고, 각 위치에 해당하는 값으로 박스를 색칠하면 된다. 그리고, 그림을 그릴 수 있는 간단한 인터페이스도 PGraphics를 이용해서 제작했다.
시각화 과정까지 완성된 뒤, 뿌듯했던 나는 이 코드를 깃헙에 올리고 영상을 딥 러닝 커뮤니티 여기저기에 뿌렸다. 당시는 딥 러닝이 조금씩 핫해지던 시절이었는데, 시점이 적절했는지 사람들이 내 시각화를 아주 좋아했다. 무슨 스탠포드의 수학과 교수님이 내 시각화를 공유하고 이해하기 정말 좋다고 칭찬했다. 신나서 자바스크립트 버전도 만들어서 웹에 올렸다. 그렇게 재미있게 잘 즐겼다.
2차 시도
그 후, 한동안 이 코드를 쳐다보기도 싫었다. 그런데 생각할수록 맘에 안 드는 부분이 떠올랐다. 처음에는 MLP로 네트워크를 구성하고 시각화했지만, 문제가 좀 있었다. 일단 실제로 컴퓨터에서 숫자를 그려서 추론을 시켜보니 성능이 좀 떨어졌다. 아무래도 손글씨와 컴퓨터로 그리는 글씨가 차이가 좀 있어서 그런 게 아닌가 싶었다. 학습 과정에서 어그멘테이션도 하지 않았는데 이것도 아쉬웠다.
성능을 향상하기 위해 단순히 MLP만 쓰는 게 아니라, 컨볼루셔널 모듈을 추가하기로 마음먹었다. 또, 학습 과정에서 affine transformation을 포함한 강력한 어그멘테이션도 추가했다. 학습이 끝난 후, 이번에는 학습된 컨볼루션 필터와 바이어스를 로드하는 함수를 제작했다. 컨볼루션 연산도 제작했다. 꽤 헷갈리는 부분이 많았지만 파이토치와 똑같이 작동하는 것을 확인했고, 매우 기뻤다.
이어서 컨볼루션의 시각화를 진행했다. 여기서 좀 문제가 있었다. 컨볼루션을 시각화하려면 어떻게 하지? 필터만 보여 줄 일이 아니었다. 그 과정을 보여 주어야 했다. 그래서 애니메이션을 추가했다. 컨볼루션의 시각화에서 핵심은 필터가 입력 이미지 위를 어떻게 이동하며 각 위치에서 어떤 연산을 수행하는지를 보여주는 것, 그리고 계산된 값이 다음 레이어에 어느 부분으로 가는지를 보여주는 것이었다. 머리가 아팠고, 코드는 매우 지저분했지만, 아무튼 해냈다.
이걸 만들고 나니 reshape나 mlp의 작동 과정도 시각화하고 싶었다. 컨볼루션 레이어를 지나온 데이터를 평평하게 펼쳐 Fully Connected Layer로 전달하는 과정에서 Reshape가 일어난다. 그다음에는 mlp. mlp의 가중치는 일자로 펼쳐진 텐서와 한 줄씩 곱해진다. 한 줄의 가중치와 입력 텐서의 element를 각각 곱한 값이 모두 더해지고 활성화 함수를 통과해 다음 레이어의 한 부분이 된다. 이 모든 과정도 시각화했다.
마지막 시도
그런데, 이렇게 시각화했더니 박스가 너무 많았다. Processing의 문제는 박스 하나를 그리는 데 Draw call 하나를 소모한다는 것이다. 이렇게 Draw call이 많으면 무지하게 느리게 렌더가 진행된다. 따라서, 나는 instancing을 써야만 했다. Instancing은 동일한 객체를 다수 렌더링할 때, 각 객체에 대해 개별적으로 Draw call을 보내지 않고, GPU에서 동일한 메쉬를 반복적으로 렌더링할 수 있도록 하는 기술이다. 이를 활용하면 수천 개의 박스를 효율적으로 그릴 수 있다. Processing의 기본 렌더링 방식은 CPU에서 Draw call을 개별적으로 처리하기 때문에 많은 객체를 다루는 데 한계가 있었지만, Instancing을 사용하면 이 문제를 해결할 수 있다.
Processing 자체는 OpenGL의 저수준 기능에 바로 접근하기 어렵기 때문에, 나는 OpenGL의 Instancing 기능을 직접 사용하기 위해 Processing에서 OpenGL 라이브러리를 활용해야 했다. 이를 위해 프로세싱을 해킹해서 PJOGL 라이브러리와 함께 GLSL을 활용했다.
박스의 위치, 크기, 색상 데이터를 GPU로 전송하기 위해 OpenGL의 VBO를 생성하고, 이를 GPU에 바인딩하는 과정을 하나씩 구현해야 했다. 또, 쉐이더도 다시 짜야 했다. 나는 먼저 Vertex Shader를 작성해 각 박스의 인스턴스 데이터를 받아들였다. 이 쉐이더는 박스의 위치와 크기를 기준으로 화면 상의 적절한 좌표를 계산하는 역할을 했다. 이어서 Fragment Shader를 작성했는데, 이는 박스의 색상을 결정하고 최종적으로 화면에 표시하는 역할을 했다. 이렇게 커스텀 쉐이더를 사용할 경우 더 이상 stroke를 사용할 수 없다. 나는 간단하게 normal을 확장해서 outline을 그리는 쉐이더를 짰고, backface culling을 이용해서 먼저 테두리를 렌더한 뒤, 그 위에 박스를 그리도록 만들었다.
이 지난한 과정이 끝나고, 수천 개의 박스를 그리는 일이 기존처럼 Draw call을 수천 번 보내는 방식이 아니라, 단 한 번의 호출로 모두 처리되는 구조로 바뀌었다. OpenGL의 glDrawArraysInstanced 명령어를 통해 한꺼번에 모든 박스를 렌더링할 수 있었고, 그 결과 Processing이 감당하지 못하던 대규모 데이터를 GPU의 병렬 처리 능력으로 처리할 수 있게 되었다. 올레!
// TensorVisualizer 클래스 내부의 draw함수
void draw() {
//for (Box box : boxes) {
// box.draw();
//}
for (int i = 0; i < boxes.length; i++) {
this.offsets[i * 3 + 0] = boxes[i].curPos.x;
this.offsets[i * 3 + 1] = boxes[i].curPos.y;
this.offsets[i * 3 + 2] = boxes[i].curPos.z;
this.colors[i * 4 + 0] = boxes[i].curVal.x;
this.colors[i * 4 + 1] = boxes[i].curVal.y;
this.colors[i * 4 + 2] = boxes[i].curVal.z;
this.colors[i * 4 + 3] = boxes[i].isVisible ? 1.0f : 0.0f;
this.sizes[i * 3 + 0] = boxes[i].curSize.x;
this.sizes[i * 3 + 1] = boxes[i].curSize.y;
this.sizes[i * 3 + 2] = boxes[i].curSize.z;
}
// buffer를 바인드해서 박스를 불러온다.
bindBuffers();
// instancing을 적용한 draw call
gl.glDrawElementsInstanced(GL.GL_TRIANGLES, boxGL.indices.length, GL.GL_UNSIGNED_INT, 0, offsets.length/3);
// buffer를 해제한다. 안 해도 되긴 하지만. 안전하게!
unbindBuffers();
}
솔직하게 말하면, 여기까지 왔을 때 그냥 python에서 opengl로 했으면 참 편했을 것 같다는 생각이 들었다. 아주아주 만약에 다음에 이 짓을 또 한다면, 파이썬으로 하고 싶다.
프로세싱을 써서 한 가지 좋은 점은 있었다. osc 라이브러리가 아주 쉽게 쓸 수 있게 만들어져 있다는 점이다. 참, 카메라 라이브러리나 소리 재생 라이브러리도 편했다. osc로 그림을 그려서 보낼 수 있는 프로그램을 하나 더 만들었고, 유선 공유기를 통해서 네트워크를 시각화하는 프로그램과 osc 프로그램 사이에서 정보가 왔다 갔다 할 수 있도록 제작했다. 그리고 약간의 SFX를 추가했다.
짜잔! 그렇게 디자인코리아 2024에서 전시가 시작되었다. 처음에는 내가 공부하기 위해서 제작을 시작했다가, 나중에는 AI 하나도 모르면서 결과물만 가지고 AI 아티스트라고 주장하는 사람들 옆에서 이게 진짜 AI인데? ㅋ. 라고 잘난척하고 싶다는 욕심으로 제작을 이어나갔다. 근데 전시를 해 보면서 반응을 살펴보니, 충분히 이해하게 쉽게 만들었다고 생각했는데 대부분의 사람은 컨볼루션이고 나발이고 뭐가 뭔지 전혀 이해하지 못하는 것 같았다. 만약 다음에 또 이걸 만진다면, 진짜 정말 이해하기 쉽게 만들어야지.
결론
아무튼 결론은 네트워크 별 거 있지만, 또 별 거 없다는 것이다. 요즘 나는 AI에 너무 긍정적인 사람, 너무 부정적인 사람 둘 다 자주 본다. 솔직히 말하면, 둘 다 별로다. AI 좋지만, 안 되는 것도 아직 많다. 신체에 문제가 있는 경우를 제외하면, 인간은 보통 한 살 전후에 걸음마를 배운다. 그런데, 인간처럼 자연스럽고 유연하게 걷거나 계단을 오르내리는 동작은 여전히 AI에게 도전 과제이다. 자연스러운 손 동작 또한 마찬가지다. 만들기 정말정말 어렵다.
그렇지만, 굉장한 일을 해 내는 것도 사실이다. AI는 마법이 아니다. 그럼에도 마법같은 일을 한다. AI 연산을 주로 담당하는 GPU는 반도체로 만든다. 반도체는 모래에서 추출한 실리콘으로 만든다. 즉, 여러분이 GPT에 말을 걸면, 그건 곧 모래에게 말을 거는 거나 다름 없다. 놀랍지 않은가?! 어느 판타지 소설에나 나올 법한 설정 아닌가? SF 소설의 대부 아이작 아시모프는 ‘충분히 발달한 마법은 과학기술과 구별할 수 없다’고 이야기했다.
이렇게 의견이 극단으로 갈리는 여러 이유가 있겠지만, 내 개인적인 생각으론 이러한 극단주의가 무지에서 비롯된다고 본다. 이 작업에서 나는 진짜 AI가 어떻게 작동하는지 알리고 싶었다. 그 원리는 너무너무 단순하다. 하지만, 그 단순한 원리가 모여서 정교한 과정을 이루고 이게 무슨 일을 할 수 있게 되는지 역시 보여주고 싶었다. 내가 코드를 공개하는 것 또한 그런 의미이다. 직접 보라는 것이다!
그리고, 꼭 목적이 있어야 하나?이 작업 하는 내내 재미있었고 그걸로도 족하다.
이곳에서 작품의 전체 코드를 확인하실 수 있습니다.
Comments