개요
김성현 (@okdalto)의 ↪ CNN visualization에 영감을 받아, 2025년 2월 1일 부터 주말마다 작업하기 시작한 프로젝트. 최종적인 목표는 뉴럴네트워크 학습 과정에서 weight 와 bias 값이 업데이트 되는 것을 시각화 하는 것이다. 중간 목표는 pytorch 와 최대한 동일한 방식으로 작동하도록 패키징해서 사용을 편리하게 하는 것이다. 따라서 pytorch에서 제공하는 auto_grad 방식이 가능하게 만들고자 한다! 사실 쉽게 구현하려면 전체 모델에 대해 순전파(forward)와 역전파(backward)를 만들어두는 것이지만, 이는 모델마다 매번 만들어야 하고 또 직관적이지 않아서 만들기도 어렵다는 단점이 있다. 따라서 나는 각 weight 마다 자신의 gradient를 추적하도록 하는 방식으로 구현을 진행하고자 한다. weight 하나하나 마다 추적하게 하면, 오히려 계산이 \(wx+b\) 꼴로 단순해져서 gradient를 계산이 쉬워지지 않을까싶기도? 약간 걱정인 것은 이전 레이어로 gradient를 넘겨주는 부분하고 normalization과 activation에 대한 graident 계산인데, 전자의 경우는 생각을 좀 해봐야겠지만 후자의 경우 다행히도 Arxiv에 관련 논문들이 공개되어있으니, 차분히 읽으며 구현하면 어떻게든 만들 수 있지 않을까 싶다.
Unity는 요새 개인 개발자들도 별로 안 쓰는 거 같긴 한데 (주식만 하락장이 아니다...), 사실 unreal engine 보다는 훨씬 익숙해서 이걸로 선택했다. 이번에 들어가보니 버전 6가 나와서 해당버전으로 작업을 시작했다. Unity 에서 가장 핵심이 되는 것은 Monobehaviour 와 lifecycle인데, 일단 구현에 있어서 이것들은 신경쓰지 않겠다. 물론 각 레이어별로 넘어가는 애니메이션을 위해선 고려하지 않을 수 없는 굉장히 중요한 부분이지만, 뉴럴네트워크 구조 설계와 gradient 추정만 해도 머리 터질것 같아서 차치하기로 한다. 추가로 구현을 위해 ↪ 위키피디아를 참조했다. 설명이 자세해서 정말 좋다..!
뉴런 만들기
일단은 아주 간단하게, 어떤 RGB 값을 다른 RGB로 매핑하는 뉴럴 네트워크를 만들어보고자 한다. 즉, 3차원짜리 배열을 입력으로 받아서 3차원짜리 배열을 출력하는 형태가 될 것이다. 이번엔 먼저 레이어가 하나인 Single-Layer Perceptron을 만들어보고, 이후 레이어를 적층해서 Multi-Layer Perceptron을 만들어보고자 한다. 내가 생각하는 설계는 대충 아래와 같다.
NeuralNetwork
├── Perceptron
│ ├─── weight
│ ├─── weight
│ ├─── weight
│ └─── bias
├── Perceptron
│ ├─── weight
│ ├─── weight
│ ├─── weight
│ └─── bias
├── Perceptron
│ ├─── weight
│ ├─── weight
│ ├─── weight
│ └─── bias
├── summation
├── activation
└── loss
입력 차원이 3이기 때문에 각 Perceptron 마다 3개의 weight 가 있고, 출력이 3이기 때문에 Perceptron이 3개 있는 형태이다. 각 Perceptron 마다 bias도 하나 있고, 덧셈에 대한 연산을 할 summation 과 함수의 비선형성을 제공할 활성화 함수 activation도 하나가 있다. 그리고 마지막으로 가장 중요한 손실함수 (loss) 가 있다.
위 설계를 바탕으로 구현을 시작해보자! 우선은 Perceptron을 하나 만들어 보자. 하나의 Perceptron은 입력{X = \(x_{1},x_{2},x_{3}\)}에 대해 \(w_{1}x_{1}+w_{2}x_{2}+w_{3}x_{3}+b\) 형태의 연산을 진행하게 될 것이다. 여기서 좀 더 단순하게 \(b\)가 0인 경우를 가정하고, \(w_{1}, w_{2}, w_{3}\) 들에 대해서 만들어보고자 한다. 일단은 Unity에서 Script를 하나 만들어보자. 이름은 대충 내 이니셜을 붙여서 CSH_weight 로 붙였다.
using UnityEngine;
public class CSH_weight: MonoBehaviour
{
public void Start()
{
}
public void Update()
{
}
}
유니티에서 처음 Script를 만들면 위와 같은 형태로 만들어진다. Start()와 Update()는 MonoBehaviour 함수가 Unity의 lifecycle에에 따라서 실행되는 함수들이다. 하.지.만! 일단 지금은 Unity의 lifecycle을 고려하지 않기로 했으니 얘네는 지워버리자 (물논 나중에 살릴것임). 우선은 weight 파라매터를 만들어주도록 하자. bias도 만들어 줄 수 있지만, 일단은 \(b = 0\) 꼴의 뉴런을 가정하기로 했으니 패쓰하겠다. 그리고 weight에 임의의 값을 지정해주자. 이를 위해 Random.Range()를 사용할 건데, 이 함수는 0 과 1 사잇값을 Uniform한 확률로 뽑는 함수다. 사실 Normal distribution에서 뽑을 수 있으면 더 좋겠지만 딱히 큰 문제는 없을 테니 일단은 넘어가자. 그리구 weight의 gradient도 담을 파라매터도 하나 만들어두자. 이렇게 하면 함수는 다음과 같은 형태가 된다.
using UnityEngine;
public class CSH_weight: MonoBehaviour
{
float weight = 0.0f;
float weight_grad = 0.0f;
public void initialize()
{
weight = Random.Range(0.0f, 1.0f);
}
}
아직은 뭔가 상당히 허전하다...! initialize()를 호출하면 weight에 랜덤한 수를 배정하도록 했다. public 함수로 만든 이유는 나중에 외부에서 다수의 weight를 컨트롤하기 위함이다. 하지만 잊지 말자! 우리의 목표는 네트워크의 시각화다! 따라서 우리는 weight 값을 가시화해야한다. 이를 위한 다양한 방법이 있겠지만, 내가 생각한 방법은 Renderer 컴포넨트에 있는 Material의 color 값을 현재 함수의 weight 값으로 치환하는 것이다. 이를 위해선 우선 우리가 만든 CSH_weight 함수를 갖고 있는 GameObject 가 필요하다. Hierarchy 에서 마우스 우클릭한 다음 Create Empty를 눌러서 우선 빈 게임오브젝트를 하나 만들자. 그리고 만들어진 게임오브젝트에 우리가 방금 만든 CSH_weight 를 붙여주자. 그리고 다시 마우스 우클릭 후, 3D Object 에서 Cube를 눌러 방금 만든 게임 오브젝트의 Child로 만들어주자. 그러면 Hierarchy가 대충 이렇게 된다.
GameObject (CSH_weight를 붙인 게임임오브젝트)
└── Cube (Mesh 랑 Renderer 컴포넌트가 붙은 게임오브젝트)
사실 Cube 꼭 Child로 할 필요 없이 Cube 자체에 방금 만든 CSH_weight를 붙여도 되긴 하지만, 갠적으론 이렇게 구성하는 편이라 걍 이렇게 했다. 딱히 특별한 이유는 없다! 이제 Child로 붙인 Cube 의 컴포넌트, Renderer, 를 활용해서 weight 값을 시각화 할 수 있다. 다시 코드로 돌아와서 Renderer의 Material를 받을 파라매터를 만들어주자. 그리고 weight 값을 Material의 color 값으로 넘겨주는 SetRGB()함수를 만들자. 해당 함수는 대충 아래와 같다.
private void SetRGB(float value, Material material)
{
Color newColor = new Color(value, value, value);
material.color = newColor;
}
public void initialize()
{
weight = Random.Range(0.0f, 1.0f);
myMaterial = gameObject.GetComponentInChildren().material;
SetRGB(weight, myMaterial);
}
</code>
</pre>
initialize함수도 살짝 수정해서 weight 값을 지정함과 동시에 시각화할 수 있게 만들었다.
이제 역전파를 위한 weight의 graident를 담을 파라매터를 만들어 주자. weight의 gradient는 weight랑 차원의 크기가 같으니까 그냥 float 파라매터를 하나 만들면 된다. 하는 김에 순전파를 위한 forward_pass() 함수도 만들어주자. 우리가 지금 만드는 거는 weight 하나에 대한 거기 때문에 \(y=wx\)를 생각해서 만들어주면 된다. 다른 weight들과 더해주는 거는 일단 나중에 생각하자.
다음으로 역전파 함수 backward_pass() 도 만들어주자. 역전파 함수를 만드려면 weight 에 대한 gradient를 구해야 한다. 이는 다시 말해 \(f(x)=wx\) 에 대해서 \(w\) 에 대한 변화율을 구하는 것이다. 이를 편미분하면 \(\frac{\partial f}{\partial w}=x\)로 \(w\)의 gradient는 \(x\)라는 것을 알 수 있다! 따라서 순전파때 입력으로 들어온 값을 들고 있다가 역전파 과정에서 chain rule을 통해 들어온 gradient 값에 곱해주기만 하면되는 것이다. 쏘 심플!
이를 바탕으로 순전파와 역전파 함수를 다음과 같이 구현할 수 있다.
public float forward_pass(float input)
{
float output = input * weight;
weight_grad = input;
return output;
}
public float backward_pass(float input)
{
weight_grad = weight_grad * input;
return weight * input;
}
이제 계산한 gradient를 바탕으로 weight값을 업데이트 하는 step() 함수와 gradient를 초기화 하는 zero_grad() 함수를 만들면 끝이다.
사실 pytorch구조에선 weight에 step 과 zero_grad 가 있진 않지만, 일단 구현이 적절히 되었는지 확인을 위해 디버깅용으로 만들어두었다. 나중에 정리가 필요할 것 같긴 하다...
최종적으로 구현한 weight 는 아래와 같다.
using UnityEngine;
public class CSH_weight: MonoBehaviour
{
public float weight = 0.0f;
public float weight_grad = 0.0f;
private Material myMaterial;
private float stepsize = 0.01f;
public void set_stepsize(float input)
{
stepsize = input;
}
private void SetRGB(float value, Material material)
{
Color newColor = new Color(value, value, value);
material.color = newColor;
}
public void initialize()
{
weight = Random.Range(0.0f, 1.0f);
myMaterial = gameObject.GetComponentInChildren().material;
SetRGB(weight, myMaterial);
}
public float forward_pass(float input)
{
float output = input * weight;
weight_grad = input;
return output;
}
public float backward_pass(float input)
{
weight_grad = weight_grad * input;
return weight * input;
}
public void zero_grad()
{
weight_grad = 0.0f;
}
public void step()
{
weight = weight - (stepsize * weight_grad);
SetRGB(weight, myMaterial);
}
}
</code>
</pre>
이제 다음으론 activation을 만들어보자.