Now Loading ...
-
[Network] 1. 네트워크 기초: Packet Switching, Circuit Switching
참고한 자료
Computer Networking: a Top Down Approach (Pearson)(9th Edition)
1. 기본 용어
Host (End System): 컴퓨터, 스마트폰, 스마트워치, 게임 콘솔, 서버처럼 인터넷에 연결되어 애플리케이션을 실행하는 장치
Packet: End system이 데이터를 잘게 나눈 조각에 헤더를 붙인 전송 단위
ISP (Internet Service Provider): End system에 인터넷 접속을 제공하는 주체. 통신사, 케이블 인터넷 사업자, 셀룰러 데이터 사업자뿐 아니라 대학이나 공항처럼 자체 네트워크로 접속을 제공하는 기관도 넓은 의미에서 ISP에 포함됨
Protocol: 통신하는 개체들 사이에서 주고받는 메시지의 형식과 순서, 메시지를 보내거나 받았을 때 취할 동작을 정의한 규칙
RFC (Request for Comments): IETF에서 인터넷 기술, 프로토콜(HTTP, TCP/IP 등), 연구 결과 및 규격을 정리해 발행한 기술 문서
Access Network: End system을 경로상의 첫 번째 라우터(Edge Router)에 물리적으로 연결하는 네트워크. 예: 가정용 광랜, 기업·학교 LAN, 셀룰러 네트워크
2. 네트워크의 기본적인 동작
2.1. Packet Switching
End system은 네트워크에서 다양한 메시지를 주고받는다. 메시지에는 이메일, 사진, 오디오 파일 같은 데이터뿐 아니라 Handshaking처럼 통신을 제어하기 위한 내용도 포함된다.
Source는 긴 메시지를 작은 데이터 조각으로 나누고, 각 조각에 헤더를 붙여 패킷을 만든다. 패킷은 communication link와 packet switch를 차례로 거쳐 destination에 도착한다. Packet switch의 대표적인 예로는 라우터가 있다.
패킷은 link의 전송률(transmission rate)로 전송된다. 길이가 $L$ bits인 패킷을 전송률이 $R$ bits/sec인 link로 내보내는 데 걸리는 시간은 다음과 같다.
\[d_{\text{trans}} = \frac{L}{R}\]
Store-and-Forward Transmission
Store-and-forward는 packet switch가 패킷의 모든 비트를 받아 저장한 뒤에야 다음 link로 전송을 시작하는 방식이다. 대부분의 packet switch가 이 방식을 사용한다.
따라서 패킷이 packet switch 하나를 거치면 전송 지연이 두 번 발생한다.
Source가 패킷 전체를 packet switch로 보내는 데 $L/R$초가 걸린다.
Packet switch가 패킷 전체를 받은 뒤 다음 link로 내보내는 데 다시 $L/R$초가 걸린다.
즉, 두 link를 거치는 데 총 $2L/R$초가 걸린다. 이를 일반화하면 source와 destination 사이에 $N$개의 link가 있을 때 end-to-end delay는 다음과 같다.
\[d_{\text{end-to-end}} = N\frac{L}{R}\]
이 식은 패킷 하나만 전송하고, 모든 link의 전송률이 $R$로 같으며, 전파, 처리 등의 다른 지연을 무시한다고 가정한 식이다.
하지만 실제 인터넷에서는 당연히 패킷이 1개씩만 보내지지 않고, 수많은 패킷이 오고간다.
Queuing Delays, Packet Loss
Packet switch에는 연결된 link마다 output buffer(output queue)가 있다. Output buffer는 해당 link로 내보낼 패킷을 임시로 저장하는 공간이다.
새로 도착한 패킷이 나가야 할 link가 다른 패킷을 전송 중이면, 그 패킷은 output buffer에서 차례를 기다린다. 이로 인해 패킷은 store-and-forward로 인한 전송 지연에 더해 queuing delay를 겪는다. Queuing delay는 buffer에 쌓인 패킷의 양에 따라 달라지므로, 네트워크 혼잡도에 따라 패킷마다 다르게 나타난다.
이러한 현상은 특정 link로 향하는 패킷의 도착 속도가 일시적으로 그 link의 전송률 $R$보다 빠를 때 두드러진다. 패킷이 나가는 속도보다 들어오는 속도가 빠르므로 buffer에 패킷이 쌓이기 때문이다.
Buffer 공간은 유한하므로, 패킷이 도착했을 때 output buffer가 가득 차 있으면 패킷이 버려진다. 이를 packet loss라고 하며, 새로 도착한 패킷을 버릴 수도 있고, 이미 대기 중인 패킷을 버릴 수도 있다.
Forwarding Tables
패킷의 헤더에는 destination IP 주소가 포함되어 있다. 라우터는 패킷을 받으면 이 주소를 확인하고, forwarding table을 참조하여 패킷을 내보낼 outgoing link를 결정한다. Forwarding table은 destination IP 주소 또는 그 일부(prefix)를 outgoing link에 대응시킨 표이다.
Forwarding table의 내용은 routing protocol을 통해 자동으로 설정된다. Routing protocol은 라우터들이 경로 정보를 얻는 데 사용되며, 최단 경로처럼 프로토콜의 기준에 맞게 경로를 계산한다.
즉, forwarding은 라우터 내부에서 패킷을 알맞은 link로 보내는 동작이고, routing은 패킷이 목적지까지 이동할 경로를 결정하는 과정인 것이다.
2.2. Circuit Switching
Circuit switching은 packet switching과 달리 통신을 시작하기 전에 source와 destination 사이의 경로에 필요한 자원(buffer, transmission rate)을 미리 예약하는 방식이다. 전통적인 전화망이 대표적인 예이다.
Source가 데이터를 보내기 전에 네트워크는 source와 destination 사이에 연결을 확립한다. 이렇게 확립된 연결을 circuit이라고도 한다. Circuit이 설정되면 경로상의 각 link에서 일정한 전송률이 해당 연결에 할당된다. 할당된 자원은 연결이 유지되는 동안 다른 연결이 사용할 수 없으므로, 이 전송률을 보장할 수 있다.
예를 들어 전송률이 $R$인 link를 $n$개의 circuit이 똑같이 나누어 쓰면, 각 circuit에는 다음과 같은 전송률이 할당된다.
\[R_{\text{circuit}} = \frac{R}{n}\]
반면 데이터를 보내지 않는 동안에도 예약된 자원을 다른 연결이 사용할 수 없으므로, 그만큼 link 용량이 낭비될 수 있다.
FDM, TDM
Circuit switching에서 하나의 link는 multiplexing을 통해 여러 circuit이 함께 사용한다. 대표적인 방식으로 FDM과 TDM이 있다.
FDM (Frequency-Division Multiplexing): Link의 주파수 대역을 나누어 각 circuit에 서로 다른 주파수 대역을 할당하는 방식이다. 각 circuit은 연결이 유지되는 동안 할당받은 대역을 계속 사용한다. 이때 할당된 대역의 폭을 bandwidth라고 한다.
TDM (Time-Division Multiplexing): 시간을 frame으로 나누고, 각 frame을 다시 일정한 수의 time slot으로 나누는 방식이다. 각 circuit은 frame마다 정해진 slot을 할당받아, 그 시간에만 link의 전체 전송률로 데이터를 보낸다.
즉, FDM은 주파수를 나누어 쓰고, TDM은 시간을 나누어 쓴다.
TDM에서 link 전송률이 $R$이고 한 frame에 slot이 $n$개 있을 때, slot을 하나 할당받은 circuit의 평균 전송률은 다음과 같다.
\[R_{\text{circuit}} = \frac{R}{n}\]
2.3. Packet Switching vs Circuit Switching
구분
Circuit switching
Packet switching
자원 할당
수요와 무관하게 사전 할당
수요에 따라 사용
유휴 자원
다른 사용자가 사용 불가
다른 사용자가 사용 가능
전송률과 지연
전송률 보장, 큐잉 지연 없음
혼잡에 따라 큐잉 지연과 패킷 손실 발생 가능
강점
전송률 보장, 일정한 지연
link 공유 효율이 높고 구현이 비교적 단순함
사용자 수용력
전송률이 1 Mbps인 link를, 활동 상태일 때 100 kbps를 사용하고 전체 시간의 10%만 활동하는 사용자들이 공유한다고 하자.
Circuit switching: 사용자마다 100 kbps를 항상 예약해야 하므로 최대 10명만 수용할 수 있다.
Packet switching: 사용자가 35명이어도 동시에 11명 이상 활성 상태일 확률은 약 0.0004에 불과하다. 따라서 대부분의 시간 동안 도착 속도가 link 전송률을 넘지 않으며, 3배 이상의 사용자를 수용하면서도 circuit switching과 비슷한 성능을 낼 수 있다.
혼자 전송할 때 link 전체를 사용할 수 있다
$n$명이 전송률 $R$인 link를 공유하는데, 한 명만 $D$ bits를 전송한다고 하자.
TDM circuit switching: 다른 사용자의 slot이 비어 있어도 사용할 수 없으므로 $\frac{nD}{R}$초가 걸린다.
Packet switching: 다른 패킷이 없으므로 link 전체를 사용할 수 있어 $\frac{D}{R}$초가 걸린다.
따라서 이 경우 packet switching이 $n$배 빠르다.
Packet switching은 지연을 예측하기 어렵다는 단점이 있지만, 데이터가 간헐적으로 발생하는 환경에서 link를 효율적으로 공유한다. 현재 통신망에는 두 방식이 모두 쓰이지만, 전체적인 흐름은 packet switching 쪽으로 이동하고 있다.
-
[RL] 2. DP: Policy Iteration, Value Iteration
참고한 자료
파이썬과 케라스로 배우는 강화학습 (개정판)
Github
이전 글에서 순차적 행동 결정 문제를 MDP로 정의하고, 벨만 기대 방정식과 최적 방정식을 얻었다. 이제 이 방정식을 풀 차례이다.
벨만 방정식을 푼다는 것은 방정식을 만족하는 가치함수를 찾는 것이다. 벨만 기대 방정식을 풀면 주어진 정책 $\pi$의 가치함수 $v_\pi$를 얻고, 벨만 최적 방정식을 풀면 최적 가치함수 \(v_*\)와 최적 큐함수 \(q_*\)를 얻는다.
에이전트는 정책에 따라 각 상태에서 어떤 행동을 할지 결정한다. 따라서 최종 목표는 가치함수 자체를 구하는 것이 아니라, 기대 반환값을 최대로 만드는 최적 정책 $\pi_*$를 찾는 것이다. 최적 큐함수를 알고 있다면, 각 상태에서 큐함수가 가장 큰 행동을 고르기만 하면 최적 정책을 얻을 수 있다.
\[\pi_*(s) = \arg\max_{a \in A} q_*(s, a)\]
1. Dynamic Programming
다이내믹 프로그래밍(DP)은 큰 문제를 한 번에 풀지 않는다. 대신 문제를 작은 문제로 나누어 풀고, 그 해를 이용해 큰 문제의 해에 도달한다. MDP에서는 벨만 방정식을 통해 이러한 분할이 가능하다. 현재 상태의 가치가 다음 상태의 가치로 재귀적으로 표현되므로, 다음 상태들의 가치를 이용해 현재 상태의 가치를 계산할 수 있다.
따라서 DP에서는 가치함수를 한 번에 정확히 구하지 않는다. 임의의 초기값 $v_0$에서 시작하여, 벨만 방정식을 이용해 가치함수를 반복적으로 업데이트한다.
\[v_0 \rightarrow v_1 \rightarrow v_2 \rightarrow \cdots \rightarrow v_k \rightarrow \cdots \rightarrow v_\pi\]
DP로 벨만 방정식을 푸는 방법은 두 가지이다. 벨만 기대 방정식을 이용하는 정책 이터레이션(Policy Iteration)과, 벨만 최적 방정식을 이용하는 가치 이터레이션(Value Iteration)이다.
DP를 적용할때 유의할 점은, 환경의 모델, 즉 상태 변환 확률 $P_{ss’}^a$과 보상함수 $R_s^a$를 알고 있어야 한다는 것이다. 벨만 방정식의 기댓값을 계산하려면 다음 상태가 어떤 확률로 나타나는지 알아야 하기 때문이다. 따라서 DP는 환경과 상호작용하며 배우는 학습(learning)이라기보다, 알려진 모델을 바탕으로 계산하는 계획(planning)에 가깝다.
책에서는 그리드월드 예제를 통해 내용을 설명한다. 그리드월드에서 에이전트는 격자 위를 상하좌우로 이동하며 장애물을 피해 목표 지점에 도달해야 한다.
2. Policy Iteration (정책 이터레이션)
정책 이터레이션은 임의의 정책에서 시작한다. 책에서는 모든 행동을 같은 확률로 선택하는 무작위 정책을 초기 정책으로 사용한다. 무작위 정책은 당연히 최적 정책이 아니므로, 현재 정책이 얼마나 좋은지 평가하고, 그 평가를 바탕으로 더 나은 정책으로 발전시켜야 한다. 이 두 과정을 반복하면 정책은 점점 개선되어 최적 정책에 수렴한다.
\[\pi_0 \xrightarrow{\text{평가}} v_{\pi_0} \xrightarrow{\text{발전}} \pi_1 \xrightarrow{\text{평가}} v_{\pi_1} \xrightarrow{\text{발전}} \cdots \rightarrow \pi_*\]
정책 평가 (Policy Evaluation)
정책이 얼마나 좋은지를 판단하는 기준으로 가치함수를 사용할 수 있다.
\[v_\pi(s) = \mathbb{E}_\pi\left[R_{t+1} + \gamma R_{t+2} + \gamma^2 R_{t+3} + \cdots \mid S_t = s\right]\]
가치함수는 상태 $s$에서 정책 $\pi$를 따라 행동했을 때 앞으로 받을 보상의 할인된 합(반환값)의 기댓값이다. 같은 상태에서 출발하더라도 어떤 정책을 따르느냐에 따라 받게 될 보상이 달라지므로, 가치함수는 그 자체로 정책의 좋고 나쁨을 판단하는 기준이 된다.
정책 이터레이션에서는 이 가치함수를 DP로 계산하기 위해 벨만 기대 방정식을 사용한다.
\[v_\pi(s) = \mathbb{E}_\pi\left[R_{t+1} + \gamma v_\pi(S_{t+1}) \mid S_t = s\right]\]
이 식의 핵심은 미래의 모든 보상을 직접 더하지 않는다는 것이다. 대신 현재 상태에서 행동했을 때 받는 보상과 그 행동으로 도달할 수 있는 다음 상태들의 가치함수만으로 현재 상태의 가치함수를 표현한다. 기댓값 $\mathbb{E}_\pi$를 풀어 쓰면, 다음과 같이 계산 가능한 형태가 된다.
\[v_\pi(s) = \sum_{a \in A} \pi(a \mid s) \left( R_s^a + \gamma \sum_{s' \in S} P_{ss'}^a \, v_\pi(s') \right)\]
각 행동을 선택할 확률 $\pi(a \mid s)$로, 그 행동을 했을 때의 보상과 다음 상태 가치의 합을 가중평균한 형태이다.
그런데 우리는 아직 참 가치함수 $v_\pi$를 모른다. 따라서 위 식의 우변에 참값 대신 현재까지 계산한 $k$번째 가치함수 $v_k$를 넣고, 그 결과를 $k+1$번째 가치함수 $v_{k+1}$로 삼는다.
\[v_{k+1}(s) = \sum_{a \in A} \pi(a \mid s) \left( R_s^a + \gamma \sum_{s' \in S} P_{ss'}^a \, v_k(s') \right)\]
$v_k$는 참값이 아니므로 한 번 계산한 $v_{k+1}$ 역시 참값이 아니다. 하지만 이 업데이트를 모든 상태에 대해 반복하면, 실제 보상 $R_s^a$에 대한 정보가 매 반복마다 주변 상태로 전파되면서 $v_k$는 $v_\pi$에 수렴한다.
위 수식은 모든 상태의 $v_{k+1}$을 이전 반복의 값 $v_k$만으로 계산하는 동기식 업데이트를 나타낸다. 이를 위해 $v_k$와 $v_{k+1}$을 저장할 테이블을 따로 두며, 책의 코드도 이 방식을 따른다. 반면 테이블을 하나만 두고 계산 즉시 값을 덮어쓰는 제자리 업데이트(in-place update)에서는 이웃 상태 중 일부는 이미 갱신된 $v_{k+1}$, 일부는 $v_k$를 참조하게 된다. 이 경우에도 모든 상태가 반복적으로 갱신되기만 하면 $v_\pi$로 수렴하며, 최신 값을 바로 활용하므로 보통 더 빠르게 수렴한다.
그리드월드는 어떤 상태에서 행동 $a$를 하면 항상 정해진 다음 상태 $s’$로 이동한다고 가정한다. 즉 상태 변환 확률이 해당 $s’$에 대해서만 1이고 나머지는 0이므로, $s’$에 대한 합이 사라져 식이 다음과 같이 단순해진다.
\[v_{k+1}(s) = \sum_{a \in A} \pi(a \mid s) \left( R_s^a + \gamma \, v_k(s') \right)\]
이것이 한 번의 정책평가 과정이다. 한 번의 정책평가로는 제대로 정책이 평가될 수 없으므로 여러 번 반복한다.
정책 발전 (Policy Improvement)
정책 평가로 현재 정책의 가치함수를 구했다면, 이제 이를 바탕으로 정책을 발전시킬 차례이다. 정책을 발전시키는 방법은 여러 가지가 있지만, 책에서는 기본적인 방법인 탐욕 정책 발전(greedy policy improvement)을 사용한다.
정책은 각 상태에서 어떤 행동을 할지 정하는 것이다. 그런데 가치함수 $v_\pi(s)$는 상태 하나의 가치만 알려 줄 뿐, 그 상태에서 어떤 행동이 더 좋은지는 직접 알려 주지 않는다. 행동을 비교하려면 상태와 행동의 쌍에 대한 가치, 즉 큐함수가 필요하다.
\[q_\pi(s, a) = \mathbb{E}_\pi\left[R_{t+1} + \gamma\, v_\pi(S_{t+1}) \mid S_t = s, A_t = a\right]\]
큐함수는 상태 $s$에서 행동 $a$를 한 뒤, 그 이후로는 정책 $\pi$를 따랐을 때의 기대 반환값이다. 기댓값을 풀어 쓰면 다음과 같이 계산 가능한 형태가 된다.
\[q_\pi(s, a) = R_s^a + \gamma \sum_{s' \in S} P_{ss'}^a \, v_\pi(s')\]
이 식에서 알 수 있듯이, 큐함수는 정책 평가에서 구한 가치함수만으로 계산할 수 있다. 따라서 큐함수를 별도로 저장할 필요가 없다. 그리드월드처럼 행동에 따라 다음 상태가 하나로 정해지는 환경에서는 식이 더 단순해진다.
\[q_\pi(s, a) = R_s^a + \gamma\, v_\pi(s')\]
탐욕 정책 발전은 상태 $s$에서 선택 가능한 모든 행동의 큐함수를 비교하고, 그중 큐함수가 가장 큰 행동을 선택한다. 이렇게 발전된 정책 $\pi’$는 다음과 같다.
\[\pi'(s) = \arg\max_{a \in A} q_\pi(s, a)\]
큐함수가 최대인 행동이 여러 개라면, 그 행동들에 확률을 균등하게 나누어 준다. 예를 들어 두 행동의 큐함수가 같은 최댓값을 가지면 각각 $0.5$의 확률로, 세 행동이면 각각 $\frac{1}{3}$의 확률로 정책을 수정한다.
탐욕 정책 발전으로 얻은 정책은 이전 정책보다 나빠지지 않는다. 즉 모든 상태 $s$에 대해 다음이 성립한다.
\[v_{\pi'}(s) \geq v_\pi(s)\]
정책 평가와 정책 발전을 반복하다가 정책이 더 이상 바뀌지 않는 순간이 온다. 이때는 각 상태에서 고르는 행동이 이미 큐함수를 최대로 만드는 행동이라는 뜻이다. 즉 이 시점의 가치함수는 벨만 최적 방정식을 만족하고, 그때의 정책이 최적 정책 $\pi_*$이다. 또한 유한 MDP에서는 정책의 개수가 유한하므로, 이 과정은 유한 번의 반복 안에 끝난다.
3. Value Iteration (가치 이터레이션)
정책 이터레이션에서는 정책과 가치함수가 명확히 분리되어 있었다. 정책 $\pi$는 별도로 저장되어 있고, 가치함수는 그 정책이 얼마나 좋은지를 평가하는 도구로만 쓰였다. 정책 평가를 아무리 반복해도 정책은 바뀌지 않으며, 정책은 정책 발전 단계에서만 바뀐다.
가치 이터레이션은 정책을 따로 두지 않는다. 대신 현재의 가치함수가 최적 가치함수라고 가정한다. 이 가정 하에, 최적 정책은 각 상태에서 큐함수가 가장 큰 행동을 고르는 것이므로 가치함수로부터 바로 얻을 수 있다.
\[\pi(s) = \arg\max_{a \in A} \left( R_s^a + \gamma\, v_k(s') \right)\]
이 정책은 가장 좋은 행동 하나만을 선택하는 결정적(deterministic) 정책이다. 무작위 정책처럼 확률적으로 행동을 고르는 정책 이터레이션의 초기 정책과 대비된다. 이 결정적 정책을 벨만 기대 방정식에 대입하면, 행동에 대한 가중평균이 최댓값 하나로 바뀌어 결국 벨만 최적 방정식을 이용한 업데이트가 된다.
물론 처음의 가치함수는 최적이 아니므로 이 가정은 사실이 아니다. 하지만 업데이트를 반복하면 가치함수는 최적 가치함수에 수렴하고 최적 정책을 구할 수 있다. 따라서 가치 이터레이션은 정책을 따로 발전시키는 단계 없이 가치함수만 반복해서 업데이트하고, 정책은 수렴한 가치함수로부터 마지막에 한 번 꺼내 쓰면 된다. 가치함수 안에 정책이 내재적으로 포함되어 있다는 것이다.
벨만 최적 방정식은 다음과 같다.
\[v_*(s) = \max_{a \in A} \mathbb{E}\left[R_{t+1} + \gamma\, v_*(S_{t+1}) \mid S_t = s, A_t = a\right]\]
벨만 기대 방정식과 비교하면, 정책 $\pi$에 대한 기댓값 $\mathbb{E}_\pi$ 대신 행동에 대한 최댓값 $\max_a$가 들어가 있다. 기댓값을 풀어 쓰면 다음과 같이 계산 가능한 형태가 된다.
\[v_*(s) = \max_{a \in A} \left( R_s^a + \gamma \sum_{s' \in S} P_{ss'}^a \, v_*(s') \right)\]
정책 평가에서와 마찬가지로, 우리는 아직 참값 $v_*$를 모른다. 따라서 우변에 현재까지 계산한 $v_k$를 넣고, 그 결과를 $v_{k+1}$로 삼아 반복한다.
\[v_{k+1}(s) = \max_{a \in A} \left( R_s^a + \gamma \sum_{s' \in S} P_{ss'}^a \, v_k(s') \right)\]
그리드월드에서는 행동에 따라 다음 상태가 하나로 정해지므로, $s’$에 대한 합이 사라져 다음과 같이 단순해진다.
\[v_{k+1}(s) = \max_{a \in A} \left( R_s^a + \gamma\, v_k(s') \right)\]
정책 평가의 업데이트 식과 나란히 놓으면 차이가 분명해진다.
\[\begin{aligned}
\text{정책 이터레이션 (평가)} &: \quad v_{k+1}(s) = \sum_{a \in A} \pi(a \mid s) \left( R_s^a + \gamma\, v_k(s') \right) \\
\text{가치 이터레이션} &: \quad v_{k+1}(s) = \max_{a \in A} \left( R_s^a + \gamma\, v_k(s') \right)
\end{aligned}\]
정책 평가는 정책이 정한 확률로 각 행동의 값을 가중평균하는 반면, 가치 이터레이션은 그중 최댓값만 취한다. 업데이트 식 자체에 “가장 좋은 행동을 고른다”는 정책 발전이 이미 포함되어 있는 셈이다.
이 업데이트를 모든 상태에 대해 반복하면 $v_k$는 $v_*$에 수렴한다. 이렇게 얻은 가치함수로부터, 앞서 본 $\arg\max$ 식으로 각 상태의 최적 행동을 꺼내면 최적 정책이 된다.
\[\pi_*(s) = \arg\max_{a \in A} \left( R_s^a + \gamma\, v_*(s') \right)\]
정책 이터레이션
가치 이터레이션
사용하는 방정식
벨만 기대 방정식
벨만 최적 방정식
정책 저장
명시적으로 저장
저장하지 않음 (가치함수에 내재)
반복 단위
정책 평가 + 정책 발전
가치함수 업데이트
업데이트 연산
정책에 따른 가중평균
최댓값
정책을 얻는 시점
매 정책 발전마다
가치함수 수렴 후 $\arg\max$로 추출
-
FNet: Mixing Tokens with Fourier Transforms
논문 정보
제목: FNet: Mixing Tokens with Fourier Transforms
arXiv: 2105.03824
Introduction
트랜스포머 아키텍처의 핵심은 attention 메커니즘이다. attention은 하나의 토큰을 표현할 때 입력 시퀀스의 다른 모든 토큰과의 연관성을 계산하고, 이를 가중치로 정보를 종합한다. 이는 “모든 토큰이 서로를 참조할 수 있어야 한다”는 귀납적 편향(inductive bias)이 포함된 것이다. 따라서 레이어를 거칠수록 입력 토큰들의 조합으로부터 점점 더 상위 수준의 표현이 형성되며, 이러한 구조 덕분에 트랜스포머가 다양한 문법적(syntactic), 의미적(semantic) 관계를 포착할 수 있었다.
이 논문에서는 트랜스포머 인코더의 비교적 복잡한 self-attention 레이어를, 토큰을 섞는(token mixing) 더 단순한 메커니즘으로 완전히 대체할 수 있는지를 검증한다.
먼저 attention 서브레이어를 학습 가능한 두 개의 행렬곱으로 교체하였다. 하나는 시퀀스 차원을, 다른 하나는 은닉(hidden) 차원을 섞는다. 입력 $X \in \mathbb{R}^{N \times d}$에 대해 이는 다음과 같이 표현된다.
\[Y = W_{\text{seq}} \, X \, W_{\text{h}}, \qquad W_{\text{seq}} \in \mathbb{R}^{N \times N}, \; W_{\text{h}} \in \mathbb{R}^{d \times d}\]
이러한 단순한 선형 혼합만으로도 유의미한 성능이 나오자, 저자들은 더 빠르게 계산할 수 있는 구조화된(structured) 선형 변환으로 범위를 넓혀 실험하였다.
그 결과, 학습 파라미터가 전혀 없는 푸리에 변환(Fourier Transform)이 학습 가능한 선형 혼합과 거의 같은 성능을 보이는 것을 확인했으며, $O(N \log N)$의 FFT알고리즘 덕분에 긴 입력에 대해서도 매우 효율적으로 동작하였다. 이 모델을 FNet이라 명명하여 소개한다.
이 연구는 attention을 효율적으로 개선하려는 “efficient Transformer” 연구들과도 맞닿아 있다. attention 행렬을 희소화하거나 선형화하여 몇몇 연구들은 $O(N)$ 수준의 복잡도를 달성한다. 그러나 이들 기법은 근사 과정에서 발생하는 큰 상수 비용을 숨기고 있어, 실제로는 FNet보다 확장성이 떨어지는 경우가 많다.
이 논문에서는 크게 다음 4가지 결과를 보여준다:
단순한 선형 변환만으로도 텍스트의 다양한 관계를 모델링할 수 있다. 파라미터가 없는 푸리에 변환에 feed-forward 레이어의 MLP를 결합하는 것만으로도 충분한 성능이 나온다. 이는 적어도 일부 NLP 과제에서 attention이 트랜스포머 성능의 핵심 요소가 아닐 수도 있음을 시사한다.
FNet은 속도, 메모리, 정확도 사이에서 좋은 절충점을 제공한다. GLUE 벤치마크에서 BERT-Base 정확도의 92%, BERT-Large 정확도의 97%를 달성하면서, 학습 속도는 GPU에서 80%, TPU에서 70% 빠르다.
소수의 attention 레이어만 추가해도 정확도를 대부분 회복할 수 있다. 마지막 두 레이어만 self-attention으로 되돌린 FNet-Hybrid는 BERT 정확도의 97~99%를 달성하면서도 40~70% 빠르다. 이는 attention이 정확도 향상에 도움은 되지만, 모든 레이어에 필요하지 않을 수 있음을 보여준다.
FNet은 긴 입력에서 efficient Transformer보다 더 나은 속도–정확도 향상을 보인다. Long Range Arena(LRA) 벤치마크에서 FNet은 가장 정확한 efficient Transformer들과 비슷한 정확도를 보이면서, GPU를 이용한 모든 시퀀스 길이에서의 학습, 추론이 더 빨랐다.
Model
Discrete Fourier Transform
푸리에 변환은 함수를 그것을 구성하는 주파수 성분들로 분해하여 표현한다. 길이 $N$인 시퀀스 ${x_n}$ ($n = 0, \dots, N-1$)이 주어졌을 때, 이산 푸리에 변환(DFT)은 다음과 같이 정의된다.
\[X_k = \sum_{n=0}^{N-1} x_n \, e^{-\frac{2\pi i}{N} nk}, \qquad 0 \le k \le N-1\]
각 출력 $X_k$는 모든 입력 $x_n$에 회전 인자(twiddle factor) $e^{-\frac{2\pi i}{N}nk}$를 곱해 더한 값이다. 즉 출력의 각 성분이 시퀀스 전체의 정보를 담게 되며, FNet은 바로 이 성질을 토큰 혼합(token mixing)에 활용한다. 또한 회전 인자가 위치 인덱스 $n$에 의존하므로, 변환 결과에는 토큰의 위치 정보도 함께 반영된다.
즉 Fourier 서브레이어는 $[N, D]$ 입력을 고정된 무늬들로 측정하여, 출력 행렬의 각 원소가 모든 토큰과 모든 특징의 정보를 담도록 재표현하는 역할까지만 하는 것이다. 섞인 표현에서 의미 있는 패턴을 추출하는 일은 뒤따르는 feed-forward 서브레이어가 학습을 통해 수행한다.
예시를 통해 원리를 살펴보면 다음과 같다.
입력과 DFT 행렬
토큰 4개, 임베딩 차원 4인 입력 $X \in \mathbb{R}^{4 \times 4}$를 생각해 보자. 행은 토큰, 열은 임베딩 차원(특징)이다.
\[X = \begin{array}{c|cccc}
& h_0 & h_1 & h_2 & h_3 \\ \hline
\text{나는} & 1 & 0 & 2 & 1 \\
\text{밥을} & 0 & 1 & 1 & 0 \\
\text{정말} & 2 & 1 & 0 & 1 \\
\text{먹었다} & 1 & 0 & 1 & 2
\end{array}\]
$N = 4$일 때 회전 인자는 $e^{-\frac{2\pi i}{4}nk} = (-i)^{nk}$이므로, DFT 행렬은 다음과 같다. (정규화 계수는 생략)
\[W = \begin{bmatrix}
1 & 1 & 1 & 1 \\
1 & -i & -1 & i \\
1 & -1 & 1 & -1 \\
1 & i & -1 & -i
\end{bmatrix}\]
$W$의 각 행은 서로 다른 빠르기로 진동하는 기본 패턴이다.
$k$
패턴 (위치 0~3)
의미
0
$[1,\ 1,\ 1,\ 1]$
변화 없음 (전체 합)
1
$[1,\ -i,\ -1,\ i]$
길이 4 동안 한 번 진동
2
$[1,\ -1,\ 1,\ -1]$
한 칸마다 부호가 바뀜 (가장 빠른 진동)
3
$[1,\ i,\ -1,\ -i]$
$k=1$의 켤레 (반대 방향 회전)
$W$는 대칭 행렬이므로, 두 1D DFT는 행렬곱으로 다음과 같이 표현된다.
$\mathcal{F}_{\text{h}}(X) = XW$: 오른쪽에서 곱하며, 각 행(토큰) 안에서 특징들을 섞는다.
$\mathcal{F}_{\text{seq}}(X) = WX$: 왼쪽에서 곱하며, 각 열(특징) 안에서 토큰들을 섞는다.
따라서 Fourier 서브레이어는 다음과 같이 쓸 수 있다.
\[Y = \Re\big(\mathcal{F}_{\text{seq}}(\mathcal{F}_{\text{h}}(X))\big) = \Re(WXW)\]
이는 학습 가능한 선형 혼합 \(W_{\text{seq}} X W_{\text{h}}\)에서 두 학습되던 행렬을 고정된 DFT 행렬로 바꾼 형태이다.
1단계: 은닉 차원 DFT (각 토큰 안에서 섞기)
각 행에 DFT를 적용한다. 예를 들어 “나는” 행 $[1, 0, 2, 1]$은 다음과 같이 계산된다.
\[\begin{aligned}
k=0&: \; 1 + 0 + 2 + 1 = 4 \\
k=1&: \; 1 + 0\cdot(-i) + 2\cdot(-1) + 1\cdot(i) = -1 + i \\
k=2&: \; 1 - 0 + 2 - 1 = 2 \\
k=3&: \; 1 + 0\cdot(i) + 2\cdot(-1) + 1\cdot(-i) = -1 - i
\end{aligned}\]
네 행에 모두 적용하면 다음과 같다.
\[XW = \begin{array}{c|cccc}
& k_h{=}0 & k_h{=}1 & k_h{=}2 & k_h{=}3 \\ \hline
\text{나는} & 4 & -1+i & 2 & -1-i \\
\text{밥을} & 2 & -1-i & 0 & -1+i \\
\text{정말} & 4 & 2 & 0 & 2 \\
\text{먹었다} & 4 & 2i & 0 & -2i
\end{array}\]
이 단계에서 행은 여전히 토큰이다. 각 토큰은 자기 자신의 특징들만 섞었을 뿐, 다른 토큰의 정보는 아직 들어오지 않았다. 반면 열은 더 이상 원래의 특징 $h_m$이 아니라 특징 축의 주파수 성분 $k_h$가 된다.
2단계: 시퀀스 DFT (토큰끼리 섞기)
이번에는 각 열에 DFT를 적용한다. 첫 번째 열 $[4, 2, 4, 4]^\top$은 다음과 같이 계산된다.
\[\begin{aligned}
k=0&: \; 4 + 2 + 4 + 4 = 14 \\
k=1&: \; 4 + 2\cdot(-i) + 4\cdot(-1) + 4\cdot(i) = 2i \\
k=2&: \; 4 - 2 + 4 - 4 = 2 \\
k=3&: \; 4 + 2\cdot(i) + 4\cdot(-1) + 4\cdot(-i) = -2i
\end{aligned}\]
네 열에 모두 적용하면 다음과 같다.
\[WXW = \begin{array}{c|cccc}
& k_h{=}0 & k_h{=}1 & k_h{=}2 & k_h{=}3 \\ \hline
k_{\text{seq}}{=}0 & 14 & 2i & 2 & -2i \\
k_{\text{seq}}{=}1 & 2i & -6+2i & 2 & 0 \\
k_{\text{seq}}{=}2 & 2 & 2 & 2 & 2 \\
k_{\text{seq}}{=}3 & -2i & 0 & 2 & -6-2i
\end{array}\]
3단계: 실수부 추출
마지막으로 실수부만 취하면 Fourier 서브레이어의 출력이 된다.
\[Y = \Re(WXW) = \begin{bmatrix}
14 & 0 & 2 & 0 \\
0 & -6 & 2 & 0 \\
2 & 2 & 2 & 2 \\
0 & 0 & 2 & -6
\end{bmatrix}\]
두 DFT의 적용 순서는 결과에 영향을 주지 않는다. 행렬곱의 결합법칙에 의해 $W(XW) = (WX)W$이기 때문이다.
출력의 의미
행렬곱 $WXW$의 $(a, b)$ 원소를 풀어 쓰면 다음과 같다.
\[Y_{ab} = \Re\Big(\sum_{n,m} W_{an}\, X_{nm}\, W_{mb}\Big) = \sum_{n,m} X_{nm}\, P^{(a,b)}_{nm}, \qquad P^{(a,b)}_{nm} = \cos\!\left(\tfrac{\pi}{2}(an + bm)\right)\]
즉 $Y_{ab}$는 입력 $X$와 고정된 2차원 물결 무늬 $P^{(a,b)}$를 원소별로 곱해 모두 더한 값이다. 다시 말해 $X$가 그 무늬와 얼마나 닮았는지를 측정한 값이다. 예를 들어 $P^{(0,0)}$은 모든 원소가 1인 무늬이므로, $Y_{00} = 14$는 모든 토큰과 모든 특징을 더한 문장 전체의 총량이 된다.
이 관점에서 $Y$의 행과 열은 다음과 같이 해석할 수 있다.
행은 특정 토큰이 아니라, 문장 전체를 바라보는 하나의 관점이다. 행 $a$는 네 토큰을 패턴 $a$로 묶은 결과이다. 예를 들어 행 0은 네 토큰을 그대로 더한 문장 요약이고, 행 2는 $[+, -, +, -]$ 패턴으로 인접 토큰 간의 차이를 묶은 것이다.
열은 원래 특징이 아니라, 특징들을 조합해 만든 파생 특징이다. 열 $b$는 각 토큰의 특징을 패턴 $b$로 요약한 값이 토큰을 따라 어떻게 변하는지를 나타낸다.
결과적으로 출력의 모든 원소는 입력의 16개 값 전부로부터 계산되며, 이것이 Fourier 서브레이어가 토큰을 섞는 방식이다.
해석 시 유의할 점
1. 의미적 해석이 아니다.
$Y$의 각 원소에 문법적 관계와 같은 언어적 의미가 있는 것은 아니다. 무늬 $P^{(a,b)}$는 입력과 무관하게 고정되어 있기 때문이다. Attention은 무엇을 얼마나 섞을지를 입력이 결정하는 반면, FNet은 섞는 방식이 고정되어 있고 섞인 결과를 어떻게 해석할지만 이후 레이어에서 학습한다.
2. 실수부만 취하면 정보가 일부 중복된다.
$k = 1$과 $k = 3$은 서로 켤레 관계이므로 $Y_{ab} = Y_{(4-a)\bmod 4,\,(4-b)\bmod 4}$가 성립한다. 실제로 $Y_{11} = Y_{33} = -6$이다. 따라서 16개의 값이 모두 독립적이지는 않으며, 이 때문에 실수부만 취한 변환은 역변환이 불가능하다.
3. residual 연결이 토큰 정체성을 되살린다.
Fourier 서브레이어 뒤에는 $\text{LayerNorm}(X + Y)$가 이어진다. 따라서 $n$번째 행은 다음 두 정보를 함께 가진다.
\[\underbrace{X_{n,:}}_{\text{토큰 } n \text{ 자신의 특징}} + \underbrace{Y_{n,:}}_{\text{문장 전체를 } n\text{번 패턴으로 본 요약}}\]
$Y$ 단독으로는 행이 토큰에 대응하지 않지만, residual 덕분에 각 행은 “자기 토큰의 정보 + 전역 정보”를 갖게 된다. 이후 feed-forward 서브레이어는 각 행을 독립적으로 처리하는 연산임에도, 이미 모든 토큰의 정보가 섞인 입력을 받으므로 사실상 문장 전체를 보게 된다.
4. 비선형성은 feed-forward 레이어에만 있다.
Fourier 서브레이어 또한 선형 변환이다.
토큰 혼합이 없다면
토큰 혼합의 중요성은 Fourier 서브레이어를 완전히 제거한 FF-only 모델과 비교하면 분명해진다. 논문에서 FF-only 모델은 이진 분류 과제에서 약 50%, 즉 무작위 추측 수준의 정확도에 그쳤다. 또한 시퀀스 방향 DFT만 남기고 은닉 방향 DFT를 제거한 모델은 정확도가 다소 떨어졌지만 FF-only보다는 훨씬 좋은 성능을 보였다. 은닉 방향 혼합은 feed-forward 레이어도 수행하는 일이므로, Fourier 서브레이어에서 핵심적인 역할은 토큰 방향의 혼합임을 알 수 있다.
FNet architecture
그림과 같이 FNet은 attention이 없는(attention-free) 트랜스포머 인코더 아키텍처이다. 각 인코더 블록은 Fourier 서브레이어 → Add & Normalize → Feed Forward → Add & Normalize 순으로 구성되며, 이 블록을 $N$개 쌓은 뒤 dense 레이어로 최종 출력을 만든다. 즉 기존 트랜스포머 인코더 블록에서 self-attention 서브레이어만 Fourier 서브레이어로 교체한 구조이다.
Fourier 서브레이어는 (시퀀스 길이, 은닉 차원) 크기의 임베딩 입력에 2D DFT를 적용한다. 2D DFT는 시퀀스 차원을 따라 수행하는 1D DFT $\mathcal{F}{\text{seq}}$와 은닉 차원을 따라 수행하는 1D DFT $\mathcal{F}{\text{h}}$를 차례로 적용한 것과 같으며, 출력에서는 실수부만 취한다.
\[y = \Re\big(\mathcal{F}_{\text{seq}}(\mathcal{F}_{\text{h}}(x))\big)\]
두 1D DFT는 교환 가능하므로 적용 순서는 결과에 영향을 주지 않는다. 또한 실수부만 남기기 때문에, 뒤따르는 feed-forward 서브레이어나 출력 레이어가 복소수를 다룰 수 있도록 수정할 필요가 없다. Fourier 서브레이어 자체에는 학습 파라미터가 없다.
이 변환의 효과는 앞의 예시에서 보았듯이, 출력의 각 원소가 모든 토큰의 정보를 담게 된다는 것이다. 따라서 각 위치를 독립적으로 처리하는 feed-forward 서브레이어가 시퀀스 전체의 정보를 충분히 얻을 수 있다.
시간 영역과 주파수 영역의 교대
푸리에 변환에는 쌍대성(duality)이 있어, DFT를 두 번 적용하면 원래 신호가 (순서만 뒤집힌 채) 상수배되어 돌아온다.
\[\mathcal{F}(\mathcal{F}(x))_n = N \cdot x_{(-n) \bmod N}\]
즉 정방향 DFT를 한 번 더 적용하는 것은 역변환과 사실상 같은 역할을 한다. 따라서 FNet의 블록을 차례로 통과하는 과정은, 입력을 “시간” 영역(토큰 위치)과 주파수 영역 사이에서 번갈아 오가게 하는 것으로 볼 수 있다.
이때 합성곱 정리(convolution theorem)에 의해, 주파수 영역에서 계수를 곱하는 것은 시간 영역에서 합성곱을 하는 것과 같다.
\[\mathcal{F}^{-1}\big(\mathcal{F}(x) \odot \mathcal{F}(g)\big) = x \circledast g\]
따라서 주파수 영역에 있는 표현에 feed-forward 레이어의 가중치를 곱하는 것은, 원래 영역에서 보면 그와 관련된 커널로 합성곱을 하는 것에 해당한다. 이런 관점에서 저자들은 FNet을 곱셈과 합성곱을 번갈아 수행하는 모델로 해석할 수 있다고 설명한다. 다만 이는 직관적인 해석이며, 실제로는 실수부 추출, residual 연결, 비선형 활성화 함수가 끼어 있으므로 엄밀한 등가 관계는 아니다.
임베딩
DFT의 회전 인자 $e^{-\frac{2\pi i}{N}nk}$가 위치 인덱스 $n$에 의존하므로, Fourier 서브레이어는 그 자체로 위치 정보를 반영한다. 따라서 FNet은 원리적으로 positional embedding이 필요 없다. 그럼에도 논문에서는 BERT와 깔끔한 비교를 위해, BERT와 동일하게 단어 임베딩, 절대 위치 임베딩, 문장 구분(type) 임베딩을 합한 입력 임베딩을 사용하였다.
Implementation
실험 결과, GPU에서는 실험한 모든 시퀀스 길이(512–8192 토큰)에서 FFT가 행렬곱보다 빨랐다. 반면 TPU에서는 비교적 짧은 시퀀스(4096 토큰 이하)의 경우, DFT 행렬을 미리 계산해 캐싱해 두고 행렬곱으로 DFT를 계산하는 편이 FFT보다 빨랐다. 그보다 긴 시퀀스에서는 TPU에서도 FFT가 더 빨랐다.
이에 따라 GPU 구현에서는 항상 FFT를 사용하고, TPU 구현에서는 길이 4096까지는 행렬곱으로, 그보다 긴 시퀀스에서는 FFT로 2D DFT를 계산한다.
저자들은 이러한 차이의 원인으로 두 가지를 든다.
TPU는 GPU보다도 행렬곱에 더 고도로 최적화된 하드웨어이다.
GPU의 FFT 구현이 TPU의 FFT 구현보다 효율적이다.
따라서 TPU의 FFT 구현이 개선된다면 TPU에서의 FNet 성능도 더 향상될 것으로 예상된다. 모델은 JAX와 Flax 프레임워크로 구현되었다.
Results
1. Transfer learning (GLUE)
BERT와 동일한 Base/Large 설정으로 C4 데이터셋에서 사전학습한 뒤, GLUE에서 fine-tuning하여 비교하였다.
FNet은 BERT-Base 정확도의 92%, BERT-Large 정확도의 97%를 달성하면서 GPU에서 약 80%, TPU에서 약 70% 빠르게 학습된다. Large에서 격차가 줄어든 것은 FNet-Large가 BERT-Large보다 학습이 안정적이었기 때문으로 보인다.
FF-only는 이진 분류 과제에서 50% 수준에 그쳐 학습에 실패했다. 즉 토큰 혼합은 필수적이다.
Random의 낮은 성능은 아무렇게나 섞는 것이 아니라 구조화된 혼합이 필요함을 보여준다.
FNet-Hybrid는 마지막 두 Fourier 서브레이어만 self-attention으로 교체한 모델로, 적은 속도 손실로 BERT 정확도의 97~99%를 회복한다.
Linear는 Base에서 FNet보다 약간 정확하지만, 메모리를 더 많이 쓰고 긴 시퀀스로의 확장성이 떨어지며, Large에서는 학습이 불안정했다.
2. 속도–정확도 비교
모델 크기를 다양하게 바꿔 가며 비교하면, 큰 모델에서는 BERT와 FNet-Hybrid가, 작은 모델에서는 FNet과 Linear가 가장 좋은 속도–정확도 절충(Pareto frontier)을 보인다. 즉 FNet은 자원이 제한된 환경의 경량 모델에서 특히 유리하다.
3. Long Range Arena (LRA)
정확도: FNet의 평균 정확도(55.30)는 가장 정확한 모델인 Transformer(55.83)와 비슷하며, 대부분의 efficient Transformer보다 높다.
속도: GPU에서는 모든 시퀀스 길이(512–8192)에서 비교 모델 중 가장 빠르다. 길이 2048에서 Transformer 대비 학습은 3.2배, 추론은 3.4배 빠르다.
메모리: 모든 길이에서 메모리 사용량이 가장 적으며, Transformer가 메모리 부족(OOM)으로 학습하지 못하는 길이 8192에서도 7.4GB만 사용한다.
Deep Learning
· 2026-10-27
-
BEATs: Audio Pre-Training with Acoustic Tokenizers
논문 정보
제목: BEATs: Audio Pre-Training with Acoustic Tokenizers
arXiv: 2212.09058
Abstract
기존의 SOTA Audio SSL 모델들은 원본 스펙트로그램을 복원하는 Reconstruction Loss를 사용해 pre-training을 해왔다. Reconstruction Loss와 비교했을 때, Discrete Label Prediction(이산 레이블 예측) 방식은 SSL 모델이 고수준의 오디오 의미를 추상화하고, 인간의 인지 방식처럼 불필요한 저수준 디테일을 무시할 수 있도록 유도한다. 하지만 오디오 데이터는 연속적이고, speech와 달리 음소 단위 같은 자연스러운 이산 구분이 존재하지 않아, 범용 오디오를 잘 표현하는 label을 생성할 acoustic tokenizer를 만드는 것이 쉽지 않다.
이를 해결하기 위해 본 논문에서는 acoustic tokenizer와 audio SSL 모델을 반복적으로 상호 최적화하는 프레임워크인 BEATs(Bidirectional Encoder representation from Audio Transformers)를 제안한다. 처음에는 random projection을 tokenizer로 사용하여 SSL 모델을 mask-and-label-prediction 방식으로 학습시키고, 이후 반복에서는 이전에 학습된(또는 fine-tuned) SSL 모델을 teacher로 삼아 knowledge distillation을 통해 tokenizer를 개선한다. 이 과정을 반복함으로써 tokenizer와 SSL 모델이 서로의 품질을 끌어올리는 구조이다.
Introduction
Speech 데이터와 달리, 일반적인 Audio에는 목소리, 환경음, 음악 등 다양한 출처의 소리가 포함되어 범용적인 오디오 모델링이 어렵다. 이를 위해 SS-AST, Audio-MAE 같은 모델들이 일반 오디오 분류를 목표로 설계되었으며, SSL이 speech뿐 아니라 비음성 신호에서도 유용한 표현을 학습할 수 있음을 보여주었다.
하지만 이산 레이블 예측 방식을 사용하는 speech, vision, language 분야의 SSL모델과 달리, 범용 오디오 SSL 모델들은 여전히 Reconstruction Loss를 사용해 학습되어 왔다. 논문의 저자는 크게 세 가지 이유로 Discrete Label Prediction 방식이 더 적합하다고 주장한다.
1. 인간은 고수준의 의미(semantics)를 통해 소리를 이해한다.
사람은 개가 짖는 소리를 들을 때, 조용한 환경이든 비가 오는 환경이든 상관없이 “개가 짖는다”라는 패턴을 일관되게 인식한다. 배경 잡음이나 미세한 스펙트럼 차이 같은 저수준 디테일에 좌우되지 않고, 고수준의 의미적 특징을 추출, 분류하여 판단을 내리는 것이다. 이산 레이블 예측 방식은 이러한 semantic extracting & clustering 과정을 모사할 수 있으며, 이를 통해 Audio SSL 모델이 인간과 유사한 이해력과 일반화 능력(generalization)을 갖출 수 있을 것으로 기대한다. 반면 Reconstruction Loss는 마스킹된 원본 스펙트로그램을 복원하는 데 집중하므로 저수준 time-frequency 디테일에 민감해지게 된다.
2. 재구성 방식은 불필요한 정보를 예측하는 데 모델 용량이 낭비된다.
아래 그림은 논문 Section 4의 Figure 4로, 각 SSL 모델의 pre-training target을 ESC-50 오디오 샘플에 대해 T-SNE로 2차원 시각화한 것이다. Reconstruction 방식의 타겟은 동일한 오디오라도 잡음 조건이 달라지면 표현이 크게 흩어지며, 클래스별 의미 불변성(semantic invariance)이 약한 것을 볼 수 있다. 이처럼 Reconstruction Loss는 의미적으로 불필요한 정보까지 복원하는 데 모델 용량과 학습 자원이 소모된다. 반면 이산 레이블 예측 방식은 이러한 불필요한 디테일을 버리고, semantic-rich token을 예측 타겟으로 삼아 더 적은 비용으로도 고수준의 오디오 이해 능력을 학습할 수 있다.
3. Modality 간 사전학습 방식의 통합이 쉬워진다.
Language, vision, speech 분야에서는 이미 discrete label prediction이 주류이기 때문에, 오디오 또한 이 방식을 채택하면 모달리티별로 사전학습 방식을 따로 설계할 필요 없이, 하나의 패러다임으로 학습·전이·적용이 가능해진다.
이러한 장점에도 불구하고, 연속적이고 매우 다양한 형태의 소리를 포함하는 오디오를 NLP처럼 의미가 집약된 discrete token으로 표현하는 것은 여전히 어려운 과제였다. BEATs는 이 문제를 acoustic tokenizer와 SSL 모델의 반복적 상호 최적화를 통해 해결하며, 이에 대한 구체적인 방법은 다음 절에서 다룬다.
BEATs
Iterative Audio Pre-Training
위의 사진이 BEATs의 전체적인 파이프라인을 보여준다. Unlabeled Audio가 주어졌을 때, acoustic tokenizer가 먼저 이산 레이블을 생성하고, 이를 마스킹과 이산 레이블 예측을 통해 SSL 모델을 학습시킨다. 이후 학습된 SSL 모델을 다시 teacher로써 acoustic tokenizer를 지식 증류 방식으로 학습시키고, 이 과정을 반복하여 두 모델을 학습시킨다.
구체적으로, 오디오 클립이 주어지면 먼저 raw waveform으로부터 acoustic feature(=128차원 Mel-filterbank feature, 즉 mel-spectrogram)를 추출하고, 이를 16×16 크기의 patch들로 나눈 뒤 flatten하여 patch sequence X = {x_t}를 만든다.
SSL 모델 학습 시: acoustic tokenizer가 이 patch sequence $X$를 quantize하여 patch-level discrete label \(\hat{Z} = \{\hat{z}_t\}_{t=1}^{T}\)를 생성하고, 이를 masked prediction의 target으로 사용한다. (이후 내용에서 자세히 설명)
acoustic tokenizer 학습 시: 반대로 (이전 iteration에서 학습된) SSL 모델이 동일한 patch sequence $X$를 인코딩하여 얻은 출력 시퀀스 \(\hat{O} = \{\hat{o}_t\}_{t=1}^{T}\)를 knowledge distillation target으로 사용한다. 즉 SSL 모델이 tokenizer의 teacher 역할을 하는 것이다. (이후 내용에서 자세히 설명)
이렇게 두 모델이 서로 번갈아 가며 상대방의 학습 target을 제공하는 구조를 갖는다. 참고로 첫 번째 iteration에서는 아직 학습된 SSL 모델이 없기 때문에, random-projection tokenizer를 사용해 discrete label을 생성한다.
Acoustic Tokenizers
BEATs의 tokenizer는 첫 iteration에 쓰이는 Random-project Tokenizer와 이후 학습한 Self-Distilled Tokenizer가 있다.
1) Random-Projection Tokenizer
단순히 랜덤하게 patch embedding을 선형변환 하는 것이 아닌, Codebook을 이용한 양자화(Quantize)과정이 함께 일어난다.
Quantization은 연속적인(continuous) 벡터를 codebook 내가장 가까운 벡터로 매핑하고, 그 벡터의 인덱스를 이산 레이블로 사용하는 과정이다. Codebook $V = {v_1, v_2, \dots, v_K}$ 는 $K$개의 대표 벡터로 이루어진 집합이며, quantize는 입력 벡터가 이 $K$개 벡터 중 어디에 가장 가까운지를 찾는 nearest-neighbor 탐색이다.
Random-projection tokenizer의 codebook은 랜덤 초기화 이후 고정(frozen)되어 전혀 학습되지 않는다. 반면 self-distilled tokenizer의 codebook은 학습 가능(learnable)하며, quantization의 argmin 연산이 미분 불가능하다는 문제를 해결하기 위해 일반적인 backpropagation 대신 Exponential moving average(EMA) 방식으로 업데이트된다.
패치 시퀀스 \(X = \{x_t\}_{t=1}^{T}\)의 각 $x_t$를 랜덤 초기화된 linear projection $W$로 변환한 뒤, 고정된(frozen) codebook $V = {v_i}_{i=1}^{K}$에서 가장 가까운 벡터를 찾는다.
\[\hat{z}_t = \arg\min_{i} \, \lVert v_i - W x_t \rVert_2^2 \tag{1}\]
$W x_t$ : 선형변환된 입력 벡터
$v_i$ : codebook의 $i$번째 벡터 (랜덤 초기화 후 학습되지 않음)
$\hat{z}_t$ : 가장 가까운 codebook 벡터의 인덱스 –> 이것이 discrete label
2) Iteration: Self-Distilled Tokenizer
학습의 두 번째 iteration부터는 이전 iteration에서 학습된 audio SSL 모델(pre-trained 혹은 fine-tuned)을 teacher로 삼아 tokenizer를 학습시킨다. 이를 self-distilled tokenizer라고 부른다.
1. Tokenizer Encoder: X → E
입력 patch sequence \(X = \{x_t\}_{t=1}^{T}\)를 12-layer Transformer encoder에 통과시켜 인코딩된 벡터 시퀀스 \(E = \{e_t\}_{t=1}^{T}\)를 얻는다.
2. Quantization: E → $\hat{Z}$
각 인코딩 벡터 $e_t$에 대해, 학습 가능한(learnable) codebook $V = {v_i}_{i=1}^{K}$에서 nearest neighbor를 찾아 quantize한다. 이때 codebook 활용도(utilization)를 높이기 위해 $\ell_2$ 정규화를 적용한다.
\[\hat{z}_t = \arg\min_{i} \, \lVert \ell_2(v_i) - \ell_2(e_t) \rVert_2^2 \tag{2}\]
Quantize된 벡터 시퀀스는 다음과 같이 정의된다.
\[E^{q} = \{ v_{\hat{z}_t} \}_{t=1}^{T}\]
3. Tokenizer Estimator: E^q → teacher 출력 예측
$E^q$를 입력으로 하는 3-layer Transformer estimator를 학습시켜, teacher(SSL 모델)의 마지막 레이어 출력 \(\{\hat{o}_t\}_{t=1}^{T}\)을 맞히도록 한다.
+) Straight-Through Gradient
vector quantization은 $\arg\min$ 연산을 포함하므로 미분이 불가능하다. Random-Projection Tokenizer의 경우에는 학습되는 파라미터가 없어서 gradient를 계산할 필요가 없었으나, Self-Distilled Tokenizer는 Encoder와 Codebook이 learnable parameter이므로 gradient가 필요하다.
이를 위해 Van Den Oord et al. (2017)의 straight-through gradient 방식을 적용하여, backward 과정에서 quantized vector sequence $E^q$의 gradient를 encoder 출력 $E$로 그대로 복사한다.
참고: 공식 GitHub 코드(quantizer.py)에서 straight-through gradient는 다음과 같이 구현되어 있다.
z_q = z + (z_q - z).detach()
(z_q - z)에 .detach()를 적용해 이 차이값에 대한 gradient를 끊음으로써, backward 시 $z_q$로 흘러온 gradient가 그대로 encoder 출력 $z$로 전달되도록 만든다.
4. 전체적인 학습 목표
Self-distilled tokenizer의 전체 학습 목표는 tokenizer estimator의 출력 \(\{o_t\}_{t=1}^{T}\)과 teacher 모델 출력 \(\{\hat{o}_t\}_{t=1}^{T}\) 사이의 cosine similarity, 그리고 encoder 출력 $E$와 quantized vector sequence $E^q$ 사이의 MSE로 구성된다.
\[\max \sum_{X \in D} \sum_{t=1}^{T} \cos(o_t, \hat{o}_t) \;-\; \lVert \text{sg}[\ell_2(e_t)] - \ell_2(v_{\hat{z}_t}) \rVert_2^2 \;-\; \lVert \ell_2(e_t) - \text{sg}[\ell_2(v_{\hat{z}_t})] \rVert_2^2 \tag{3}\]
첫 번째 항: knowledge distillation (tokenizer estimator 출력과 teacher 출력 간 cosine similarity)
두 번째, 세 번째 항: encoder 출력과 quantized vector 간 MSE (codebook 학습 안정화를 위한 항)
$D$ : pre-training dataset, $\text{sg}[\cdot]$ : stop-gradient operator
Codebook 벡터 $v_i$ 자체의 업데이트는 보다 안정적인 학습을 위해 exponential moving average (EMA) 방식으로 이루어진다 (Van Den Oord et al., 2017).
구성 요소
학습 여부
학습 방식
Tokenizer Encoder (12-layer Transformer)
✅ 학습됨
일반 backpropagation. 단, $E^q \rightarrow E$ 구간은 straight-through gradient로 우회
Codebook $V = {v_i}$
✅ 학습됨
EMA(exponential moving average)로 업데이트 - 일반 backprop이 아님
Tokenizer Estimator (3-layer Transformer)
✅ 학습됨
일반 backpropagation (loss에서 바로 연결되어 있어 gradient 문제 없음)
5. Inference(=pre-training 파이프라인 내부)
tokenizer를 다 학습시킨 뒤, 이 tokenizer를 실제로 SSL 모델의 pre-training에 쓸 때는 estimator를 떼고 encoder+codebook만 쓴다. 입력 \(X = \{x_t\}_{t=1}^{T}\)를 위에서와 같은 방식(2)으로 patch-level discrete label \(\hat{Z} = \{\hat{z}_t\}_{t=1}^{T}\)로 변환한다.
discrete label z_t는 codebook 인덱스, 정수값이며, SSL모델 학습 시 정답값으로 사용된다.
Audio SSL Model
Backbone
이전 연구들과 마찬가지로, backbone network로 ViT(Vision Transformer) 구조를 사용한다. ViT는 linear projection layer와 여러 층의 Transformer encoder layer로 구성된다.
입력 오디오로부터 얻은 patch sequence \(X = \{x_t\}_{t=1}^{T}\)를 linear projection network에 통과시켜 patch embedding \(E = \{e_t\}_{t=1}^{T}\)를 얻는다. 이후 $E$를 Transformer encoder layer에 통과시켜 인코딩된 patch representation \(R = \{r_t\}_{t=1}^{T}\)를 얻는다.
일반 ViT와 다른 점 (구조적 개선):
Convolution 기반 relative position embedding: Transformer 하단에 위치하며, patch 간 상대적 위치 정보를 더 잘 인코딩하기 위해 사용
Gated relative position bias (Chi et al., 2022): 위치 정보 인코딩을 개선하기 위해 추가
DeepNorm (Wang et al., 2022a): 보다 안정적인 pre-training을 위해 적용
Pre-Training
왼쪽의 (a) Pre-training 그림처럼 Masked Audio Modeling (MAM) task를 사용한다. 기존의 음향적 특징을 직접 reconstruct하며 사전학습하는 방식과 달리, BEATs는 acoustic tokenizer가 생성한 patch-level discrete label을 예측하도록 학습된다.
1. Masking
입력 patch sequence \(X = \{x_t\}_{t=1}^{T}\)와 이에 대응되는 discrete label \(\hat{Z} = \{\hat{z}_t\}_{t=1}^{T}\)가 주어지면, 이 중 75% 의 patch를 랜덤하게 마스킹한다. 마스킹된 위치의 집합을 \(\mathcal{M} = \{1, \dots, T\}^{0.75T}\)로 표기한다.
2. Encoder: 마스킹되지 않은 patch만 입력
마스킹되지 않은 patch sequence \(X^U = \{x_t : t \notin \mathcal{M}\}_{t=1}^{T}\)만을 ViT encoder에 통과시켜, 인코딩된 representation \(R^U = \{r_t : t \notin \mathcal{M}\}_{t=1}^{T}\)를 얻는다.
전체 patch가 아니라 마스킹되지 않은 patch만 encoder에 넣는 이유는 학습 속도를 크게 높이기 위함이며, downstream task 성능에도 오히려 약간의 향상을 준다고 언급한다(Xu et al., 2022).
Note: 논문에서는 $\mathcal{M}$을 “masking된 위치의 집합(75%)”으로 정의했음에도, 이후 $X^U$, $R^U$, label predictor 입력 수식에서는 $t \in \mathcal{M}$을 “masking되지 않은(unmasked)” 위치를 가리키는 데 사용하고 있어 표기상 일관성이 어긋난다. 이 글에서는 의미에 맞게 $t \notin \mathcal{M}$ (unmasked) / $t \in \mathcal{M}$ (masked, mask token 대입)로 표기한다.
3. Label Predictor: 전체 위치에 대해 discrete label 예측
Encoder를 거친 non-masked patch representation과, masked position에는 학습 가능한 mask token(0으로 표기)을 채워 넣은 조합
\[\{r_t : t \notin \mathcal{M}\}_{t=1}^{T} \cup \{\mathbf{0} : t \in \mathcal{M}\}_{t=1}^{T}\]
을 label predictor(Transformer 기반)에 입력하여, 전체 위치에 대한 discrete label $Z = {z_t}_{t=1}^{T}$을 예측한다.
Mask token $\mathbf{0}$은 masking된 모든 위치에서 동일한 값을 갖는 placeholder로, 실제로 “그 위치에 무엇이 있었는지”에 대한 정보를 담고 있지 않다. 그럼에도 각 masked 위치마다 서로 다른 discrete label $\hat{z}_t$를 예측할 수 있는 이유는, Transformer의 position embedding과 self-attention을 통해 주변 unmasked patch들의 정보를 활용하기 때문이다. 이는 BERT의 [MASK] 토큰과 동일한 원리이다.
Note: BEATs 논문에는 mask token이 learnable 벡터인지, 단순한 고정 zero vector인지 명시되어 있지는 않다.
4. 학습 목표
MAM의 pre-training objective는 cross entropy loss이며, 마스킹된 위치에서 정답 discrete label $\hat{z}_t$의 log-likelihood를 최대화하는 방향으로 학습한다.
\[\mathcal{L}_{MAM} = -\sum_{t \in \mathcal{M}} \log p(\hat{z}_t \mid X^U) \tag{4}\]
즉, encoder는 마스킹되지 않은 정보만으로 문맥을 파악하고, label predictor는 그 문맥을 바탕으로 마스킹된 위치의 “정답 label”(tokenizer가 만들어 둔 $\hat{z}_t$)을 맞히도록 학습된다. BERT의 masked language modeling과 동일한 구조를, “단어” 대신 “discrete acoustic label”에 적용한 것이라 볼 수 있다.
Fine-Tuning
파인튜닝 단계에서는 pre-training 때 사용했던 label predictor를 버리고, ViT encoder 위에 task-specific한 linear classifier를 연결하여 downstream classification task를 수행한다.
1. 입력 처리
먼저 입력 acoustic feature에 시간과 주파수 축으로 랜덤 마스킹을 적용하는 spec-augmentation(Park et al., 2019)을 수행한 뒤, 이를 patch로 나누고 flatten하여 patch sequence $X = {x_t}_{t=1}^{T}$를 만든다.
2. Encoder: pre-training과의 차이
Pre-training에서는 75%를 마스킹하고 unmasked patch만 encoder에 입력했지만, fine-tuning에서는 전체 patch sequence $X$를 그대로 ViT encoder에 입력하여 encoded representation $R = {r_t}_{t=1}^{T}$를 얻는다.
3. Classifier
Encoder의 출력 $R$에 linear projection $W_c$를 먼저 적용한 뒤, 이를 mean-pooling하고 softmax를 거쳐 category 확률을 계산한다.
\[p(C) = \text{Softmax}(\text{MeanPool}(W_c R))\]
$W_c$: linear classifier의 projection weight
MeanPool: $W_c R$의 결과(patch sequence 전체, $T$개)에 대한 평균 풀링
Softmax: 최종 class 확률로 변환
4. Loss Function
단일 레이블 분류 task: cross entropy loss
다중 레이블 분류 task (AudioSet처럼 하나의 clip에 여러 class가 붙는 경우) 또는 mixup augmentation(Zhang et al., 2017)을 적용하는 경우: binary cross entropy loss
Implementation Details
Backbone
Transformer encoder layer 12개, hidden dimension 768, attention head 8개
총 파라미터 수: 90M
기존 SOTA 모델(Audio-MAE, MaskSpec 등)과 모델 크기를 비슷하게 맞춰 pre-training 방법 자체의 효과를 공정하게 비교
Acoustic Feature 추출
Raw waveform → sample rate 16,000Hz로 변환
128차원 Mel-filter bank feature 추출 (25ms Povey window, 10ms shift)
Feature를 평균 0, 표준편차 0.5로 정규화
16×16 크기의 patch로 분할 후 flatten하여 patch sequence 생성
Codebook
Codebook: $K = 1024$개의 embedding vector로 구성, 각 벡터의 차원은 256 ($V \in \mathbb{R}^{1024 \times 256}$)
-
ViT - An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale
논문 정보
제목: An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale
arXiv: 2010.11929
Abstract
트랜스포머(Transformer) 아키텍처는 자연어처리(NLP) 분야에서 사실상 표준으로 자리잡았지만, 컴퓨터 비전(CV) 분야에서의 적용은 아직 제한적이다. (논문 시점 기준) 기존 연구들은 CNN과 attention을 함께 사용하거나 CNN의 일부 구성 요소만 attention으로 대체하는 정도였고, 전체적인 CNN 구조 자체는 그대로 유지하는 경우가 많았다.
본 논문은 이러한 CNN에 대한 의존이 반드시 필요한 것은 아니며, 이미지 패치(patch) 시퀀스에 순수한 트랜스포머를 직접 적용하는 것만으로도 이미지 분류 태스크에서 매우 좋은 성능을 얻을 수 있음을 보인다. 대량의 데이터로 사전학습(pre-training)한 뒤 ImageNet, CIFAR-100, VTAB 등 이미지 인식 벤치마크로 전이(transfer)했을 때, Vision Transformer(ViT)는 기존 SOTA CNN 대비 적은 연산 자원으로도 대등하거나 더 뛰어난 성능을 달성했다.
1. Introduction
NLP에서의 트랜스포머 확장 성공에 영감을 받아, 본 논문은 최소한의 구조 변경만으로 표준 트랜스포머를 이미지에 직접 적용한다. 이를 위해 이미지를 여러 개의 패치(patch)로 나누고, 이 패치들을 선형 임베딩(linear embedding)한 시퀀스를 트랜스포머의 입력으로 사용한다. 즉, 이미지 패치를 NLP의 토큰(단어)과 동일한 방식으로 취급하는 것이다. 모델은 지도학습(supervised) 방식의 이미지 분류로 학습된다.
ImageNet과 같은 중간 규모 데이터셋으로 강한 정규화(regularization) 없이 학습할 경우, ViT는 비슷한 크기의 ResNet보다 살짝 낮은 아쉬운 정확도를 보인다. 이는 어느 정도 예상 가능한 결과인데, 트랜스포머는 CNN이 태생적으로 갖고 있는 귀납적 편향(inductive bias) - 이동 등변성(translation equivariance)과 지역성(locality)을 갖고 있지 않기 때문에, 데이터 양이 충분하지 않으면 일반화 성능이 떨어지는 것으로 보인다.
그러나 더 큰 규모의 데이터셋(1,400만~3억 장)으로 학습하면 더 좋은 성능을 보여줬다.
Inductive Bias(귀납적 편향)
귀납적 편향이란, 모델이 학습 데이터에서 보지 못한 입력에 대해 일반화할 때 사용하는 ‘모델 구조에 미리 내장된 가정 또는 제약’을 말한다. CNN은 이미지에 특화된 두 가지 강한 귀납적 편향을 갖는다.
지역성(Locality): convolution 연산은 작은 커널(예: 3x3)로 이미지의 국소 영역만 보고 특징을 뽑는다. “의미 있는 패턴(엣지, 질감)은 가까운 픽셀들 사이에서 나온다”는 가정이 구조적으로 내장되어 있다.
이동 등변성(Translation Equivariance): 동일한 필터가 이미지 전체를 슬라이딩하며 적용되므로, 객체의 위치가 이동해도 그에 대응해 특징맵도 같은 방식으로 이동한다.
덕분에 CNN은 비교적 적은 데이터로도 이미지의 공간 구조를 잘 학습한다.
반면 ViT의 self-attention은 원칙적으로 모든 패치가 서로 전역적(global)으로 상호작용하고, 위치 정보도 학습 가능한 position embedding으로만 부여될 뿐 CNN처럼 구조적으로 강제되지 않는다. “가까운 패치끼리 더 관련 있다”거나 “패턴은 위치가 바뀌어도 동일하게 인식돼야 한다”는 가정을 모델이 미리 갖고 있지 않으므로, 트랜스포머는 이런 공간적 관계를 데이터로부터 처음부터 스스로 학습해야 한다.
2. Method
2.1. Vision Transformer (ViT)
트랜스포머는 원래 1차원 토큰 임베딩 시퀀스를 입력으로 받는다. 이미지(2D 데이터)를 트랜스포머에 넣기 위해, ViT는 이미지를 여러 개의 고정 크기의 패치로 나눈 뒤 각 패치를 1차원 벡터처럼 취급한다.
원본 이미지: $\mathbf{x} \in \mathbb{R}^{H \times W \times C}$
$H, W$: 이미지의 세로/가로 해상도
$C$: 채널 수 (RGB면 3)
이를 flatten된 2D 패치들의 시퀀스로 reshape:
\[\mathbf{x}_p \in \mathbb{R}^{N \times (P^2 \cdot C)}\]
$(P, P)$: 패치 하나의 해상도 (예: 16×16)
$N = HW / P^2$: 패치 개수 –> 이 값이 곧 트랜스포머의 시퀀스 길이(sequence length) 가 된다. (이미지가 $P \times P$ 크기의 “단어” $N$개로 쪼개지는 것)
패치 하나당 차원은 $P^2 \cdot C$ (패치를 펼쳐서 1차원 벡터로 만든 크기)
예를 들어 224×224×3 이미지를 16×16 패치로 나누면, $N = (224/16)^2 = 196$개의 패치가 생기고 패치 하나의 차원은 $16 \times 16 \times 3 = 768$이 된다.
Patch Embedding (Eq. 1)
트랜스포머는 모든 레이어에서 고정된 잠재 벡터 크기 $D$ 를 사용한다. 따라서 각 패치($P^2 \cdot C$ 차원)를 학습 가능한 선형 변환 $E$를 통해 $D$ 차원으로 매핑한다.
\[\mathbf{z}_0 = [\mathbf{x}_{\text{class}}; \; \mathbf{x}_p^1 \mathbf{E}; \; \mathbf{x}_p^2 \mathbf{E}; \; \cdots; \; \mathbf{x}_p^N \mathbf{E}] + \mathbf{E}_{pos}
\tag{1}\]
\[\mathbf{E} \in \mathbb{R}^{(P^2 \cdot C) \times D}, \qquad \mathbf{E}_{pos} \in \mathbb{R}^{(N+1) \times D}\]
$\mathbf{x}_p^i \mathbf{E}$: $i$번째 패치를 $D$차원으로 투영한 것. Patch embedding이라 부른다.
$\mathbf{x}_{\text{class}}$: 맨 앞에 붙이는 학습 가능한 class 토큰 (아래에서 설명)
$\mathbf{E}_{pos}$: 각 위치(패치)에 더해지는 position embedding. 시퀀스 길이가 $N+1$인 이유는 class 토큰까지 포함하기 때문.
결과적으로 $\mathbf{z}_0 \in \mathbb{R}^{(N+1) \times D}$ - 이 텐서가 트랜스포머 인코더의 입력이 된다.
Position Embedding
$\mathbf{E}_{pos}$는 D차원 벡터 하나가 아니라, $(N+1)$개의 행(row)을 가진 학습 가능한 룩업 테이블(lookup table) 이다. 행 하나하나가 각각 D차원 벡터이고, 몇 번째 위치인지(0번=class 토큰, 1번=첫 패치, 2번=두 번째 패치…)에 따라 해당 행이 선택되어 그 위치의 patch embedding에 더해진다.
\[\mathbf{z}_0^i = \mathbf{x}_p^i \mathbf{E} + \mathbf{E}_{pos}^{(i)}, \qquad i = 0, 1, \ldots, N\]
여기서 \(\mathbf{E}_{pos}^{(i)} \in \mathbb{R}^D\) 는 $\mathbf{E}_{pos}$ 행렬의 $i$ 번째 행이다. 즉 패치 196개짜리 시퀀스(class 토큰 포함 197개)라면, 서로 다른 197개의 D차원 벡터가 각각 학습되는 것이지, 모든 위치에 동일한 벡터가 더해지는 게 아니다.
기본 트랜스포머는 사인/코사인 함수로 만든 고정된(fixed) position encoding을 사용했다면, ViT는 이 값들을 랜덤 초기화 후 역전파로 학습시키는 것이다.
논문의 “1D”라는 표현은 벡터의 차원이 1차원이라는 뜻이 아니라, 패치들을 (row, col) 같은 2차원 좌표가 아닌 순서(1차원 인덱스, raster order)로만 구분한다는 뜻이다. 즉 14x14로 배열된 패치라도 위치를 2차원으로 인코딩하지 않고 1번, 2번, …, 196번처럼 일렬로 펴서 각각 독립적인 벡터를 학습시킨다.(2D-aware PE를 해도 큰 성능향상을 발견하지 못함. Appendix D.4)
여기까지 기억해야할 점은 patch embedding($\mathbf{E}$)과 position embedding($\mathbf{E}_{pos}$) 모두 완전히 학습되는 파라미터라는 것이다. 초기화 시점에는 위치 정보가 전혀 없고, 2D 공간 관계는 학습을 통해 처음부터 습득해야 한다.
Transformer Encoder (Eq. 2, 3, 4)
인코더는 Multi-head Self-Attention(MSA) 블록과 MLP 블록이 번갈아 쌓인 구조다. 각 블록 앞에는 LayerNorm(LN)이, 각 블록 뒤에는 residual connection이 적용된다.
\[\mathbf{z}'_\ell = \text{MSA}(\text{LN}(\mathbf{z}_{\ell-1})) + \mathbf{z}_{\ell-1}, \qquad \ell = 1 \ldots L
\tag{2}\]
\[\mathbf{z}_\ell = \text{MLP}(\text{LN}(\mathbf{z}'_\ell)) + \mathbf{z}'_\ell, \qquad \ell = 1 \ldots L
\tag{3}\]
\[\mathbf{y} = \text{LN}(\mathbf{z}_L^0)
\tag{4}\]
$L$: 인코더 레이어(블록) 개수
\(\mathbf{z}_{\ell-1}, \mathbf{z}_\ell \in \mathbb{R}^{(N+1) \times D}\) - 레이어를 통과해도 shape은 그대로 유지된다.
MLP는 GELU 비선형성을 사용하는 2개의 완전연결층으로 구성 (보통 중간에 차원을 확장했다가 다시 $D$로 축소, 예: $D \to 4D \to D$)
식 (4)에서 $\mathbf{z}_L^0$은 마지막 레이어 출력 시퀀스의 0번째 벡터, 즉 class 토큰 위치의 출력만 뽑아낸 것이다. 여기에 최종 LayerNorm을 적용한 것이 이미지 전체를 표현하는 벡터 $\mathbf{y}$가 된다.
[class] token
BERT의 [CLS] 토큰과 동일한 아이디어다. 패치 임베딩 시퀀스 $N$개만 있으면 “이미지 전체를 대표하는 하나의 벡터”가 없다. 그래서 학습 가능한 벡터 \(\mathbf{x}_{\text{class}}\)를 시퀀스 맨 앞에 인위적으로 추가한다 (\(\mathbf{z}_0^0 = \mathbf{x}_{\text{class}}\)).
이 토큰도 다른 패치 토큰들과 동일하게 self-attention에 참여한다. 즉 모든 레이어에서 다른 패치들의 정보를 attention을 통해 수집한다.
$L$개의 레이어를 다 통과하고 나면, 이 위치의 최종 출력 $\mathbf{z}_L^0$이 이미지 전체의 표현(representation) $\mathbf{y}$으로 사용된다 (식 4).
분류를 할 때는 이 $\mathbf{y}$에 classification head를 붙인다:
사전학습(pre-training) 시: hidden layer 1개짜리 MLP (tanh 활성함수)
미세조정(fine-tuning) 시: 단순한 linear layer 1개
요약
단계
연산
Shape
원본 이미지
—
$H \times W \times C$
패치로 분할
reshape
$N \times (P^2 \cdot C)$
Patch embedding
$\mathbf{x}_p \mathbf{E}$
$N \times D$
class 토큰 추가
concat
$(N+1) \times D$
Position embedding 추가
$+ \mathbf{E}_{pos}$
$(N+1) \times D$
인코더 통과 ($L$회 반복)
MSA + MLP
$(N+1) \times D$ (shape 불변)
최종 표현 추출
$\mathbf{z}_L^0$ 선택 + LN
$1 \times D$
분류
MLP / Linear head
$1 \times K$ (K = 클래스 수)
2.2. Fine-tuning and Higher Resolution
ViT는 일반적으로 대규모 데이터셋으로 사전학습(pre-training) 한 뒤, 이보다 작은 다운스트림 태스크로 미세조정(fine-tuning) 하는 2단계 방식을 따른다.
Classification Head 교체
사전학습 때 class 토큰 뒤에 붙어있던 예측 head(hidden layer 1개짜리 MLP)를 제거한다.
대신 0으로 초기화된 $D \times K$ 크기의 feedforward(linear) layer를 새로 붙인다.
$D$: hidden size (패치 임베딩 차원)
$K$: 다운스트림 태스크의 클래스 개수 (예: 사전학습은 ImageNet-21k의 21,000개 클래스였지만, 미세조정 대상이 CIFAR-10이면 $K=10$)
0으로 초기화하는 이유는, 학습 초반에 head의 출력이 무작위로 요동치지 않고 사전학습된 표현($\mathbf{y} = \text{LN}(\mathbf{z}_L^0)$)을 안정적으로 활용하며 서서히 적응하도록 하기 위함이다. (?)
더 높은 해상도로 파인튜닝
선행 연구(Touvron et al., 2019; Kolesnikov et al., 2020)에 따르면, 사전학습보다 더 높은 해상도의 이미지로 파인튜닝하면 성능이 향상되는 경우가 많다 (예: 224×224로 사전학습 후 384×384 또는 512×512로 파인튜닝).
이때 패치 크기 $P$는 그대로 유지한다. 해상도 $H, W$가 커지는데 $P$가 고정이면:
\(N = \frac{HW}{P^2}\)
값이 커진다. 즉 트랜스포머의 유효 시퀀스 길이 $N$이 사전학습 때보다 늘어난다. (?)
문제: 사전학습된 Position Embedding이 더 이상 맞지 않음
ViT는 구조적으로 임의의 시퀀스 길이를 처리할 수 있다(메모리가 허용되는 한). Self-attention의 가중치 파라미터($\mathbf{U}_{qkv}$ 등)는 시퀀스 길이 $N$에 의존하는 shape을 갖지 않기 때문이다.
다만 position embedding $\mathbf{E}_{pos} \in \mathbb{R}^{(N+1)\times D}$는 사전학습 때의 시퀀스 길이에 맞춰 학습된 룩업 테이블이다. 파인튜닝에서 시퀀스 길이가 늘어나면 이 테이블의 행 개수가 부족해지고, 설령 억지로 맞춘다 해도 그 의미(어느 위치를 표현하는지)가 더 이상 유효하지 않다.
해결책: Position Embedding의 2D 보간(Interpolation)
이를 해결하기 위해, 사전학습된 position embedding들을 원본 이미지에서의 위치(2D 좌표, row/col)를 기준으로 2D 보간하여 늘어난 시퀀스 길이에 맞는 새로운 position embedding 집합을 만들어낸다.
예: 기존에 14×14 (196개)로 학습된 position embedding을, 24×24 (576개)로 보간해서 늘리는 방식.
이는 사전학습에서 학습된 “위치 간 유사도 패턴”(가까운 패치일수록 비슷한 embedding을 갖는 경향, Appendix D.4/Figure 7)을 최대한 보존하면서 크기만 확장하는 방법이다.
핵심 포인트: 유일하게 수동 주입되는 귀납적 편향
이 해상도 조정과 패치 추출(patch extraction)이, ViT에 이미지의 2D 구조에 대한 귀납적 편향(inductive bias)이 수동으로 주입되는 유일한 지점이다.
ViT는 CNN과 달리 2D 공간 구조에 대한 가정을 모델 구조 자체에 내장하지 않고, 원칙적으로 모든 공간 관계를 데이터로부터 학습하도록 설계되었다. 그런데 예외적으로 딱 두 곳에서만 사람이 “이건 2차원 이미지다”라는 사전 지식을 명시적으로 반영한다.
패치 추출 (초기 시점): 이미지를 1차원으로 늘어놓지 않고 애초에 $P \times P$ 정사각형 패치로 잘라내는 것 자체가 “이미지는 2D 격자 구조를 갖는다”는 가정을 반영한 것.
Position embedding의 2D 보간 (미세조정 시점): 새로운 위치의 embedding 값을 채울 때, 단순히 1차원 순서로 나열하는 게 아니라 원본 이미지의 2D 좌표 관계를 기준으로 보간.
이 두 지점을 제외하면, self-attention 연산이나 position embedding 학습 등 나머지 모든 부분은 2D 구조에 대한 어떠한 가정도 없이 순수하게 데이터로부터 학습된다.
-
AST: Audio Spectrogram Transformer
논문 정보
제목: AST: Audio Spectrogram Transformer
arXiv: 2104.01778
Introduction
AST(Audio Spectrogram Transformer)는 기존의 CNN-attention과 같은 hybrid model과 달리 CNN으로의 의존성 없이 purely attention-based인 모델이다.
ViT(Vision Transformer)와 유사하게 Convolution layer없이 Spectrogram을 처리하며, 사전학습된 ViT를 AST로 transfer도 가능하다.
AST의 장점은 크게 3가지로 소개된다:
뛰어난 성능: AudioSet, ESC-50과 같은 데이터셋에서 기존 SOTA모델보다 뛰어난 성능을 보임.
AST는 가변적인 길이의 데이터를 입력받을 수 있으며, 구조의 변화 없이 다양한 task에 쉽게 적용 가능.
기존 SOTA CNN모델들과 비교했을때 더 단순한 아키텍처, 적은 파라미터 수를 가지며 학습 시 더 빠르게 수렴.
Model Architecture
AST는 이름처럼 waveform대신 spectrogram을 input으로 사용한다.
먼저 t초의 waveform에 25ms Hamming window를 10ms 간격(hop size)으로 적용하여 각 프레임으로 분할한다.
Hamming window란?
오디오를 프레임 단위로 잘라서 FFT를 하면, 각 프레임의 양 끝이 뚝 잘리면서 원래 신호에 없던 성분이 생긴다. 이를 방지하기 위해 프레임에 window function를 곱해주는데, Hamming window는 그중 하나이다.
프레임의 중간은 값이 1에 가깝고 양쪽 끝으로 갈수록 0에 가까워지는 종 모양 곡선이므로, 이걸 곱하면 프레임 경계가 자연스럽게 fade out되어 잘림으로 인한 노이즈가 줄어든다.
또한 이러한 25ms의 window가 10ms씩 이동하며 생기므로 이웃한 프레임끼리 15ms씩 겹치게 된다. 이를 통해 1초당 약 100개의 프레임이 생성된다.
생성된 각 프레임에 대해 FFT를 수행한 뒤 Mel scale로 배치된 128개의 필터를 적용하고 로그를 취하면, 프레임당 128차원의 log Mel filterbank 벡터가 만들어진다. Mel scale은 사람의 청각 특성을 반영하여 저주파 쪽은 촘촘하게, 고주파 쪽은 듬성듬성 배치하는 주파수 스케일이다.
결과적으로 주파수 축 128 × 시간 축 100t 크기의 2D 스펙트로그램이 만들어지고, 이를 이미지처럼 취급하여 AST의 입력으로 사용한다.
이후 해당 2D spectrogram을 $N$개의 $16 \times 16$ 크기의 패치로 나누는데, 시간 축과 주파수 축 모두 6만큼 겹치도록(overlap) 분할한다. 이때 $N = 12 \times \lceil (100t - 16) / 10 \rceil$ 이며, 이 $N$이 곧 Transformer에 입력되는 시퀀스의 길이가 된다.
각 $16 \times 16$ 패치를 flatten한 뒤 선형 변환을 통해 768차원의 1D 임베딩으로 변환한다. 이때 사용되는 선형 변환 레이어를 Patch Embedding Layer라고 부른다.
AST는 ViT와 아키텍처가 매우 유사하다. Patch Embedding에 학습 가능한(learnable) Positional Embedding(768차원)을 더하여 각 패치의 2D 공간 정보를 인코딩한다. Transformer 자체는 입력 순서 정보를 갖고 있지 않고, 패치 또한 시간 순서대로 나열되지 않기 때문에 이 Positional Embedding이 필수적이다.
또한 시퀀스의 맨 앞에 [CLS] 토큰을 추가하며, Transformer Encoder를 거친 뒤 [CLS] 토큰의 출력이 해당 스펙트로그램의 최종 representation이 된다. 이 representation을 sigmoid 활성화 함수가 있는 Linear Layer에 통과시켜 최종 분류를 수행한다.
AST는 분류 task를 위해 설계되었기 때문에 Transformer의 Encoder만 사용하며, 별도의 구조 수정 없이 표준 Transformer Encoder를 사용한다. 구체적으로 ViT-Base와 동일한 설정(임베딩 차원 768, 12 layers, 12 heads)을 사용하는데, 이는 ImageNet pretrained ViT의 가중치를 그대로 전이하기 위함이다.
ImageNet Pretraining
ViT 논문에서 언급되었듯이, Transformer 구조가 CNN-based 모델보다 좋은 성능을 보이려면 더 많은 데이터(약 14 million 이상)가 필요하다. 하지만 오디오 데이터셋은 이만한 규모를 확보하기 어렵기 때문에, 이미지와 스펙트로그램의 유사한 형식을 활용한 cross-modality 전이학습이 필요하게 된다. 실제로 CNN-based 모델에서도 ImageNet으로 학습된 CNN 가중치를 초기값으로 사용하고 오디오 데이터로 fine-tuning하는 방식이 이전부터 쓰여 왔다.
다만 ViT와 AST가 매우 유사한 구조를 가졌더라도 완전히 동일하지 않으므로 몇가지 처리가 필요하다.
1. ViT는 RGB 3채널 입력을 받지만, AST는 single-channel 스펙트로그램을 입력으로 받는다.
따라서 ViT의 Patch Embedding Layer의 3채널 가중치를 평균하여 AST의 single-channel Patch Embedding 가중치로 사용한다. 이는 스펙트로그램을 3채널로 복제하여 입력하는 것과 스케일링 상수를 제외하면 동일하며(Layer Normalization에 의해 흡수됨), 연산 효율이 더 높다.
또한 학습 전에 입력 스펙트로그램을 데이터셋 전체 기준으로 평균 0, 표준편차 0.5로 정규화하여, pretrained ViT 가중치가 기대하는 입력 분포와 유사하게 맞춰준다.
2. 가변적인 길이의 input 처리
Transformer 구조 자체는 시퀀스 길이에 제한이 없으나, ViT가 학습한 Positional Embedding은 고정된 개수(예: $24 \times 24 = 576$개)이므로 입력 크기가 달라지면 그대로 사용할 수 없다. AST는 이 문제를 cut & bilinear interpolation으로 해결한다.
예를 들어, $384 \times 384$ 이미지를 $16 \times 16$ 패치로 나눈 ViT의 Positional Embedding은 $24 \times 24$ 형태이다. 반면 10초 오디오의 AST 패치는 $12 \times 100$ 형태이므로, ViT의 $24 \times 24$ Positional Embedding에서 주파수 축(첫 번째 차원)은 24에서 12로 잘라내고(cut), 시간 축(두 번째 차원)은 24에서 100으로 보간(bilinear interpolation)하여 $12 \times 100$ 크기로 변환한다. [CLS] 토큰의 Positional Embedding은 그대로 재사용한다.
ViT의 Positional Embedding 텐서는 (24×24,768), 다시 말해 576개의 768차원 벡터이다. 이걸 2D로 reshape하면 (24,24,768).
이를 통해 입력 크기가 다르더라도 ViT가 ImageNet 학습 과정에서 획득한 2D 공간 정보를 AST에 전이할 수 있다.
3. Classification Layer
분류 task 자체는 ViT와 완전히 다르기 때문에, ViT의 마지막 classification layer는 버리고 새로 초기화하여 학습시킨다. 이러한 adaptation 덕분에 AST는 다양한 pretrained ViT 가중치를 초기값으로 활용하면서도 오디오 분류 task를 수행할 수 있다.
해당 논문에서는 DeiT(Data-efficient Image Transformer)의 사전학습 가중치를 사용하였다. DeiT는 CNN knowledge distillation으로 학습되었으며, $384 \times 384$ 이미지 기반, 87M 파라미터, ImageNet 2012에서 85.2% top-1 accuracy를 달성한 모델이다. DeiT는 학습 시 [CLS] 토큰을 두 개 사용하는데, AST에서는 이 둘의 평균을 하나의 [CLS] 토큰으로 사용한다.
Experiments 요약
AudioSet
AST는 single model, ensemble 모두에서 기존 CNN-attention hybrid 모델(PSLA)을 능가했다.
동일한 training pipeline을 사용했으므로 성능 차이는 아키텍처의 차이에서 비롯된다.
AST는 5 epoch만에 학습이 수렴하는 반면, CNN-attention hybrid 모델은 30 epoch이 필요했다.
Ablation Study
ImageNet Pretraining: 없으면 성능이 크게 하락하며, 특히 데이터가 적을수록 pretraining의 효과가 더 크다.
Positional Embedding 전이: 랜덤 초기화보다 cut & interpolation 방식이 확실히 우수하다. 이는 ViT의 2D 공간 정보 전이가 실제로 효과가 있음을 보여준다.
Patch Overlap: overlap이 클수록 성능이 좋아지지만, 시퀀스 길이가 늘어나 연산량이 제곱으로 증가하는 trade-off가 있다. overlap 없이도 기존 SOTA를 능가한다.
Patch 크기/모양: 32x32보다 16x16이 더 좋고, $128 \times 2$ 직사각형 패치가 $16 \times 16$ 정사각형보다 scratch 학습 시 성능이 좋지만, pretrained ViT가 $16 \times 16$ 기반이므로 전이학습을 고려하면 $16 \times 16$이 최적이다. (Section 3.1.3 Table 6)
ESC-50 & Speech Commands
입력 길이가 1초(Speech Commands), 5초(ESC-50), 10초(AudioSet)로 모두 다르고 내용도 speech/non-speech로 다르지만, 동일한 AST 아키텍처로 모든 벤치마크에서 SOTA를 달성했다.
AST의 범용 오디오 분류기(generic audio classifier)로서의 가능성을 보여준다.
Conclusion
AST는 오디오 분류에서 CNN이 필수적이지 않음을 보여주었으며, 순수 attention 기반 모델만으로도 더 단순한 구조, 빠른 수렴, 그리고 SOTA 성능을 동시에 달성할 수 있음을 입증했다.
-
Andrej Karpathy - Makemore Part 4: Becoming a Backprop Ninja
원본 영상 및 자료
Youtube
Github
해당 영상에서는 역전파를 단순히 loss.backward()로 호출하는 것이 아닌, 직접 gradient를 하나하나 정의해보며 backprop의 동작을 구현한다.
Starter Code
이전과 동일한 MLP코드 위에서 진행.
block_size = 3
n_embd = 10 # the dimensionality of the character embedding vectors
n_hidden = 64 # the number of neurons in the hidden layer of the MLP
g = torch.Generator().manual_seed(2147483647) # for reproducibility
C = torch.randn((vocab_size, n_embd), generator=g)
# Layer 1
W1 = torch.randn((n_embd * block_size, n_hidden), generator=g) * (5/3)/((n_embd * block_size)**0.5)
b1 = torch.randn(n_hidden, generator=g) * 0.1 # using b1 just for fun
# Layer 2
W2 = torch.randn((n_hidden, vocab_size), generator=g) * 0.1
b2 = torch.randn(vocab_size, generator=g) * 0.1
# BatchNorm parameters
bngain = torch.randn((1, n_hidden))*0.1 + 1.0
bnbias = torch.randn((1, n_hidden))*0.1
parameters = [C, W1, b1, W2, b2, bngain, bnbias]
직접 gradient를 계산하고 PyTorch gradient와 동일한지 확인하기 위한 utility function이다.
def cmp(s, dt, t):
ex = torch.all(dt == t.grad).item()
app = torch.allclose(dt, t.grad)
maxdiff = (dt - t.grad).abs().max().item()
print(f'{s:15s} | exact: {str(ex):5s} | approximate: {str(app):5s} | maxdiff: {maxdiff}')
loss 는 cross entropy인 것은 동일한데, 메서드 하나로 선언한 것이 아닌, 각각의 연산들을 분리하여 변수로 선언하였다. 아래의 변수들의 관계를 따라가며 각각의 단계에서의 gradient를 직접 구해볼 것이다.
emb = C[Xb] # embed the characters into vectors
embcat = emb.view(emb.shape[0], -1) # concatenate the vectors
# Linear layer 1
hprebn = embcat @ W1 + b1 # hidden layer pre-activation
# BatchNorm layer
bnmeani = 1/n*hprebn.sum(0, keepdim=True)
bndiff = hprebn - bnmeani
bndiff2 = bndiff**2
bnvar = 1/(n-1)*(bndiff2).sum(0, keepdim=True)
bnvar_inv = (bnvar + 1e-5)**-0.5
bnraw = bndiff * bnvar_inv
hpreact = bngain * bnraw + bnbias
# Non-linearity
h = torch.tanh(hpreact) # hidden layer
# Linear layer 2
logits = h @ W2 + b2 # output layer
# cross entropy loss (same as F.cross_entropy(logits, Yb))
logit_maxes = logits.max(1, keepdim=True).values
norm_logits = logits - logit_maxes # subtract max for stability
counts = norm_logits.exp()
counts_sum = counts.sum(1, keepdims=True)
counts_sum_inv = counts_sum**-1
probs = counts * counts_sum_inv
logprobs = probs.log()
loss = -logprobs[range(n), Yb].mean()
1. Backproping the atomic compute graph
아래의 코드는 각각의 gradient를 전부 구한 코드이다.
dlogprobs = torch.zeros_like(logprobs)
dlogprobs[range(n), Yb] = -1.0/n
dprobs = (1.0 / probs) * dlogprobs
dcounts_sum_inv = (counts * dprobs).sum(1, keepdim=True)
dcounts = counts_sum_inv * dprobs
dcounts_sum = (-counts_sum**-2) * dcounts_sum_inv
dcounts += torch.ones_like(counts) * dcounts_sum
dnorm_logits = counts * dcounts
dlogits = dnorm_logits.clone()
dlogit_maxes = (-dnorm_logits).sum(1, keepdim=True)
dlogits += F.one_hot(logits.max(1).indices, num_classes=logits.shape[1]) * dlogit_maxes
dh = dlogits @ W2.T
dW2 = h.T @ dlogits
db2 = dlogits.sum(0)
dhpreact = (1.0 - h**2) * dh
dbngain = (bnraw * dhpreact).sum(0, keepdim=True)
dbnraw = bngain * dhpreact
dbnbias = dhpreact.sum(0, keepdim=True)
dbndiff = bnvar_inv * dbnraw
dbnvar_inv = (bndiff * dbnraw).sum(0, keepdim=True)
dbnvar = (-0.5*(bnvar + 1e-5)**-1.5) * dbnvar_inv
dbndiff2 = (1.0/(n-1))*torch.ones_like(bndiff2) * dbnvar
dbndiff += (2*bndiff) * dbndiff2
dhprebn = dbndiff.clone()
dbnmeani = (-dbndiff).sum(0)
dhprebn += 1.0/n * (torch.ones_like(hprebn) * dbnmeani)
dembcat = dhprebn @ W1.T
dW1 = embcat.T @ dhprebn
db1 = dhprebn.sum(0)
demb = dembcat.view(emb.shape)
dC = torch.zeros_like(C)
for k in range(Xb.shape[0]):
for j in range(Xb.shape[1]):
ix = Xb[k,j]
dC[ix] += demb[k,j]
기본 개념
코드에서 d로 시작하는 모든 변수는 loss를 그 변수로 편미분한 값이다.
\[\texttt{dprobs} = \frac{\partial \text{loss}}{\partial \texttt{probs}}, \qquad \texttt{dW2} = \frac{\partial \text{loss}}{\partial \texttt{W2}}\]
기준은 항상 최종 스칼라인 loss로 고정되어 있다. 중간 변수끼리의 미분(예: probs를 counts_sum_inv로 미분한 것)은 국소 미분(local derivative) 이며, 별도의 이름을 붙이지 않고 그 자리에서 곧바로 곱해 사용한다.
이 방식의 중요한 성질은, loss가 스칼라이므로, 스칼라를 텐서로 미분한 결과는 원래 텐서와 shape이 정확히 같다는 것이다.
dprobs.shape == probs.shape # 항상 참
dW2.shape == W2.shape # 항상 참
수식을 잘못 유도했는지 확인하는 1차 검증 도구 shape이 안 맞으면 무조건 틀린 것이고, 반대로 shape이 맞는 조합이 하나뿐인 경우에는 그것이 곧 정답인 경우가 많다.
연쇄법칙: 국소 미분 × upstream gradient
역전파의 모든 줄은 예외 없이 다음 형태다.
\[\underbrace{\frac{\partial \text{loss}}{\partial x}}_{\texttt{dx (구하려는 것)}} = \underbrace{\frac{\partial \text{loss}}{\partial y}}_{\texttt{dy (이미 알고 있음)}} \times \underbrace{\frac{\partial y}{\partial x}}_{\text{국소 미분}}\]
여기서 dy를 upstream gradient라고 부른다. 이미 뒤쪽에서 계산되어 흘러들어온 값이다. 우리가 각 단계에서 실제로 해야 할 일은 국소 미분을 구해서 곱하는 것뿐이고, 그래서 계산 그래프를 출력에서 입력 방향(역순)으로 올라가야 한다.
예를 들어 forward가
probs = counts * counts_sum_inv
일 때, counts_sum_inv에 대한 국소 미분은 $\partial\,\texttt{probs} / \partial\,\texttt{counts_sum_inv} = \texttt{counts}$ 이므로
dcounts_sum_inv = counts * dprobs # 국소 미분 × upstream
이 된다. (실제 코드에는 .sum(1, keepdim=True)가 더 붙으며, 자세한 내용은 이후 내용에서 다룸)
규칙 A - 여러 번 쓰인 변수는 gradient를 더한다
forward에서 한 변수가 여러 곳에 쓰였다면, loss로 가는 경로가 여러 개라는 뜻이다. 다변수 연쇄법칙에 따라 각 경로에서 흘러온 gradient를 모두 합산해야 한다.
counts가 대표적인 예다.
counts_sum = counts.sum(1, keepdims=True) # 경로 1
counts_sum_inv = counts_sum**-1
probs = counts * counts_sum_inv # 경로 2
그래서 dcounts는 두 번에 걸쳐 채워진다.
dcounts = counts_sum_inv * dprobs # 경로 2에서의 gradient
...
dcounts += torch.ones_like(counts) * dcounts_sum # 경로 1에서의 gradient
이 때문에 역전파 순서가 중요해진다. 계산 그래프의 위상 정렬(topological order)의 역순을 지켜야 한다.
규칙 B - forward의 sum과 backward의 broadcast
가장 자주 쓰이고, 알아두면 절반 이상이 자동으로 풀리는 규칙이다.
forward에 sum이 있으면 → backward에는 broadcast
forward에 broadcast(복제)가 있으면 → backward에는 sum
forward broadcast –> backward sum
probs = counts * counts_sum_inv
# (n, V) (n, 1) ← broadcast
counts_sum_inv의 값들이 열 방향(axis=1)으로 브로드캐스팅 되어 V개의 열로 복사되어 원소별로 곱해졌다. 즉 forward에서 같은 값이 V번 복제되어 쓰인 것이고, 이건 “여러 번 쓰인 변수”의 경우와 동일하게 V개 경로에서 온 gradient를 전부 더해야 한다.
dcounts_sum_inv = (counts * dprobs).sum(1, keepdim=True)
shape으로도 확인된다. counts * dprobs는 (n, V)인데 dcounts_sum_inv는 (n, 1)이어야 하므로, axis=1을 없애는 sum이 필요하다.
같은 논리가 그대로 적용되는 곳들:
db2 = dlogits.sum(0) # b2가 n개 행에 broadcast됨
dbngain = (bnraw * dhpreact).sum(0, keepdim=True)
dbnbias = dhpreact.sum(0, keepdim=True)
dbnvar_inv = (bndiff * dbnraw).sum(0, keepdim=True)
dlogit_maxes = (-dnorm_logits).sum(1, keepdim=True) # 국소미분이 -1
dbnmeani = (-dbndiff).sum(0)
keepdim은 shape을 유지해야 한다면 사용한다.
forward sum –> backward broadcast
반대의 상황이다.
counts_sum = counts.sum(1, keepdims=True)
# (n, V) → (n, 1)
counts_sum의 한 원소는 그 행의 모든 counts 원소에 대해 국소 미분이 1이다. 따라서 dcounts_sum (n,1)을 (n,V)로 그대로 펼쳐서(복제해서) 전달하면 된다.
dcounts += torch.ones_like(counts) * dcounts_sum
sum에 상수 배가 붙어 있으면 그 상수도 함께 전달한다.
# bnvar = 1/(n-1) * bndiff2.sum(0, keepdim=True)
dbndiff2 = (1.0/(n-1)) * torch.ones_like(bndiff2) * dbnvar
# bnmeani = 1/n * hprebn.sum(0, keepdim=True) 평균은 sum의 상수배로 볼 수 있음
dhprebn += 1.0/n * (torch.ones_like(hprebn) * dbnmeani)
sum과 broadcast는 서로의 전치(transpose) 관계인 선형 변환이기 때문에 이러한 규칙이 생긴 것이다.
broadcast는 $\mathbb{R}^1 \to \mathbb{R}^V$ 로 가는 선형사상이고, 그 행렬은 1로 채워진 열벡터다.
sum은 $\mathbb{R}^V \to \mathbb{R}^1$ 로 가는 선형사상이고, 그 행렬은 1로 채워진 행벡터다.
역전파는 본질적으로 야코비안의 행렬의 전치를 곱하는 연산(vector-Jacobian product) 이므로, forward에서 한쪽을 썼다면 backward에서는 그 전치인 다른 쪽이 나온 것이다.
연산 유형별 정리
(1) 인덱싱으로 값 선택
loss = -logprobs[range(n), Yb].mean()
logprobs는 (n, V)지만, loss 계산에 실제로 쓰인 건 각 행에서 정답에 해당하는 위치 하나씩이다. 쓰이지 않은 값은 loss에 아무 영향을 주지 않으므로 gradient가 0이다.
\[\text{loss} = -\frac{1}{n}\sum_{i} \texttt{logprobs}[i, Yb_i] \;\Longrightarrow\; \frac{\partial \text{loss}}{\partial \texttt{logprobs}[i, Yb_i]} = -\frac{1}{n}\]
dlogprobs = torch.zeros_like(logprobs) # 기본값은 0
dlogprobs[range(n), Yb] = -1.0/n # 뽑힌 자리에만 값을 넣음
뽑힌 위치로만 gradient를 되돌려 보낸다.
(2) 원소별 연산
log, exp, tanh처럼 원소별로 동작하는 함수는 국소 미분도 원소별이므로, 그냥 원소별 곱만 하면 된다.
dprobs = (1.0 / probs) * dlogprobs # log'(x) = 1/x
dnorm_logits = counts * dcounts # exp'(x) = exp(x) = counts
dhpreact = (1.0 - h**2) * dh # tanh'(x) = 1 - tanh(x)^2
dcounts_sum = (-counts_sum**-2) * dcounts_sum_inv # (x^-1)' = -x^-2
dbnvar = (-0.5*(bnvar + 1e-5)**-1.5) * dbnvar_inv # (x^-0.5)' = -0.5x^-1.5
(3) max
logit_maxes = logits.max(1, keepdim=True).values
norm_logits = logits - logit_maxes
max는 여러 값 중 하나만 골라서 통과시킨다. 따라서 gradient도 최댓값이었던 그 자리로만 전달되고 나머지는 0이다.
dlogit_maxes = (-dnorm_logits).sum(1, keepdim=True)
dlogits += F.one_hot(logits.max(1).indices, num_classes=logits.shape[1]) * dlogit_maxes
여기서 dlogit_maxes를 출력해보면 값이 사실상 0이다. softmax는 입력 전체를 같은 값만큼 평행이동해도 결과가 변하지 않으므로, 어떤 값을 빼든 loss가 달라지지 않기 때문이다. 수치 안정성을 위한 이 중간 과정이 loss에 영향을 주지 않는다는 걸 gradient가 직접 보여주는 case이다.
(4) 행렬곱
logits = h @ W2 + b2
# (n,V) = (n,H) @ (H,V) + (V,)
dh = dlogits @ W2.T # (n,V) @ (V,H) = (n,H)
dW2 = h.T @ dlogits # (H,n) @ (n,V) = (H,V)
db2 = dlogits.sum(0) # b2는 n개 행에 broadcast --> sum (규칙 B)
하나하나 인덱스를 풀어서 유도할 수도 있지만, shape 비교로 빠르게 답을 얻을 수 있다. dh는 h와 같은 (n,H)여야 하고, 있는 것은 dlogits (n,V)와 W2 (H,V)뿐이다. 이 둘로 (n,H)를 만드는 방법은 dlogits @ W2.T 하나뿐이다. dW2도 마찬가지로 (H,V)를 만드는 조합이 h.T @ dlogits 하나뿐이다.
(5) 임베딩 조회
emb = C[Xb]
(1)의 인덱싱으로 값 선택을 행 단위로 한 것이다. C의 한 행(하나의 문자 임베딩)은 배치 안에서 여러 번 참조될 수 있으므로, 규칙 A에 따라 그 모든 사용처의 gradient를 더해야 한다.
dC = torch.zeros_like(C)
for k in range(Xb.shape[0]):
for j in range(Xb.shape[1]):
ix = Xb[k,j]
dC[ix] += demb[k,j] # 인덱스에 해당하는 행에 gradient전파
이중 루프는 느리므로 실제로는 dC.index_add_(0, Xb.view(-1), demb.view(-1, C.shape[1])) 같은 형태로 쓴다.
요약
forward 연산
backward 연산
sum (평균 포함)
broadcast (상수배 유지)
broadcast / 복제
해당 축으로 sum
변수를 여러 곳에 사용
각 경로의 gradient를 +=로 누적
원소별 함수 $f$
$f’$ 를 원소별 곱
max
argmax 위치로 전달
인덱싱 (gather)
해당하는 원소에만 gradient전파
행렬곱 A @ B
dA = dOut @ B.T, dB = A.T @ dOut
지금 구하려는 d변수의 shape은 원래 변수와 같아야 한다.
국소 미분을 구해 upstream gradient와 곱한다.
forward에서 값이 복제되었거나 여러 번 쓰였다면, backward에서는 그만큼 합산한다.
2. cross entropy를 한 번에 미분하기
Exercise 1에서 계산 그래프를 노드 단위로 따라가며 gradient를 계산했다면, 이번에는 forward를 F.cross_entropy 한번으로 묶어 중간 노드를 거치지 않고 gradient를 loss --> logits로 바로 전달하는 것이다.
# before: 8개의 원자 연산
logit_maxes = logits.max(1, keepdim=True).values
norm_logits = logits - logit_maxes
counts = norm_logits.exp()
counts_sum = counts.sum(1, keepdims=True)
counts_sum_inv = counts_sum**-1
probs = counts * counts_sum_inv
logprobs = probs.log()
loss = -logprobs[range(n), Yb].mean()
# now: 한 줄
loss_fast = F.cross_entropy(logits, Yb)
\[\frac{\partial L}{\partial \ell_{ij}} = \frac{1}{n}\left(p_{ij} - \delta_{j, y_i}\right) \qquad\Longrightarrow\qquad \texttt{dlogits} = \frac{1}{n}\big(\texttt{probs} - \texttt{onehot}(Y)\big)\]
핵심은 $-\log(e^{\ell_y} / \sum_k e^{\ell_k}) = -\ell_y + \log\sum_k e^{\ell_k}$ 로 $\log$와 $\exp$가 상쇄된다는 점이다. 그 결과 Exercise 1에서 거쳐야 했던 max 라우팅, 역수의 미분, 브로드캐스팅 합산이 하나도 남지 않는다.
dlogits = F.softmax(logits, 1) # probs
dlogits[range(n), Yb] -= 1 # - onehot(Y)
dlogits /= n # / n
결과
$n$을 곱해 평균을 되돌려보면 softmax 확률과 정확히 같고, 정답 위치에서만 1이 빠져 있다.
F.softmax(logits, 1)[0] # [0.03, 0.11, 0.07, ...]
dlogits[0] * n # [0.03, 0.11, 0.07, ...] 인데 정답 자리만 -0.93
위치
gradient
경사하강 시 logit 변화
정답 $y$
$p_y - 1 < 0$
증가
나머지 $j$
$p_j > 0$
감소
크기에도 의미가 있다. 오답인데 확률을 많이 준 쪽일수록 더 세게 눌리고, 정답에 이미 확신이 있으면 $p_y \approx 1$ 이라 거의 건드리지 않는다. 틀린 만큼만 고친다는 것이 식에 그대로 들어 있다.
그리고 각 행의 합은 0이다. 행합이 0이라는 건 logits 한 행 전체를 같은 값만큼 밀어도 loss가 변하지 않는다는 뜻이다.
dlogits[0].sum() # 1e-9
Exercise 1에서 dlogit_maxes가 0이었던 것 - softmax는 입력 전체를 평행이동해도 결과가 변하지 않는다.
밀어 올리는 총량과 눌러 내리는 총량이 정확히 같고, 확률을 재분배할 뿐 전체를 통째로 올리거나 내리지 않는다.
이것이 F.cross_entropy가 실제 PyTorch에서 단일 커널인 이유이기도 하다. 수치적으로 안정적이고, backward에 필요한 중간 텐서가 probs 하나뿐이라 메모리도 아끼고, 커널 실행도 8번에서 1번으로 준다.
Deep Learning
· 2026-07-26
-
[RL] 1. MDP & Bellman Equation
참고한 자료
파이썬과 케라스로 배우는 강화학습 (개정판)
Github
1. Reinforcement Learning?
강화학습은 머신러닝의 한 분야로, 지도학습과 비지도학습과는 다르게 정답이나 데이터에 대해 학습하는 것이 아닌, 주어진 환경에서 에이전트(Agent)의 행동에 따른 “보상”을 통해 학습하는 머신러닝 기법이다.
에이전트가 환경에서 어떤 행동을 하면 보상이 주어지는데, 이 보상을 최대화 하는 방법을 시행착오를 겪으며 학습하는 것이다.
이러한 강화학습은 단순히 한 시점에서 단번의 결정으로 해결되는 문제가 아닌, 여러 차례에 순차적인 결정을 요구하는 문제에 적용된다. 이러한 순차적 행동 결정 문제를 정의하는데 MDP(Markov Decision Process)를 사용한다.
2. MDP(Markov Decision Process)
MDP는 순차적 행동 결정 문제를 정의하는 프레임워크로, 크게 다섯 가지 요소로 구성된다.
\[\text{MDP} = \langle S,\ A,\ R,\ P,\ \gamma \rangle\]
상태 집합 $S$
행동 집합 $A$
보상 함수 $R$
상태 변환 확률 $P$
할인율 $\gamma$
상태 (State)
\[S_t = s\]
상태는 에이전트가 관찰할 수 있는 자신의 상황이다. 환경이 그리드월드일 때 에이전트가 있는 좌표 $(x, y)$ 가 상태가 되고, 전체가 5×5라면 상태 집합은 다음과 같다.
\[S = \{(1,1), (1,2), \dots, (5,5)\}\]
주의할 점은 $S_t$ 가 확률변수라는 것이다. “시간 $t$ 에 에이전트가 어떤 상태에 있을지”는 그 전까지의 행동과 환경에 따라 달라지므로 고정된 값이 아니다. 그래서 “시간 $t$ 에 에이전트가 상태 $s$ 에 있다”는 사실은 $S_t = s$ 라고 표현한다.
행동 (Action)
\[A_t = a\]
행동은 에이전트가 어떤 상태에서 취할 수 있는 선택지다. 그리드월드의 경우에는 상하좌우 네 가지다.
\[A = \{\text{up},\ \text{down},\ \text{left},\ \text{right}\}\]
보상 함수 (Reward Function)
\[r(s, a) = E[R_{t+1} \mid S_t = s,\ A_t = a]\]
보상은 환경이 에이전트에게 주는 유일한 학습할 수 있는 정보이다. 강화학습의 목표는 시간에 따라 얻는 보상의 합을 최대로 하는 행동을 에이전트가 선택하도록 만드는 것이다.
보상함수는 시간 $t$ 일 때 상태 $S_t = s$ 에서 $A_t = a$ 행동을 했을 경우 받을 보상에 대한 기댓값 $E[\cdot]$ 이다. 보상을 계산하는 주체는 환경인데, 환경에 따라서 같은 상태에 같은 행동을 해도 다른 보상을 줄 수 있기 때문에 확률적인 환경도 표현할 수 있도록 기댓값으로 정의한다.
또한 상태와 행동은 $S_t,\ A_t$ 인 반면 보상은 $R_{t+1}$ 이다. 에이전트는 시간 $t$ 에 행동을 결정하고, 그 행동에 대한 보상은 환경으로부터 다음 시점에 전달받는다. 즉 에이전트 입장에서 보상은 “행동한 뒤에 환경으로부터 받는 것”이므로 $t+1$ 인 시점에서 에이전트가 받는 보상을 $R_{t+1}$ 으로 표현한다.
보상은 에이전트가 알고 있는 것이 아닌, 환경이 알려주는 것이다.
상태 변환 확률 (State Transition Probability)
\[P_{ss'}^a = P[S_{t+1} = s' \mid S_t = s,\ A_t = a]\]
상태 $s$ 에서 행동 $a$ 를 했을 때 다음 상태가 $s’$ 이 될 확률이다.
어떤 행동을 하였더라도 의도한대로 원하는 상태에 도달하지 못할 수도 있다. 미끄러지는 로봇, 바람이 불어 밀리는 등 상태의 변화는 환경의 불확실성, 확률적인 요인을 포함하고 있다.
이 확률 또한 에이전트는 알지 못하는 값으로, 환경의 모델(model)이라고도 부른다.
할인율 (Discount Factor)
\[\gamma \in [0, 1]\]
동일한 보상일지라도 미래에 받게 될 보상은 당장 현재 받는 보상에 비해 가치가 떨어진다. 할인율은 미래의 가치를 현재의 가치로 할인하는 비율로, 시간 $t$로부터 $k$가 지난 뒤에 받을 보상이 $R_{t+k}$라고 하면, 이것의 현재 가치는 $\gamma^{k-1} R_{t+k}$ 이다.
\[\gamma^{k-1} R_{t+k}\]
3. 정책 (Policy)
MDP가 문제를 정의했다면, 정책은 에이전트가 문제의 모든 상태에서 취할 행동 양식이다.
\[\pi(a \mid s) = P[A_t = a \mid S_t = s]\]
각 상태에서 어떤 행동을 할 확률인지를 나타내는 함수다. 그리드월드의 어떤 칸에서 정책이 $\pi(\text{up} \mid s) = 0.7,\ \pi(\text{right} \mid s) = 0.3$ 이라면, 그 칸에서는 70% 확률로 위로, 30% 확률로 오른쪽으로 간다는 뜻이다.
강화학습의 목표는 결국 누적 보상의 기댓값을 최대로 만드는 최적 정책 $\pi_*$ 를 찾는 것이다. 이를 위해 정책의 좋고 나쁨을 수치화할 기준이 필요하다. 그것이 다음에 나올 가치함수다.
4. 가치함수
에이전트는 현재 시점에서 앞으로 받을 보상을 고려해서 행동을 선택해야 한다. 이 “앞으로 받을 보상” 을 평가하는 개념이 바로 가치함수이다.
반환값 (Return)
에이전트가 시간 $t$로부터 행동을 하면서 받게 될 보상들을 할인해서 모두 더한 값이다.
\[G_t = R_{t+1} + \gamma R_{t+2} + \gamma^2 R_{t+3} + \cdots\]
보상은 행동을 한 그 다음 타임스텝에 받으므로 $R_{t+1}$에서 시작한다는 것을 유의. 에이전트가 최대화하려는 대상은 당장의 보상 $R_{t+1}$ 이 아니라 이 반환값 이다.
또한 반환값은 “실제로 환경에서 행동하며 받은 보상의 합”이다. 반환값을 구하려면 에이전트의 행동이 유한한 시간에 끝나야 하고, 이렇게 유한한 에이전트와 환경의 상호작용을 “에피소드”라고 부른다.
환경과 정책이 확률적(불확실성을 포함)이므로 같은 상태에서 시작해도 반환값은 에피소드마다 다를 수 있다.
가치함수 (Value Function)
따라서 반환값의 기댓값을 쓴다.
\[v_\pi(s) = E_\pi[G_t \mid S_t = s]\]
“정책 $\pi$ 를 따를 때, 현재시점 t의 상태 $s$ 에서 앞으로 받을 것으로 기대되는 보상들의 합”이다.
큐함수 (Q-Function)
가치함수는 상태의 가치만 알려준다. 하지만 실제로 상태를 옮기려면 에이전트는 행동을 해야 하고, 행동에 따라 보상이 달라지거나 상태 변환 확률 때문에 의도한 대로 되지 않을 수도 있다. 그래서 행동을 고르려면 “현재 상태에서 각 행동을 했을 때의 가치”를 알아야 한다. 그것이 큐함수다.
\[q_\pi(s, a) = E_\pi[G_t \mid S_t = s,\ A_t = a]\]
가치함수와 큐함수는 다음 관계로 이어져 있다. 상태의 가치는 그 상태에서 가능한 각 행동의 큐함수 $q_\pi(s,a)$ 를 그 행동을 할 확률 $\pi(a \mid s)$ 로 가중해서 모두 더한 것이다. 즉 정책에 따라 행동을 골랐을 때 큐함수의 기댓값이다.
\[v_\pi(s) = \sum_{a \in A} \pi(a \mid s)\, q_\pi(s, a)\]
실전에서 에이전트가 행동을 선택하는 기준으로는 큐함수가 훨씬 유용하다. 가치함수만 있으면 “다음 상태의 가치”를 알기 위해 환경 모델($P_{ss’}^a$)이 필요하지만, 큐함수는 상태에서 각 행동의 가치를 직접 알려주기 때문이다.
5. 벨만 방정식 (Bellman Equation)
가치함수를 정의는 했지만, 정의대로 계산하려면 무한한 미래의 보상을 전부 합쳐야 한다. 이걸 계산 가능한 형태로 바꿔주는 것이 벨만 방정식이다.
벨만 기대 방정식
반환값은 보상들의 합이었다. 여기서 $\gamma R_{t+2}$ 부터 뒤의 항들을 $\gamma$ 로 묶어보면, 묶인 괄호 안이 다시 반환값의 형태가 된다.
\[\begin{aligned}
G_t &= R_{t+1} + \gamma R_{t+2} + \gamma^2 R_{t+3} + \gamma^3 R_{t+4} + \cdots \\
&= R_{t+1} + \gamma \left( R_{t+2} + \gamma R_{t+3} + \gamma^2 R_{t+4} + \cdots \right) \\
&= R_{t+1} + \gamma G_{t+1}
\end{aligned}\]
즉 반환값은 “바로 다음에 받을 보상 + 할인된 다음 시점의 반환값” 으로 쪼갤 수 있다. 이를 가치함수에 그대로 대입하면 다음과 같다.
\[v_\pi(s) = E_\pi[R_{t+1} + \gamma G_{t+1} \mid S_t = s]\]
그런데 $G_{t+1}$ 은 에이전트가 실제로 받은 보상이 아닌, 앞으로 받을 것이라 예상하는 값이다. 그리고 “어떤 상태에서 앞으로 받을 보상의 기댓값”은 바로 가치함수의 정의다. 따라서 $G_{t+1}$ 자리를 다음 상태의 가치함수로 바꿔 쓸 수 있다.
\[v_\pi(s) = E_\pi[R_{t+1} + \gamma v_\pi(S_{t+1}) \mid S_t = s]\]
이것이 벨만 기대 방정식이다. 현재 상태의 가치함수 $v_\pi(s)$ 와 다음 상태의 가치함수 $v_\pi(S_{t+1})$ 사이의 관계를 나타낸 식으로, 미래의 보상을 전부 더하지 않고도 한 스텝 앞만 보면 가치를 계산할 수 있게 해준다.
정책과 환경을 명시적으로 풀어 쓰면 다음과 같다.
\[v_\pi(s) = \sum_{a \in A} \pi(a \mid s) \left( r(s, a) + \gamma \sum_{s' \in S} P_{ss'}^a\, v_\pi(s') \right)\]
큐함수에 대해서도 같은 형태가 성립한다.
\[q_\pi(s, a) = E_\pi[R_{t+1} + \gamma q_\pi(S_{t+1}, A_{t+1}) \mid S_t = s,\ A_t = a]\]
벨만 최적 방정식
벨만 기대 방정식은 “주어진 정책 $\pi$ 의 가치”를 계산한다. 하지만 우리가 원하는 건 최적 정책이다.
최적 정책이라는 것은 정책을 따라갔을 때 받을 보상들의 합인 가치함수가 가장 큰 정책이다. 이를 위해 먼저 최적 가치함수, 최적 큐함수를 정의한다.
\[v_*(s) = \max_{\pi} \left[ v_\pi(s) \right], \qquad q_*(s, a) = \max_{\pi} \left[ q_\pi(s, a) \right]\]
여기서 $\max_{\pi}[\cdot]$ 는 가능한 모든 정책 $\pi$ 에 대해 대괄호 안의 값 중 최댓값을 반환하는 함수다. 즉 \(v_*(s)\) 는 어떤 정책을 쓰든 상태 $s$ 에서 얻을 수 있는 가치의 최댓값이고, \(q_*(s,a)\) 는 각 $(s,a)$ 쌍마다 모든 정책을 다 넣어 봤을 때 나오는 가장 큰 큐함수의 값이다.
이 정의에는 최적 정책 $\pi_*$ 가 등장하지 않는다는 점에 유의하자. \(v_*\) 와 \(q_*\) 는 최적 정책을 몰라도 정의되는 값이고, 최적 정책은 뒤에서 이 값들로부터 만들어진다.
최적 가치함수와 최적 큐함수의 관계
최적 큐함수 \(q_*(s,a)\) 에는 “지금 행동 $a$ 를 한 뒤, 이후 모든 시점에서도 계속 최적으로 행동한다” 는 가정이 담겨 있다. 즉 미래의 최적성이 값 안에 이미 포함되어 있으므로, 현재 상태에서는 \(q_*\)값이 가장 큰 행동 하나만 고르면 된다. 이 직관을 부등식으로 확인해 보자.
(1) \(v_*(s) \le \max_a q_*(s,a)\)
임의의 정책 $\pi$ 에 대해 다음이 성립한다.
\[v_\pi(s) = \sum_{a \in A} \pi(a \mid s)\, q_\pi(s,a) \;\le\; \sum_{a \in A} \pi(a \mid s)\, q_*(s,a) \;\le\; \max_{a \in A} q_*(s,a)\]
첫 번째 부등호는 \(q_*\) 의 정의에 의해 모든 정책에 대해 \(q_\pi(s,a) \le q_*(s,a)\) 이기 때문에 성립한다. 두 번째 부등호는 확률 $\pi(a \mid s)$ 로 가중합한 값이 그 최댓값을 넘을 수 없으므로 성립한다. 이 부등식은 모든 $\pi$ 에 대해 성립하므로, 좌변에 $\max_\pi$ 를 취해도 그대로 유지된다.
(2) \(v_*(s) \ge \max_a q_*(s,a)\)
\(a^* = \arg\max_{a} q_*(s,a)\) 라 하자. 상태 $s$ 에서 $a^*$ 를 하고, 이후에도 최적으로 행동하면 그 가치는 정확히 \(q_*(s,a^*)\) 이다. \(v_*(s)\) 는 모든 정책의 가치 중 최댓값이므로, 이 정책의 가치보다 작을 수 없다.
두 부등식을 합치면 다음 성질이 유도된다.
\[v_*(s) = \max_{a \in A} q_*(s, a)\]
$v_\pi(s) = \sum_a \pi(a \mid s)\, q_\pi(s,a)$ 는 행동들에 대한 가중평균이었다. 최적에서는 평균을 내는 대신 가장 좋은 행동 하나에 확률을 몰아주므로, 가중합 자리에 $\max$ 가 들어간다.
최적 정책
이제 최적 정책은 각 상태에서 최적 큐함수 \(q_*(s,a)\) 값이 가장 큰 행동을 선택하여 만들 수 있다. 즉 \(q_*\) 를 최대로 만드는 행동은 확률 1로 선택하고, 나머지 행동은 선택하지 않는다.
\[\pi_*(a \mid s) =
\begin{cases}
1 & \text{if } a = \arg\max_{a' \in A} \left[ q_*(s, a') \right] \\
0 & \text{otherwise}
\end{cases}\]
여기서 $\arg\max_{a’ \in A}[\cdot]$ 는 대괄호 안의 값을 최대로 만드는 행동 $a’$ 를 반환한다.
\(q_*\) 의 정의에서 $\max_\pi$ 는 $(s,a)$ 쌍마다 따로 계산되므로, 원칙적으로는 상태마다 최댓값을 주는 정책이 서로 다를 수도 있다. 그러나 MDP에서는 모든 상태에서 동시에 최댓값을 달성하는 결정론적 정책이 항상 존재하며, 위처럼 \(q_*\) 에 대해 greedy하게 행동하는 정책이 바로 그것이다. 즉 “\(q_*\) 로 만든 greedy 정책을 따르면, 그 결과가 다시 \(q_*\) 를 달성한다”는 일관성이 성립한다.
주의할 점은, greedy 선택의 기준이 반드시 최적 큐함수여야 한다는 것이다. 최적이 아닌 큐함수 $q_\pi$ 는 \(q_*\) 와 달리 “현재 행동 이후에는 기존 정책 $\pi$ 를 따른다”는 가정 위에서 계산된 값이다. 따라서 $q_\pi$ 에 대해 greedy하게 행동하면 정책이 $\pi$ 보다 좋아지기는 하지만, 최적이라는 보장은 없다. 이 개선을 반복해 최적 정책에 도달하는 방법이 정책 이터레이션이다.
벨만 최적 방정식
위에서 유도한 \(v_*(s) = \max_a q_*(s,a)\) 에서 큐함수 \(q_*(s,a)\) 를 그 정의로 풀어 가치함수 형태로 고쳐 표현하면 벨만 최적 방정식이 된다. 기대 방정식에서 정책에 대한 가중합 $\sum_a \pi(a \mid s)$ 자리에 $\max_a$ 가 들어간 형태다.
\[v_*(s) = \max_{a} E\left[R_{t+1} + \gamma v_*(S_{t+1}) \mid S_t = s,\ A_t = a\right]\]
\[q_*(s, a) = E\left[R_{t+1} + \gamma \max_{a'} q_*(S_{t+1}, a') \mid S_t = s,\ A_t = a\right]\]
이 두 식의 우변에는 어떤 정책도 등장하지 않는다. 따라서 모든 정책을 나열해 $\max_\pi$ 를 직접 계산할 필요 없이, 이 방정식을 만족하는 \(v_*\) 또는 \(q_*\) 를 먼저 구한 뒤 greedy하게 행동하기만 하면 최적 정책을 얻을 수 있다.
-
Andrej Karpathy - Makemore Part 3: Activations & Gradients, BatchNorm
원본 영상 및 자료
Youtube
Github
해당 영상에서는 모델의 학습과정에서 생길 수 있는 Activation/Gradient관련 문제점과, 원활한 학습을 위해 이를 어떻게 해결하는지 소개한다.
1. 이전 내용
이전 영상에서의 MLP코드 위에서 진행된다. block_size는 3으로 동일하고, 글자들의 임베딩 공간 C는 [27, 10]의 shape으로 각 character는 10차원의 공간에서 표현된다.
또한 MLP의 첫 hidden차원이 200으로 확장되었다.
block_size = 3
n_embd = 10 # the dimensionality of the character embedding vectors
n_hidden = 200 # the number of neurons in the hidden layer of the MLP
g = torch.Generator().manual_seed(2147483647) # for reproducibility
C = torch.randn((vocab_size, n_embd), generator=g)
W1 = torch.randn((n_embd * block_size, n_hidden), generator=g)
b1 = torch.randn(n_hidden, generator=g)
W2 = torch.randn((n_hidden, vocab_size), generator=g)
b2 = torch.randn(vocab_size, generator=g)
parameters = [C, W1, W2, b1, b2]
for p in parameters:
p.requires_grad = True
Xb는 각 샘플에서의 context가 담겨있어 [batch_size, 3]의 shape을 가진다. ([0, 0, 5], [0, 5, 13]…)
이를 C[Xb]로 임베딩하면 Xb 안의 숫자들을 행 번호로 간주하여 C에서 그 행들을 그대로 복사해 새 차원을 만들어 추가한다.
최종적으로 emb의 형태는 [batch_size, 3, 10]가 되며, embcat에서 context의 임베딩을 concat하여 [batch_size, 30]이 된다.
for i in range(max_steps):
# minibatch construct
ix = torch.randint(0, Xtr.shape[0], (batch_size,), generator=g)
Xb, Yb = Xtr[ix], Ytr[ix] # batch X,Y
# forward pass
emb = C[Xb] # embed the characters into vectors
embcat = emb.view(emb.shape[0], -1) # concatenate the vectors
2. Initialization
logits
현재 코드를 기반으로 학습을 시키며 loss를 출력해보면 다음과 같다.
loss가 학습하며 정상적으로 줄어들고는 있으나, 맨 처음 loss를 보면 27 근처로 굉장히 높은 수치이다.
우리가 사용한 손실함수는 loss = F.cross_entropy(logits, Yb) 이다. batch 크기를 $B$, 정답 character를 $y_i$라 하면
\[\text{loss} = -\frac{1}{B}\sum_{i=1}^{B} \log \frac{\exp(\text{logits}_{i,\,y_i})}{\sum_{j=1}^{27}\exp(\text{logits}_{i,\,j})}\]
즉 logits를 softmax로 확률분포로 바꾼 뒤, 정답 character에 할당된 확률에 $-\log$를 취해 평균낸 값이다.
학습 초기의 상태에는, 어떤 input이 들어오든 모든 character에 대해 동등한 확률을 가지는 것이 자연스럽다. 총 27개의 character가 있으니 초기에는 어떤 character든 1/27 확률로 예측하는 상태여야 한다는 것이다.
이 경우의 loss는
\[-\log\frac{1}{27} = \log 27 \approx 3.2958\]
이 되어야 한다. 하지만 실제 초기 loss는 27 근처이고, 이는 모델이 시작부터 엉뚱한 character에 강한 확신(fake confidence)을 가지고 있다는 뜻이다.
softmax는 입력 전체에 상수를 더해도 결과가 변하지 않으므로, uniform distribution을 만들려면 logits의 각 행이 “전부 0”일 필요는 없고 “전부 같은 값”이기만 하면 된다. 하지만 랜덤 초기화로 값들이 정확히 같아지도록 만들 수는 없으므로, 대신 logits 전체를 0에 가깝게 눌러서 값들 간의 차이를 작게 만드는 방식을 택한다.
logits = h @ W2 + b2 이다. 0에 가까운 값으로 초기화를 해야하므로 randn으로 초기화된 bias(b2)를 0으로 초기화하고, W2 또한 scaling을 통해 작은 값으로 만들어 해결할 수 있다.
W2 = torch.randn((n_hidden, vocab_size), generator=g) * 0.01
b2 = torch.randn(vocab_size, generator=g) * 0
여기서 W2를 정확히 0으로 만들지 않고 0.01을 곱해 아주 작은 값만 남겨두는데, 완전한 대칭 상태를 만들지 않기 위해 약간의 entropy를 남겨두는 것이다.
그 결과 초기 loss가 3.3221로 떨어졌으며, 최종 train/val loss도 함께 떨어졌다. 즉 초기의 부자연스러운 값을 되돌리는 데 학습 cycle이 낭비되지 않고, 처음부터 실제 문제를 optimize하는 데 학습 cycle이 사용된 것이다.
hidden state
logits의 초기값은 안정화가 되었는데, logits를 구성하는 h에 대해서도 살펴볼 필요가 있다. 현재 초기 h의 분포를 확인하면 아래와 같다.
대부분의 값이 -1 또는 1에 분포해있다. 현재 forward pass코드를 보면
# forward pass
emb = C[Xb] # embed the characters into vectors
embcat = emb.view(emb.shape[0], -1) # concatenate the vectors
hpreact = embcat @ W1 + b1 # hidden layer pre-activation
h = torch.tanh(hpreact) # hidden layer
logits = h @ W2 + b2 # output layer
loss = F.cross_entropy(logits, Yb) # loss function
h는 tanh의 출력 결과인데, -1과 1에 몰려있다는 것은 입력인 hpreact가 과하게 양쪽 끝으로 퍼져 있어 tanh가 saturation 되었다는 뜻이다. hpreact의 분포를 확인해보면 대략 -15 ~ 15 범위에 퍼져 있는 것을 확인할 수 있다.
이것이 학습에 문제가 되는 이유는 backward pass에 있다. tanh의 국소 미분은
\[\frac{\partial}{\partial x}\tanh(x) = 1 - \tanh(x)^2\]
이므로, 출력값 $t = \tanh(x)$가 $\pm 1$에 가까울수록 $1 - t^2$가 0에 가까워진다.
위의 코드처럼 tanh의 값이 1이나 -1에 가까울수록 out.grad의 값과 상관없이 gradient를 0에 가깝게 만들어버린다. 즉 tanh가 gradient를 죽여버려서, 그 앞단에 연결된 W1, b1, C 등의 파라미터들이 제대로 된 역전파를 받지 못한다.
더 나아가, 특정 뉴런이 모든 batch 샘플에 대해 saturation 상태라면 그 뉴런은 어떤 입력에도 gradient를 흘려보내지 못하는 dead neuron이 된다. 이 경우 해당 뉴런은 학습 내내 갱신되지 않는다.
이를 해결하기 위해 hpreact 또한 0 주변의 값으로 초기화되도록 해야 한다. 이를 위해 이전과 같이 W1, b1을 scaling하여 완화할 수 있다.
W1 = torch.randn((n_embd * block_size, n_hidden), generator=g) * 0.2
b1 = torch.randn(n_hidden, generator=g) * 0.01
다만 이 0.2라는 값은 분포를 눈으로 확인하며 찾은 임의의 수치이다. 이 scaling 값을 정하는데 비교적 공식화된 방법이 존재한다.
Kaiming He Initialization
아래의 코드처럼, 정규분포 $N(0,1)$을 따르는 x와 w가 있을 때, y = x @ w 라면 y에서 평균은 0 근처로 유지가 되지만, 표준편차가 커진다.
이는 matmul이 fan_in개의 항을 더하는 연산이기 때문이다. 출력의 한 원소는
\[y_j = \sum_{i=1}^{n_{in}} x_i w_{ij}\]
이다. 일반적으로 합의 분산은 각 항의 분산에 공분산 항이 더해지지만, 가중치 $w_{ij}$들이 서로 독립이고 평균이 0이므로 서로 다른 항 사이의 공분산이 0이 되어 분산이 단순히 더해진다.
\[\mathrm{Cov}(x_i w_{ij},\, x_k w_{kj}) = \mathbb{E}[x_i x_k]\cdot\mathbb{E}[w_{ij}w_{kj}] = 0 \quad (i \neq k)\]
각 항의 분산은 $x$와 $w$가 독립이므로 $\mathbb{E}[x^2]\,\mathbb{E}[w^2]$이고, 둘 다 평균이 0이면 이는 $\mathrm{Var}(x)\mathrm{Var}(w)$와 같다. 따라서
\[\mathrm{Var}(y_j) = n_{in}\cdot \mathrm{Var}(x)\cdot \mathrm{Var}(w)\]
가 된다. 즉 표준편차는 $\sqrt{n_{in}}$배만큼 커진다.
w에 scaling을 적용할 때, 1보다 큰 값을 곱하면 표준편차가 더 커지고, 1보다 작은 값을 곱하면 줄어든다. 표준편차를 1 근처로 유지하려면 커진 만큼을 그대로 되돌리면 되므로, 곱해야 할 값은
\[\frac{1}{\sqrt{n_{in}}}\]
이다. 여기서 fan_in이란 w의 input 차원, 즉 하나의 출력 뉴런이 받아들이는 입력의 개수를 말한다.
지금 예제는 bias도 없고 비선형층도 없는 단순한 linear layer여서 간단히 해결했으나, 실제 network는 더 복잡한 구조를 가지고 있을 것이고, 이러한 경우에도 결과값의 분포가 잘 유지되도록 W를 초기화해야 한다.
특히 비선형 함수가 들어오면, 예를 들어 tanh는 입력을 (-1, 1) 범위로 압축하는 함수이므로, 층을 통과할 때마다 분포가 조금씩 줄어든다. 앞서 구한 $1/\sqrt{n_{in}}$만 적용하면 층이 깊어질수록 activation이 0으로 수축해 사라지게 된다. 따라서 이 압축량을 상쇄해줄 보정 계수가 추가로 필요하고, 이를 gain이라 부른다.
이를 정리한 것이 Kaiming He Initialization이다. 원 논문은 ReLU 계열을 다룬 Delving Deep into Rectifiers (He et al., 2015)이며, 가중치의 표준편차를 다음과 같이 정한다.
\[\text{std} = \frac{\text{gain}}{\sqrt{n_{in}}}\]
PyTorch에서는 torch.nn.init.kaiming_normal_로 제공되고, gain 값은 torch.nn.init.calculate_gain(nonlinearity)로 확인할 수 있다.
nonlinearity
gain
Linear / Identity, Conv{1,2,3}D
$1$
Sigmoid
$1$
Tanh
$5/3 \approx 1.667$
ReLU
$\sqrt{2} \approx 1.414$
Leaky ReLU
$\sqrt{2 / (1 + \text{negative_slope}^2)}$
SELU
$3/4$
압축이 강한 함수일수록 gain이 커지는 것을 볼 수 있다. ReLU는 입력의 음수 절반을 통째로 0으로 만들어 분산을 대략 절반으로 줄이므로, 이를 되돌리기 위해 $\sqrt{2}$를 곱한다. tanh 역시 squashing 함수이므로 1보다 큰 $5/3$이 필요하고, 반대로 Linear는 아무것도 압축하지 않으므로 gain이 1이다.
kaiming_normal_에는 mode 인자로 fan_in과 fan_out을 선택할 수 있다. fan_in은 forward pass에서 activation의 분산을 보존하고, fan_out은 backward pass에서 gradient의 분산을 보존한다. 논문에서는 둘 중 어느 쪽을 써도 무방하다고 언급하며, PyTorch의 기본값은 fan_in이다.
우리의 코드에서는 tanh가 사용되었으므로 다음과 같이 scaling을 적용할 수 있다. fan_in은 embcat의 차원인 n_embd * block_size = 30이다.
W1 = torch.randn((n_embd * block_size, n_hidden), generator=g) * (5/3)/((n_embd * block_size)**0.5)
b1 = torch.randn(n_hidden, generator=g) * 0.01
앞서 실험적으로 찾았던 0.2 대신, 이제는 $\frac{5/3}{\sqrt{30}} \approx 0.304$라는 값을 원리에 근거해 얻게 되었다.
기존에는 이러한 initialization 과정이 매우 중요했으나, 오늘날에는 residual connection, normalization layer, 그리고 RMSProp/Adam과 같은 현대적인 optimizer 덕분에 안정성이 많이 확보되어 initialization에 매우 정확한 값을 요구하지는 않는 편이다.
3. BatchNorm
앞서 hpreact의 분포가 적당한 범위에 놓이도록 초기화 시점에 W를 scaling했다. 하지만 이는 어디까지나 간접적인 방법이고, 층이 깊어질수록 이런 계산을 매번 하는 것은 까다롭다.
hpreact가 적절한 분포를 갖기를 원한다면, 초기화를 통해 우회하지 말고 그냥 직접 정규화해버리는 것이 BatchNorm의 시작점이다.
원리
수식은 다음과 같다. 각 미니배치마다 hpreact의 평균과 분산을 구해 이를 바탕으로 정규화한 뒤, scale and shift 과정을 거치는 것이다.
normalize의 epsilon은 분산이 0이 되어 0으로 나누는 경우를 방지하기 위해 추가된 아주 작은 값(PyTorch 기본값 1e-5)이다.
평균과 분산을 batch 차원(dim=0)에 대해 구한다. 200개의 hidden 뉴런 각각에 대해, batch 안의 32개 샘플이 만든 값들의 통계를 계산하여, 결과적으로 평균과 분산은 [1, n_hidden] shape이 된다.
코드로 보면 다음과 같다.
bngain = torch.ones((1, n_hidden))
bnbias = torch.zeros((1, n_hidden))
parameters = [C, W1, W2, b2, bngain, bnbias]
hpreact = bngain * (hpreact - hpreact.mean(0, keepdim=True)) / hpreact.std(0, keepdim=True) + bnbias
정규화만 하면 hpreact는 항상 평균 0, 표준편차 1로 고정되어 버린다. 이는 초기에는 바람직하지만, 학습이 진행되면서 tanh를 saturate시키는 편이 좋다고 판단하더라도 그럴 자유가 없어진다. 그래서 학습 가능한 파라미터 $\gamma$(bngain), $\beta$(bnbias)를 두어 분포를 다시 조정할 수 있게 한다. 학습 초기에는 gain = 1, bias = 0인 상태에서 출발하고, 이후 backprop을 통해 적절한 값으로 조정된다.
이러한 normalization은 보통 linear 혹은 convolution 연산 직후, 비선형성이 추가되는 activation layer 이전에 적용하여 activation의 입력값이 안정되도록 한다. (resnet구조 참고)
문제점
배치 내 샘플들이 서로 얽힌다. 원래 신경망은 하나의 입력에 대해 하나의 출력을 내는 함수여야 하는데, BatchNorm을 넣는 순간 어떤 샘플의 hidden activation이 같은 배치에 우연히 함께 뽑힌 다른 샘플들에 의해 달라진다. 동일한 입력이라도 어떤 batch에 속하느냐에 따라 결과가 바뀌는 상태가 되는 것이다.
그런데 이 버그처럼 보이는 성질이 실제로는 도움이 될 수도 있다. 매 batch마다 평균/분산이 조금씩 다르므로 activation에 일종의 noise가 주입되고, 이것이 data augmentation과 유사한 regularization 효과를 내어 overfitting을 억제한다고 한다.
이 애매한 특징으로 인해 LayerNorm, GroupNorm, InstanceNorm 등이 등장했다.
추론 단계
학습 시에는 batch 통계를 쓰면 되지만, 실제 서비스에서는 샘플 하나만 들어오는 경우가 대부분이다. 샘플이 하나뿐이면 평균과 분산을 구할 수 없다. 따라서 학습이 끝난 뒤 사용할 고정된 평균/표준편차가 필요하다.
가장 단순한 방법은 학습이 끝난 후 전체 training set을 한 번 통과시켜 통계를 계산해두는 것이다.
with torch.no_grad():
emb = C[Xtr]
embcat = emb.view(emb.shape[0], -1)
hpreact = embcat @ W1
bnmean = hpreact.mean(0, keepdim=True)
bnstd = hpreact.std(0, keepdim=True)
하지만 학습 후 별도의 단계를 두는 것은 번거롭다. 그래서 실제 구현에서는 학습 도중에 exponential moving average(EMA)로 통계를 함께 추정해 둔다.
bnmean_running = torch.zeros((1, n_hidden))
bnstd_running = torch.ones((1, n_hidden))
# 학습 루프 안에서
bnmeani = hpreact.mean(0, keepdim=True)
bnstdi = hpreact.std(0, keepdim=True)
hpreact = bngain * (hpreact - bnmeani) / bnstdi + bnbias
with torch.no_grad():
bnmean_running = 0.999 * bnmean_running + 0.001 * bnmeani
bnstd_running = 0.999 * bnstd_running + 0.001 * bnstdi
bnmeani, bnstdi는 이전과 동일하게 배치 내에서의 mean과 std역할을 하고, running값에 매번 일정 비율로 현재 mean과 std값을 누적시킨다.
즉 running 값들은 gradient descent로 학습되는 파라미터가 아니라 따로 갱신하는 값이다. 계산 후 실제 값과 비교해보면 running 추정치가 거의 일치하는 것을 확인할 수 있다.
momentum(위 코드의 0.001, PyTorch 기본값은 0.1)은 batch 크기와 함께 고려해야 한다. batch가 작아 배치별 통계가 요동칠수록 momentum을 작게 두어야 안정된다.
PyTorch BatchNorm의 momentum은 새 관측값 쪽 계수이다: running = (1 - momentum) * running + momentum * new
앞 레이어의 bias가 무의미해진다
hpreact = embcat @ W1 + b1에서 b1은 모든 샘플에 동일한 상수를 더한다. 그런데 BatchNorm은 곧바로 배치 평균을 빼버리므로, 그 상수는 평균에도 똑같이 반영되어 상쇄된다. b1은 결과에 아무 영향을 주지 못하면서 gradient만 낭비하게 된다.
실질적인 bias 역할은 bnbias($\beta$)가 대신하므로, BatchNorm 앞의 linear layer에서는 bias를 아예 제거하는 것이 맞다. (PyTorch에서도 BatchNorm 앞의 nn.Linear나 nn.Conv2d에는 관례적으로 bias=False를 준다.)
hpreact = embcat @ W1 # b1 제거
4. Diagnostics
gain값에 따른 network의 activation/grdient의 변화
linear - tanh의 반복이 5번 쌓인 네트워크에서, 적절한 gain(5/3)을 유지하니 activation의 분포가 안정되고, 1을 사용하였더니 점점 std가 줄어든다. 이는 gradient에서도 마찬가지이다.
data ratio
지금까지는 activation과 gradient의 분포를 봤는데, 학습이 적절한 속도로 진행되고 있는지는 이것만으로 알기 어렵다. gradient의 절대적인 크기는 그 자체로 의미가 없기 때문이다. 파라미터 값이 크다면 큰 gradient도 상대적으로는 미미한 변화이고, 반대로 파라미터가 작다면 작은 gradient도 큰 변화를 일으킬 수 있다.
따라서 봐야 할 것은 한 스텝의 업데이트량이 파라미터 자체의 크기 대비 얼마나 되는가이다.
\[\text{ratio} = \frac{\text{std}(\text{lr} \cdot \nabla_p)}{\text{std}(p)}\]
분자는 이번 스텝에서 실제로 파라미터가 변화하는 양이고, 분모는 파라미터 자체의 스케일이다. 텐서 전체의 대표적인 크기를 재기 위해 std를 사용한다.
학습 루프에서 다음과 같이 기록해둔다.
ud = []
# 학습 루프 안, update 직후
with torch.no_grad():
ud.append([((lr*p.grad).std() / p.data.std()).log10().item() for p in parameters])
log10을 취하는 이유는 값의 범위가 매우 넓기 때문이다. 이렇게 하면 비율 $10^{-3}$이 $-3$으로 표현된다.
plt.figure(figsize=(20, 4))
legends = []
for i, p in enumerate(parameters):
if p.ndim == 2:
plt.plot([ud[j][i] for j in range(len(ud))])
legends.append('param %d' % i)
plt.plot([0, len(ud)], [-3, -3], 'k') # 기준선
plt.legend(legends);
여기서 p.ndim == 2 조건으로 weight matrix만 그리는데, bias나 bngain 같은 1차원 파라미터는 개수가 많아 그래프가 지저분해지기 때문이다.
경험적으로 이 비율은 $10^{-3}$, 즉 로그 스케일에서 $-3$ 근처가 적절하다고 알려져 있다. 한 스텝마다 파라미터가 자기 크기의 약 0.1%씩 변한다는 뜻이다.
$-3$보다 한참 위 ($-2$, $-1$ 등): 한 번의 업데이트가 파라미터를 너무 크게 변화시킴. learning rate를 낮춰야 한다.
$-3$보다 한참 아래 ($-4$, $-5$ 등): 파라미터가 사실상 거의 갱신되지 않고 있다. 이 파라미터는 학습되고 있지 않은 것에 가까우므로 learning rate를 높여야 한다.
학습 초반 스텝은 값이 크게 요동치므로, 안정화된 구간을 기준으로 판단해야 한다.
BatchNorm을 되살리면
앞선 코드에 BatchNorm을 다시 되살린다. 각 Linear 뒤에 BatchNorm1d를 붙인다.
layers = [
Linear(n_embd * block_size, n_hidden, bias=False), BatchNorm1d(n_hidden), Tanh(),
Linear( n_hidden, n_hidden, bias=False), BatchNorm1d(n_hidden), Tanh(),
Linear( n_hidden, n_hidden, bias=False), BatchNorm1d(n_hidden), Tanh(),
Linear( n_hidden, n_hidden, bias=False), BatchNorm1d(n_hidden), Tanh(),
Linear( n_hidden, n_hidden, bias=False), BatchNorm1d(n_hidden), Tanh(),
Linear( n_hidden, vocab_size, bias=False), BatchNorm1d(vocab_size),
]
with torch.no_grad():
# last layer: make less confident
layers[-1].gamma *= 0.1
# all other layers: apply gain
for layer in layers[:-1]:
if isinstance(layer, Linear):
layer.weight *= 1.0 # 5/3이 아니라 1.0
gain이 5/3에서 1.0으로 바뀌었다. 즉 Kaiming의 tanh gain을 적용하지 않았다.
마지막 layer를 눌러 초기 confidence를 낮추는 작업이 weight *= 0.1에서 gamma *= 0.1로 바뀌었다. BatchNorm이 앞선 Linear의 weight 스케일을 무시하므로, 출력 스케일을 조절하려면 BatchNorm의 gain을 건드려야 한다.
모든 Linear가 bias=False가 되었다. 앞서 본 대로 BatchNorm 뒤에서는 bias가 상쇄되기 때문이다.
이 상태로 activation 통계를 찍어보면 다음과 같다.
layer 2 (Tanh): mean -0.00, std 0.63, saturated: 2.78%
layer 5 (Tanh): mean +0.00, std 0.64, saturated: 2.56%
layer 8 (Tanh): mean -0.00, std 0.65, saturated: 2.25%
layer 11 (Tanh): mean +0.00, std 0.65, saturated: 1.69%
layer 14 (Tanh): mean +0.00, std 0.65, saturated: 1.88%
gain을 적용하지 않았음에도 층이 깊어져도 std가 0.63~0.65로 유지되고, saturation도 2% 안팎에서 안정적이다. BatchNorm 없이 gain=1.0으로 두었을 때 층마다 activation이 수축해가던 것과 대조적이다.
이는 우연이 아니라 수학적으로 보장되는 성질이다. Linear의 가중치 $W$를 임의의 상수 $a$배 했다고 하자. 그러면 pre-activation도 $a$배가 된다.
\[\text{hpreact} \rightarrow a \cdot \text{hpreact}\]
그런데 BatchNorm은 이 값의 배치 평균과 표준편차를 구해 나눈다. 평균도 $a\mu$, 표준편차도 $a\sigma$가 되므로
\[\frac{a x - a\mu}{a\sigma} = \frac{x - \mu}{\sigma}\]
로 $a$가 정확히 소거된다. 즉 BatchNorm 앞에 있는 Linear layer의 weight 스케일은 forward pass 결과에 아무런 영향을 주지 못한다. 앞서 계산했던 gain과 $1/\sqrt{n_{in}}$은, BatchNorm이 붙는 순간 출력 분포에 관해서는 무의미해지는 것이다.
이것이 BatchNorm이 널리 쓰이게 된 가장 큰 실용적 이유다. 층이 수십, 수백 개로 깊어지면 각 층의 분포를 초기화만으로 맞추는 것은 사실상 불가능한데, BatchNorm은 그 부담을 통째로 없애준다. 초기화가 다소 어긋나 있어도 네트워크는 여전히 학습된다.
그래도 완전히 무관한 것은 아니다 (보충 설명)
영상 내용 외에 추가로 정리.
BatchNorm을 하나의 함수 $f$로 보먄, 앞서 확인한 스케일 불변성은 다음과 같이 쓸 수 있다.
\[f(az) = f(z) \qquad (a > 0)\]
양변을 $z$로 미분한다. 좌변은 연쇄법칙에 의해 안쪽 미분 $a$가 곱해지므로
\[a \cdot f'(az) = f'(z)
\quad\Longrightarrow\quad
f'(az) = \frac{1}{a} f'(z)\]
forward는 그대로인데 미분값은 $1/a$배로 줄어든다.
서로 다른 두 개의 신경망이 있다고 하자.
네트워크 A: 가중치가 $W$
네트워크 B: 가중치가 $W’ = aW$ ($W$의 모든 원소에 똑같은 숫자 $a$를 곱한 것 (예: $a=2$면 전부 2배))
두 네트워크는 가중치 숫자가 다르다. 그런데도 BatchNorm을 통과하면 출력이 같다는 것이 앞서 확인한 내용이었다.
그러면 “출력이 같으니 학습도 똑같이 되는가?” 답은 “아니다”.
역전파의 출발점은 같다
역전파는 출력 쪽에서 시작해 입력 쪽으로 거슬러 올라간다.
... → z = Wx → BatchNorm → y → 손실 L
↑
여기서 역전파 시작
$L$은 $y$와 정답만으로 계산된다. 그런데 A와 B는 $y$가 완전히 똑같으므로
손실 $L$도 똑같다
그 손실을 $y$로 미분한 값 $\partial L / \partial y$도 똑같다
$\partial L/\partial y$는 “출력을 어느 방향으로 얼마나 밀어야 손실이 줄어드는가”를 나타내는 신호다. 두 네트워크가 완전히 같은 출력을 냈으니, 받는 신호도 똑같다.
차이는 이 신호가 BatchNorm을 거슬러 통과하는 순간부터 생긴다.
BatchNorm을 통과하면 $1/a$배가 된다
이제 그 신호를 $y$에서 $z$로 옮겨야 한다. 연쇄법칙에 따라
\[\underbrace{\frac{\partial L}{\partial z}}_{\text{구하려는 것}} = \underbrace{\frac{\partial L}{\partial y}}_{\text{A, B 동일}} \times \underbrace{\frac{\partial y}{\partial z}}_{\text{여기서 차이}}\]
앞항은 방금 봤듯이 같다. 그러니 $\partial y / \partial z$, 즉 BatchNorm 자체의 미분값만 비교하면 된다.
$\partial y/\partial z$가 뜻하는 것은 “BatchNorm에 들어간 값을 변화시키면 출력이 얼마나 변하는가”이다.
A
B ($a=2$)
$z$의 표준편차
1
2
BN이 나누는 값
1
2
$z$가 $0.1$ 변화
출력이 $0.1/1 = 0.1$ 변함
출력이 $0.1/2 = 0.05$ 변함
B는 z를 같은 크기를 변화시켜도 출력이 절반밖에 안 움직인다. BatchNorm이 나누는 표준편차가 2배로 커졌기 때문이다.
“입력 변화에 덜 반응한다”가 곧 “미분값이 작다”이다. 그래서 $\partial y/\partial z$가 $1/a$배가 되고, 결과적으로
\[\frac{\partial L}{\partial z'} = \frac{1}{a}\cdot\frac{\partial L}{\partial z}\]
가 된다.
$W$를 크게 잡을수록 뒤에서 오는 학습 신호가 앞으로 전달될 때 더 많이 감쇄된다.
$z = Wx$이므로 $W$에 대한 gradient는
\[\frac{\partial L}{\partial W} = x^\top \frac{\partial L}{\partial z}\]
인데, 여기서 $x$는 A와 B가 완전히 같은 입력을 쓴다. 그러니 앞에서 $1/a$배가 된 것이 그대로 유지된다.
\[\frac{\partial L}{\partial W'} = \frac{1}{a}\cdot\frac{\partial L}{\partial W}\]
가중치는 $a$배 크고, gradient는 $1/a$배 작다. 이 둘이 합쳐져 결국 학습이 $1/a^2$만큼 느려진다.
실효 learning rate는 $1/a^2$배가 된다
앞 절의 data ratio를 다시 가져오자.
\[\text{ratio} = \frac{\|\,\text{lr}\cdot \nabla_W\,\|}{\|W\|}\]
$W$를 $a$배 했을 때 분자와 분모가 각각 어떻게 변하는지 보면:
스케일
변화
분모 (파라미터 크기)
$|aW| = a|W|$
$a$배 증가
분자 (업데이트 크기)
$\text{lr}\cdot\frac{1}{a}|\nabla_W|$
$1/a$배 감소
\[\text{ratio}' = \frac{1}{a^2}\cdot\text{ratio}\]
두 효과가 같은 방향으로 겹치기 때문에 $1/a$가 아니라 $1/a^2$이다. 초기화 스케일을 2배로 잡으면 그 layer는 실효적으로 learning rate가 1/4로 줄어든 채 학습되는 셈이고, 4배로 잡으면 1/16이 된다. forward 출력은 완전히 동일한데도 말이다.
직접 확인해보기
import torch
torch.manual_seed(0)
x = torch.randn(32, 30)
W = torch.randn(30, 100) / 30**0.5
target = torch.randn(32, 100)
bn = torch.nn.BatchNorm1d(100, affine=False)
def run(scale):
Wa = (scale * W).clone().requires_grad_(True)
y = bn(x @ Wa)
loss = 0.5 * ((y - target)**2).sum()
loss.backward()
return y.detach(), Wa.grad.norm().item(), Wa.detach().norm().item()
y0, g0, w0 = run(1.0)
for a in [0.25, 4.0, 16.0]:
y, g, w = run(a)
print(f"a={a:5.2f} forward 최대오차={(y-y0).abs().max():.2e}"
f" |grad|비={g/g0:8.4f} (1/a={1/a:.4f})"
f" ratio비={(g/w)/(g0/w0):9.5f} (1/a²={1/a**2:.5f})")
a= 0.25 forward 최대오차=5.6e-04 |grad|비= 3.9996 (1/a=4.0000) ratio비= 15.99830 (1/a²=16.00000)
a= 4.00 forward 최대오차=3.5e-05 |grad|비= 0.2500 (1/a=0.2500) ratio비= 0.06250 (1/a²=0.06250)
a=16.00 forward 최대오차=3.7e-05 |grad|비= 0.0625 (1/a=0.0625) ratio비= 0.00391 (1/a²=0.00391)
forward 출력은 (오차 $10^{-4}$ 이하로) 완전히 동일한데, gradient 크기는 정확히 $1/a$, update:data ratio는 정확히 $1/a^2$로 변한다.
forward 오차가 정확히 0이 아닌 것은 분모의 $\epsilon$ 때문이다. $\sqrt{a^2\sigma^2 + \epsilon}$는 $a\sqrt{\sigma^2+\epsilon}$와 완전히 같지 않아서, $\epsilon$이 스케일 불변성을 아주 미세하게 깨뜨린다.
정리
BatchNorm은 forward 출력에 대해서만 초기화 스케일을 완전히 지워준다.
스케일에 따라 학습 속도에 대해서는 실효 learning rate를 $1/a^2$ 비율로 변함.
BatchNorm은 W의 안정화된 분포를 만들어주지만, 각 layer가 올바른 속도로 학습되는지는 data ratio로 확인해야 한다.
Deep Learning
· 2026-07-23
-
CS231n 9강. Object Detection, Image Segmentation, Visualizing
Youtube: Stanford CS231N 2025
강의자료
컴퓨터 비전 분야의 다양한 core task를 다룬다.
1. Semantic Segmentation
이미지 분할(Image Segmentation)의 하위 분류 중 하나로, 이미지 속의 각 픽셀이 무엇인지 파악하여 같은 종류의 객체는 같은 라벨로 묶어 분할한다.
학습 시에 training data의 image에는 각 픽셀에 대해 semantic category가 레이블되어 있다. Test 단계에서는 새로운 image의 각 pixel이 어떤 클래스인지 분류하는 문제가 된다.
각 픽셀에 대해 분류를 진행하지만 같은 클래스에 속하는 서로 다른 instance(객체)를 구분하지는 않는다. 즉, 사진 속에 소가 두 마리 있어도 둘 다 똑같이 “cow” 픽셀로만 칠해질 뿐 개체 단위로 나뉘지 않는다.
Semantic Segmentation의 가장 단순한 수행 방식은 Sliding Window이다. 일정 크기의 window를 이동시키며 그 안의 context를 이용해 중심 픽셀을 분류한다. 하지만 이 방식은 window 영역이 매번 겹치게 되는데도 겹치는 영역의 feature를 공유하여 재사용하지 않으므로 매우 비효율적이다.
이를 해결하기 위해 전체 이미지를 한 번에 합성곱 레이어에 통과시키는 방식을 생각할 수 있다. 다만 분류 문제와 달리 Semantic Segmentation의 output은 input과 같은 해상도여야 하므로, 해상도를 그대로 유지한 채 깊은 Conv 연산을 쌓으면 연산량과 메모리 요구량이 매우 커진다.
Fully Convolutional Network
이로 인해 제안된 구조가, 모든 레이어를 합성곱 연산으로 구성하면서 네트워크 내부에서 downsampling과 upsampling이 일어나는 Fully Convolutional Network(FCN)이다. 앞부분에서 해상도를 줄여 연산량을 아끼고 넓은 receptive field를 확보한 뒤, 뒷부분에서 다시 원본 해상도로 복원한다.
합성곱이 큰 이미지를 작은 특성맵으로 압축했다면, upsampling은 작은 특성맵을 다시 큰 크기로 복원하는 연산을 수행한다.
Upsampling에는 다양한 방법이 존재한다. 가장 기본적인 방법으로 Nearest Neighbor, Bed of Nails, Max Unpooling이 소개되며, 이들은 모두 학습 파라미터 없이 정해진 고정된 규칙으로 upsampling을 수행한다.
이와 달리 학습 가능한 Upsampling으로 Transposed Convolution이 있다. 일반 합성곱과 동일하게 filter가 학습되는 파라미터이며, stride $> 1$인 경우 출력 해상도가 입력보다 커진다.
U-Net
U-Net은 이름처럼 구조가 U자 형태를 띄고 있으며, 왼쪽의 수축 경로(contracting path)와 오른쪽의 확장 경로(expanding path)로 구성된다.
수축 경로에서 합성곱층으로 이미지를 점점 압축하고, 확장 경로에서 upsampling으로 다시 원본 크기로 복원한다는 점은 기존 FCN과 동일하다. U-Net의 핵심 아이디어는 Skip Connection으로, 수축 경로에서 만들어진 중간 특성맵을 따로 저장해두었다가 복원 단계에서 같은 해상도의 특성맵과 결합하여 사용한다.
압축 과정에서 손실되던 세밀한 경계 정보를 복원 단계에서 다시 활용할 수 있게 되므로, 더 정밀한 분할이 가능해진다.
2. Object Detection
객체 탐지는 말 그대로 사진이나 영상에 있는 객체를 찾아내는 기술이다. 각 객체가 무엇인지 분류하는 문제와 그 객체가 어디에 있는지 위치를 찾아내는 문제를 함께 포함한다.
객체 탐지는 1-stage 방식과 2-stage 방식으로 나뉘는데, 1-stage는 분류와 위치 검출을 한 번에 수행하는 방법이고, 2-stage는 후보 영역을 먼저 뽑고 그 후에 분류를 수행하는 방법이다.
이렇듯 객체 탐지는 클래스 분류와 위치를 찾기 위한 $(x, y, w, h)$ 회귀가 합쳐진 문제이므로, 손실 함수 또한 두 손실의 가중합으로 표현된다.
\[L = L_{\text{cls}} + \lambda L_{\text{loc}}\]
여기서 $\lambda$는 두 손실 사이의 균형을 맞추는 하이퍼파라미터이다.
Region Proposal
처음에는 이미지에 객체가 몇 개 있는지 알지 못하므로, CNN 연산을 이미지의 여러 부분에 대해 반복 수행해야 한다. 이때 CNN 연산을 수행할 후보 영역(Region of Interest, RoI)을 먼저 탐색해야 하며, 이러한 후보 영역을 찾는 대표적인 알고리즘이 Selective Search(선택적 탐색)이다.
입력으로 들어온 사진을 색상, 질감, 밝기와 같은 기준으로 매우 작은 영역들로 먼저 나눈 뒤, 나누어진 영역들을 색상, 질감, 인접성 등의 기준으로 비슷한 것끼리 병합한다.
이렇게 병합된 영역들을 기반으로 객체가 있을 것으로 판단되는 후보 영역(바운딩 박스)을 얻게 되고, 이 과정을 반복하여 다양한 크기의 후보 영역을 추출한다. (후보 영역의 개수는 하이퍼파라미터로, 아래에서는 2000개가 사용된다.)
R-CNN
R-CNN은 선택적 탐색으로 먼저 후보 영역을 구하고, 그 영역들을 각각 CNN의 입력으로 전달해 객체 탐지를 수행하는 기술이다. 이전의 객체 탐지 기술은 슬라이딩 윈도우 방식으로 이미지의 모든 영역을 검사했는데, R-CNN은 객체가 있을 만한 후보 영역을 먼저 구한 뒤 그것만 CNN에 입력한다는 점이 핵심이다.
추출한 RoI에 대해 클래스 분류와 Bbox Regression을 적용하여, 해당 영역의 레이블과 함께 바운딩 박스를 어디로 얼마나 이동·확대해야 하는지 조정값을 예측한다.
R-CNN의 단점은 다음과 같다.
합성곱층에 후보 영역을 넣기 위해 각 영역을 강제로 리사이즈하는데, 이 과정에서 이미지가 왜곡되어 원본의 형태 정보가 훼손될 수 있다.
2000개의 후보 영역을 각각 독립적으로 CNN에 통과시켜야 하므로 연산량이 매우 크다.
Spatial Pyramid Pooling (공간 피라미드 풀링, SPP)
SPP는 이를 해결하기 위해 처리 순서를 바꾼다. 이전과 같이 원본 사진에서 선택적 탐색으로 RoI 좌표 2000개를 저장해두되, CNN에 통과시키는 것은 후보 영역이 아니라 원본 이미지 전체이다. 이러면 특성맵 1개가 생성되는데, 여기에 앞서 구한 RoI 좌표를 대응시킨 뒤 완전연결층에 들어가기 직전에 크기를 통일시켜 분류를 수행한다.
이 방식은 크기 조정을 합성곱층 이전이 아니라 완전연결층 직전에 수행하므로 CNN이 왜곡이 덜한 특성맵을 얻을 수 있고, 2000번이 아니라 딱 1번만 CNN을 통과시키면 되므로 훨씬 빠르고 효율적이다.
Fast R-CNN
SPP의 아이디어로 R-CNN을 개선한 방식이 Fast R-CNN이다. “원본 이미지를 CNN에 한 번만 통과시킨다”는 핵심을 그대로 유지하면서, SPP층을 RoI Pooling이라는 더 단순하고 효율적인 방식으로 교체했다. 또한 분류기로 SVM 대신 softmax를 사용하고 bbox regression까지 하나의 네트워크에서 함께 학습하도록 통합했다.
RoI Pooling: 크기가 각각 다른 후보 영역을 풀링할 때, 영역을 목표 출력 크기만큼의 격자로 나눈 뒤 각 칸마다 max pooling을 적용해 결과 크기를 통일시키는 방법.
예시: $8 \times 8$ 특성맵 위의 $7 \times 5$ 크기 RoI를 $2 \times 2$ 형태의 고정된 출력으로 만들고자 하면, 가로 방향은 stride $= 3$, 세로 방향은 stride $= 2$를 적용해 $2 \times 2$ 크기의 결과를 얻을 수 있다.
Fast R-CNN에 와서는 CNN 연산이 더 이상 병목이 아니게 되었고, 신경망 바깥에서 CPU로 돌아가는 선택적 탐색이 전체 추론 시간의 대부분을 차지하는 새로운 병목이 되었다.
Faster R-CNN
Fast R-CNN에서 한 번 더 개선된 방식이다. 지금까지 본 R-CNN, Fast R-CNN은 모두 후보 영역을 추출할 때 선택적 탐색이라는 외부 알고리즘을 신경망 바깥에서 수행했는데, Faster R-CNN은 이것을 신경망 내부의 레이어로 대체한 구조이다.
이로써 CPU 대신 GPU에서 계산이 가능해져 속도가 크게 빨라졌고, 기존에는 후보 영역 추출과 객체 분류가 별개의 과정이어서 따로따로 학습해야 했다면 이제는 두 과정을 하나의 네트워크로 통합해 End-to-End 학습이 가능한 구조가 되었다.
Region Proposal Network (RPN)
Faster R-CNN에서 후보 영역을 추출하는 레이어이다.
RPN은 CNN을 통과해서 얻은 특성맵 위에서 동작한다. 슬라이딩 윈도우 방식으로 특성맵 전체를 탐색하며, 윈도우가 멈춘 위치에서 $N \times N$ 크기의 특성맵 값을 읽어 RPN 내부의 작은 신경망에 입력으로 사용한다.
이때 윈도우 크기가 $N \times N$으로 고정되어 있으면 사진 속 다양한 크기와 비율의 객체를 수용하기 어렵다는 문제가 생긴다.
이 문제를 해결하기 위해 도입한 것이 앵커(anchor)로, 앵커는 각 윈도우 위치를 중심으로 미리 정해둔 다양한 크기와 비율의 기준 박스이다.
원본 논문에서는 크기 3종류와 비율 3종류를 조합해 위치당 총 $3 \times 3 = 9$개의 앵커를 생성했고, 이 9개의 앵커가 특성맵의 모든 위치를 중심으로 생성된다.
따라서 특성맵 크기가 $W \times H$라면 전체 앵커 수는
\[W \times H \times 9\]
개가 된다. 이렇게 다양한 크기와 비율의 앵커를 이미지 전체에 배치해서 다양한 크기와 위치의 객체를 더 수월하게 인식할 수 있다.
RPN은 각 앵커마다 두 가지를 예측한다.
첫 번째는 해당 앵커 안에 객체가 있는지 없는지를 판단하는 이진 분류이다. 이때 학습 과정에서는 IoU(Intersection over Union)라는 기준을 사용하는데, IoU는 앵커 박스 $A$와 실제 정답 박스 $B$가 얼마나 겹치는지를 $0$에서 $1$ 사이의 값으로 나타낸 것이다.
\[\text{IoU}(A, B) = \frac{|A \cap B|}{|A \cup B|}\]
Faster R-CNN 논문에서는 $\text{IoU} \ge 0.7$인 앵커를 positive(객체), $\text{IoU} < 0.3$인 앵커를 negative(배경)로 라벨링하고 negative 앵커는 학습에서 제외한다.
두 번째는 바운딩 박스 회귀이다. 앵커는 규칙에 따라 미리 정해둔 박스이기 때문에 실제 객체의 위치/크기와 정확히 맞지 않는다. 바운딩 박스 회귀는 이 앵커를 실제 객체에 맞도록 미세 조정하는 과정이다. 이때 절대 좌표를 직접 예측하는 것이 아니라, 앵커 $(x_a, y_a, w_a, h_a)$ 대비 상대적인 보정값 $t = (t_x, t_y, t_w, t_h)$를 예측한다.
\[t_x = \frac{x - x_a}{w_a}, \quad
t_y = \frac{y - y_a}{h_a}, \quad
t_w = \log \frac{w}{w_a}, \quad
t_h = \log \frac{h}{h_a}\]
중심 좌표는 앵커의 크기로 나누어 정규화하고, 너비·높이는 비율에 로그를 취해 예측한다. 이렇게 하면 객체 크기에 관계없이 예측값의 스케일이 일정하게 유지되어 학습이 안정된다.
이진 분류에서 객체로 판단된 앵커에 한해서만 이 보정을 적용하고, 배경으로 판단된 앵커는 후보 영역이 될 수 없으므로 보정 없이 버려진다. RPN의 전체 손실 함수는 다음과 같다.
\[L(\{p_i\}, \{t_i\}) = \frac{1}{N_{\text{cls}}} \sum_i L_{\text{cls}}(p_i, p_i^{*}) + \lambda \frac{1}{N_{\text{reg}}} \sum_i p_i^{*} L_{\text{reg}}(t_i, t_i^{*})\]
여기서 $p_i$는 앵커 $i$가 객체일 예측 확률, $p_i^{\ast}$ 는 정답 라벨($0$ 또는 $1$)이다. 회귀 항에 $p_i^{*}$가 곱해져 있으므로 positive 앵커에서만 회귀 손실이 발생한다.
이렇게 모든 앵커에 대해 예측이 끝나면, 점수가 높은 순으로 정렬하고 NMS(Non-Maximum Suppression)로 중복된 박스를 제거한 뒤 상위 $n$개의 후보 영역을 추출한다. 이후는 Fast R-CNN과 동일하게 RoI Pooling을 거쳐 최종 클래스 분류와 박스 보정이 이루어진다.
결과적으로 Faster R-CNN은 하나의 네트워크 안에서 RPN의 이진 분류 + 회귀, 최종 다중 클래스 분류 + 회귀로 총 4개의 손실을 함께 학습하게 된다.
Single-Stage Object Detectors: YOLO / SSD / RetinaNet
분류와 위치 검출을 한 번에 수행하는 1-stage 객체 탐지 기술이다. 별도의 region proposal 단계 없이 특성맵의 각 위치에서 곧바로 클래스와 박스를 예측하므로 훨씬 빠르지만, 일반적으로 정확도에서는 2-stage 방식에 다소 밀린다. 이후 개별 포스트에서 따로 다룰 예정이다.
Object Detection with Transformers: DETR
DETR(DEtection TRansformer)은 객체 탐지를 집합 예측(set prediction) 문제로 재정의한 모델이다. 앵커 생성이나 NMS 같은 후처리 과정을 전부 제거한 것이 핵심이다.
CNN이 이미지에서 특성맵을 추출하고, 위치 정보를 담기 위해 positional encoding을 더한다.
이를 Transformer 인코더에 넣어 이미지 전역의 관계를 파악한 표현을 얻는다.
디코더에는 학습 가능한 $N$개의 object query를 입력한다. 각 query는 “이미지 어딘가에 있을 객체 하나”를 담당하는 슬롯 역할을 한다.
각 query의 출력은 작은 FFN을 거쳐 클래스 하나 + 박스 하나로 변환된다. 객체가 없으면 “no object($\varnothing$)” 클래스를 출력한다.
학습에서는 $N$개의 예측과 실제 정답 박스들 사이를 헝가리안 알고리즘(Hungarian matching)으로 1:1 최적 매칭한 뒤, 매칭된 쌍에 대해서만 분류·박스 손실을 계산한다.
\[\hat{\sigma} = \arg\min_{\sigma \in \mathfrak{S}_N} \sum_{i=1}^{N} \mathcal{L}_{\text{match}}(y_i, \hat{y}_{\sigma(i)})\]
이 1:1 매칭 덕분에 같은 객체에 여러 박스가 중복 예측되는 일이 구조적으로 억제되며, 그 결과 NMS가 필요 없어진다. 다만 수렴이 매우 느리고 작은 객체에 대한 성능이 떨어진다는 단점이 있어, 이후 속 연구로 개선된다.
3. Instance Segmentation
픽셀 단위 레이블 분류에 더해, 같은 클래스에 속하더라도 서로 다른 객체를 개별적으로 분리하는 task이다.
Object Detection이 박스 단위로 객체를 구분하고 Semantic Segmentation이 픽셀 단위로 클래스를 나눈다면, Instance Segmentation은 두 성질을 모두 갖는다고 볼 수 있다.
Mask R-CNN
한 줄로 요약하면 Faster R-CNN에 “마스크를 그리는 브랜치” 하나를 덧붙인 것이다.
Faster R-CNN은 각 후보 영역에 대해 두 가지를 예측했다.
이건 무슨 클래스인가? (분류)
박스를 어디로 옮겨야 하나? (회귀)
Mask R-CNN은 여기에 세 번째 문제를 추가한다.
이 박스 안에서 어느 픽셀이 객체이고 어느 픽셀이 배경인가?
박스 안을 색칠하는 작업이 하나 더 붙은 것뿐이고, 나머지는 Faster R-CNN 그대로다.
전체 흐름
위 그림을 왼쪽부터 따라가면 된다.
1) CNN + RPN
입력 이미지를 백본 CNN에 통과시켜 특성맵을 만들고, RPN이 객체가 있을 만한 후보 영역을 뽑는다. Faster R-CNN과 완전히 동일하다.
2) RoIAlign
후보 영역을 특성맵에서 잘라내 고정 크기로 맞춘다. 그림에서는 채널 $256$, 크기 $14 \times 14$가 된다. Fast R-CNN의 RoI Pooling 자리를 대체한 것으로, 뒤에서 따로 설명한다.
3) 두 갈래로 분기
위쪽 (기존 헤드): 클래스 점수 $C$개와, 클래스별 박스 좌표 $4 \times C$개를 예측한다. 좌표가 클래스마다 따로 있는 이유는 “개일 때의 최적 박스”와 “고양이일 때의 최적 박스”가 다를 수 있기 때문이다.
아래쪽 (마스크 헤드): $256 \times 14 \times 14$ 특성맵에 Conv를 몇 번 더 적용한 뒤, 마지막에 크기를 두 배로 키워 $C \times 28 \times 28$ 마스크를 출력한다.
여기서 중요한 건 마스크가 클래스 개수 $C$장만큼 나온다는 점이다. 즉 “개일 경우의 마스크”, “고양이일 경우의 마스크”를 전부 그려놓는다.
왜 마스크를 클래스마다 따로 그릴까?
마스크를 한 장만 그리게 하면 그 브랜치가 “이 픽셀은 객체인가?”와 “무슨 객체인가?”를 동시에 풀어야 한다. 반면 클래스마다 한 장씩 그리게 하면, 각 마스크는 모양(shape)만 신경 쓰면 되고 클래스 판단은 위쪽 분류 헤드에 맡길 수 있다.
그래서 학습할 때도 $C$장 전부가 아니라 정답 클래스에 해당하는 한 장에 대해서만 손실을 계산한다. 나머지 $C-1$장은 그냥 무시된다. 추론할 때도 분류 헤드가 “개”라고 답하면 $C$장 중 개 마스크만 골라 쓴다.
\[L_{\text{mask}} = \text{(정답 클래스 마스크의 픽셀별 이진 분류 손실 평균)}\]
각 픽셀은 softmax가 아니라 sigmoid로 0/1을 독립적으로 판단한다. 클래스끼리 경쟁시킬 필요가 없어졌기 때문이다. 논문은 이 분리가 성능 향상에 결정적이라고 보고했다.
RoIAlign: 반올림을 없앤 RoI Pooling
Mask R-CNN의 또 다른 핵심이다. 기존 RoI Pooling에는 반올림이 두 번 들어간다.
특성맵은 원본 이미지보다 해상도가 낮다. 예를 들어 원본 이미지를 $16$배 줄였다면, 원본의 $x = 100$은 특성맵에서 $100 \div 16 = 6.25$번째 칸에 해당한다. 그런데 특성맵에는 소수점 칸이 없으니 RoI Pooling은 이걸 $6$으로 잘라버린다. 그리고 이 영역을 여러 칸으로 나눌 때 또 한 번 반올림한다.
분류에서는 이 정도 오차가 별문제가 아니다. 박스가 조금 밀려도 “개”라는 답은 바뀌지 않는다. 하지만 마스크는 픽셀 단위로 정확히 맞춰야 하는 작업이라 치명적이다. 특성맵에서 한 칸 밀린 것이 원본 이미지에서는 16픽셀이 밀린 것이기 때문이다.
RoIAlign은 이 반올림을 전부 없앤다. $6.25$번째 위치를 그대로 두고, 주변 격자값들로 보간(bilinear interpolation)해서 그 지점의 값을 만들어낸다.
6번 칸과 7번 칸 사이 $0.25$ 지점의 값이 필요하면, 6번과 7번 값을 $3:1$로 섞어서 쓴다는 뜻이다.
RoI Pooling은 칸 안의 격자값 중 최댓값을 골랐지만, RoIAlign은 칸 경계가 소수라서 그럴 수가 없다. 대신 칸을 2×2로 나눠 각 조각의 중심에 점을 4개 찍고, 각 점의 값을 주변 격자로부터 보간해 만든 뒤 평균낸다.
손실 함수
세 가지 답을 하니 손실도 세 가지를 전부 더한다.
\[L = L_{\text{cls}} + L_{\text{box}} + L_{\text{mask}}\]
추론 과정
RPN이 후보 영역을 제안한다.
분류·회귀 헤드가 클래스와 보정된 박스를 예측하고, NMS로 겹치는 박스를 정리한다.
살아남은 박스에 대해서만 마스크 브랜치를 실행한다. (학습 때와 달리 RPN 제안이 아니라 보정이 끝난 최종 박스를 쓴다.)
예측된 클래스의 $28 \times 28$ 마스크를 골라 박스 크기로 확대하고, $0.5$를 기준으로 이진화한다.
확장: Human Pose Estimation
Human Pose Estimation은 사람 사진에서 어깨, 팔꿈치, 손목, 무릎 같은 관절의 위치를 찍어내는 작업이다. 찾아낸 점들을 순서대로 이으면 막대인간 형태의 뼈대가 만들어진다.
Mask R-CNN은 이 작업을 구조를 전혀 바꾸지 않고, 마스크 브랜치가 그리는 대상만 교체해서 수행한다.
핵심 아이디어: 관절 위치를 마스크로 표현한다
보통은 “팔꿈치가 $(x, y)$에 있다”를 좌표 회귀로 풀 것 같지만, Mask R-CNN은 그렇게 하지 않는다. 대신 정답을 격자에서 팔꿈치가 있는 칸만 $1$이고 나머지는 전부 $0$인 그림(one-hot 마스크)으로 만든다. 이렇게 하면 좌표를 맞히는 회귀 문제가 “어느 칸이 정답인지 고르는 분류 문제”로 바뀐다.
그래서 왼쪽 팔꿈치용 한 장, 오른쪽 무릎용 한 장… 이런 식으로 관절마다 한 장씩 총 $K$장을 출력한다. 마스크에서 클래스마다 한 장씩 그렸던 것과 완전히 같은 패턴이고, “클래스” 자리에 “관절”이 들어갔을 뿐이다.
마스크는 픽셀마다 독립적으로 판단하면 되지만, 관절은 정답 위치가 하나뿐이므로 칸들끼리 경쟁시켜야 한다. 그래서 마스크 한 장 안의 $m^2$개 픽셀 전체에 softmax를 적용한다.
마스크
키포인트
질문
이 픽셀이 객체인가?
관절이 어느 칸에 있는가?
정답 픽셀 수
여러 개
정확히 하나
출력 함수
픽셀별 독립 sigmoid
한 장 전체에 softmax
장 수
$C$장 (클래스 수)
$K$장 (관절 수)
추론 과정
각 장에서 값이 가장 높은 칸을 하나씩 고르면 관절 $K$개의 좌표가 나오고, 어깨–팔꿈치–손목처럼 미리 정해진 연결 규칙에 따라 이으면 뼈대가 완성된다. 정확한 위치 예측이 필요한 작업이라 마스크($28 \times 28$)보다 높은 $56 \times 56$ 해상도를 사용한다.
이처럼 Mask R-CNN은 하나의 구조로 detection, instance segmentation, keypoint detection을 모두 처리할 수 있다.
강의 후반부의 Visualization & Understanding 내용은 별도 포스트에서 다룬다.
-
Andrej Karpathy - Makemore Part 2: MLP
원본 영상 및 자료
Youtube
Github
해당 영상에서는 “A Neural Probabilistic Language Model”(링크) 논문에서의 MLP를 이용하여 Character단위의 Trigram 모델을 구현한다.
위의 사진과 비슷한 구조의 MLP를 구현할 것이며, 논문에서는 가까운 단어 3개를 인풋으로 사용하지만 영상에서는 글자를 단위로 구현하며, 각 글자에 대한 임베딩을 concat하여 input으로 사용한다.
1. 데이터 로드 및 Vocabulary 구축
words = open('names.txt', 'r').read().splitlines()
chars = sorted(list(set(''.join(words))))
stoi = {s:i+1 for i,s in enumerate(chars)}
stoi['.'] = 0
itos = {i:s for s,i in stoi.items()}
''.join(words): words에 있는 모든 단어를 공백 없이 하나의 긴 문자열로 합친다.
set(…): 문자열을 set 자료형으로 만들어 중복된 character를 모두 제거
list(…) & sorted(…): 순서가 없는 set을 다시 리스트로 변환, a부터 z까지 오름차순으로 정렬한다.
이후 '.'을 0번 인덱스에 할당하고 stoi, itos를 생성.
2. 데이터셋 구축 (Sliding Window)
block_size = 3 # context length
X, Y = [], []
for w in words:
context = [0] * block_size
for ch in w + '.':
ix = stoi[ch]
X.append(context)
Y.append(ix)
context = context[1:] + [ix] # crop and append
X = torch.tensor(X)
Y = torch.tensor(Y)
각 단어 w에 대해, 처음 context는 모두 block_size만큼 . 이 반복된 형태를 가진다.
X에는 context를 할당하고, Y에는 현재 위치의 글자 인덱스를 할당한다. 이후 context = context[1:] + [ix]를 통해 맨 앞 글자는 잘라내고, 현재 [ix]를 추가하여 window를 한 칸씩 옆으로 이동시킨다.
3. Embedding Lookup Table
각 글자를 모델에 넣기 위해 인덱스에 해당하는 글자를 벡터로 변환하는 임베딩이 필요하다. 영상에서는 처음에 글자들을 모두 2차원의 벡터 공간에 담는다.
C = torch.randn((27, 2))
emb = C[X]
emb.shape # torch.Size([228146, 3, 2])
C는 글자들의 임베딩 공간이다. 알파벳 26개 + 점(.) 1개로 27개의 글자 각각의 2차원 벡터 표현을 랜덤으로 선언한 것이다.
X는 2번 과정에서 만든 텐서이다. 각 샘플에서의 context가 담겨있으므로 [데이터개수, 3]의 shape을 가진다. ([0, 0, 5], [0, 5, 13]…)
여기서 emb = C[X]를 하게 되면, X 안에 적힌 숫자들을 행 번호로 간주하여 C에서 그 행들을 그대로 복사해 새 차원을 만들어 추가한다.
예를 들어 X 안에 [0, 5, 13]이 들어있다면 C의 0번째, 5번째, 13번째 행을 가져와 마지막 차원에 추가한다. 인덱스 하나가 들어있던 자리가 2차원 벡터로 바뀐 것이다.
최종적으로 emb의 형태는 [데이터개수, 3, 2]가 되며, 이러한 방식은 적은 연산량으로 효과적으로 범주형 변수를 벡터로 변환하는 유용한 인덱싱 방식이다.
4. 모델 구조 및 Loss (Cross Entropy)
W1 = torch.randn((6, 100))
b1 = torch.randn(100)
h = torch.tanh(emb.view(-1, 6) @ W1 + b1)
W2 = torch.randn((100, 27))
b2 = torch.randn(27)
logits = h @ W2 + b2
emb.view(-1, 6)을 통해 3차원 형태였던 임베딩을 2차원(글자 3개 $\times$ 2차원 = 6)으로 바꾼 뒤, Linear Layer 두 개와 tanh 활성화 함수를 통과시킨다. logits의 shape은 [데이터개수, 27]로, 각각의 sample에서 다음 character의 확률을 예측해내야 한다. 이를 위해 softmax를 사용한다.
counts = logits.exp()
prob = counts / counts.sum(1, keepdims=True)
이후 모델의 예측 확률(prob)과 실제 정답(Y) 간의 오차를 구하기 위해 Cross Entropy를 사용한다.
loss = -prob[torch.arange({데이터 개수}), Y].log().mean()
torch.arange({데이터 개수})를 통해 행 번호를 지정하고, Y를 통해 열 번호를 지정하여 정답 확률을 가져온다. 그 후 로그를 씌워 평균을 낸 후 음수를 취한다. (Negative Log Likelihood, NLL)
위 방식은 수학적으로는 완벽하지만, 파이썬 코드상으로는 치명적인 약점이 있다. 만약 logits에 100, 1000처럼 큰 값이 들어갈 경우, logits.exp()를 계산하는 과정에서 값이 무한대(inf)로 폭발하여 정상적인 계산이 불가능해진다(NaN 발생).
따라서 실제 Softmax 연산을 구현할 때는 수치적 안정성을 위해 배열 내 최댓값(max)을 모든 값에서 빼주는 방식으로 해결한다. 지수 함수의 특성 상 분모와 분자에 동일한 값을 곱해 상쇄시키는 원리다.
\[p_i = \frac{e^{x_i}}{\sum_{j} e^{x_j}} = \frac{e^{x_i} \cdot e^{-M}}{\sum_{j} (e^{x_j} \cdot e^{-M})} = \frac{e^{x_i - M}}{\sum_{j} e^{x_j - M}} \quad (\text{단, } M = \max(x))\]
또한, 파이토치에서 제공하는 내장 함수인 F.cross_entropy를 사용하면 이 모든 과정을 한 번에 처리할 수 있다. 안정성 처리가 된 Softmax 연산이 포함되어 있는 손실함수이다(Softmax + Log + NLLLoss).
logits = h @ W2 + b2
loss = F.cross_entropy(logits, Ytr[ix])
5. 최적의 학습률 찾기
lre = torch.linspace(-3, 0, 1000)
lrs = 10**lre
# 학습 루프 내부
...
lr = lrs[i]
for p in parameters:
p.data += -lr * p.grad
학습 루프를 돌면서 학습률을 작은 값($10^{-3}$)부터 큰 값($10^0$)까지 서서히 증가시키며 Loss의 변화를 추적하는 기법이다.
미니배치가 매번 바뀌기 때문에 Loss가 위아래로 흔들리는 노이즈는 분명 발생한다. 하지만 학습률이 변하면서 Loss에 미치는 영향력이 훨씬 커서, 그래프를 그려보면 Loss가 가장 가파르게 떨어지는 구간이 눈에 띄게 나타난다. 그 가파른 구간의 학습률(영상에서는 0.1 부근)을 최적의 초기 학습률로 채택한다.
Deep Learning
· 2026-07-18
-
CS231n 8강. Attention and Transformers
Youtube: Stanford CS231N 2025
강의자료
추가 참고자료: 위키독스: 딥 러닝 파이토치 교과서 - 입문부터 LLM 파인튜닝까지
1. Seq2Seq
Seq2Seq(Sequence-to-Sequence)은 크게 인코더(Encoder)와 디코더(Decoder)로 구성된 RNN 기반 아키텍처이다.
인코더는 현재 시점의 입력 $x_t$와 이전 시점의 은닉 상태 $h_{t-1}$을 받아 다음 은닉 상태 $h_t$를 계산한다.
\[h_t = f(x_t, h_{t-1})\]
이 은닉 상태 $h_t$는 시점 $t$까지의 입력 정보를 누적하여 담고 있는 벡터이다. 인코더의 마지막 시점의 은닉 상태를 컨텍스트 벡터(context vector) $c$라고 부르며, 이를 디코더의 첫 은닉 상태로 그대로 사용하거나 초기 은닉 상태를 계산하는 데 이용한다.
디코더는 이전 시점의 은닉 상태 $s_{t-1}$, 이전 시점의 예측값(출력 토큰) $y_{t-1}$, 그리고 컨텍스트 벡터 $c$를 이용하여 매 시점마다 예측값을 출력한다.
\[s_t = g(y_{t-1}, s_{t-1}, c)\]
슬라이드와 같이 디코더는 매 시점마다 단어를 하나씩 순차적으로 번역해나가는 방식으로 동작한다.
하지만 이러한 작동 방식에는 한계가 존재한다. 문장의 길이가 매우 길거나 입력이 여러 문장으로 이루어진 글일 경우, 모든 정보를 고정된 크기의 컨텍스트 벡터 $c$ 하나에 압축해서 담는 것은 매우 어렵다. 특히 입력 시퀀스의 앞쪽 정보일수록 인코더를 거치는 동안 점점 손실되는 장기 의존성 문제(long-term dependency problem)가 발생한다.
이 문제를 완화하기 위해 제안된 방식이 바로 Attention이다.
2. Attention
Attention은 디코더가 하나의 고정된 컨텍스트 벡터에만 의존하는 대신, 인코더의 모든 시점의 은닉 상태를 매 예측 시점마다 다시 참고할 수 있도록 하는 방식이다. 이때 모든 은닉 상태를 동일한 비율로 참고하는 것이 아니라, 해당 시점에 필요한 정보에 더 높은 비중을 두어 집중(attend)하여 참고한다.
작동 과정
인코더의 동작은 Seq2Seq과 동일하다고 가정하고, 디코더의 첫 은닉 상태 $s_0$이 있다고 가정하자.
먼저 Alignment Score(Attention Score라고도 불림)를 구한다. 이는 인코더의 각 은닉 상태가 디코더의 현재 시점 은닉 상태와 얼마나 유사한지를 나타내는 값이다. 인코더의 각 은닉 상태 $h_i$를 디코더의 은닉 상태 $s_0$과 내적하여 시점마다 스칼라 값을 구한다.
\[e_{1,i} = h_i \cdot s_0\]
이렇게 구한 스코어를 softmax에 통과시켜, 0~1 사이의 값을 가지며 합이 1인 확률 분포 형태의 Attention 가중치 $a_{1,i}$를 얻는다.
\[a_{1,i} = \frac{\exp(e_{1,i})}{\sum_j \exp(e_{1,j})}\]
이후 컨텍스트 벡터 $c_1$을 구한다. 모든 인코더 은닉 상태 $h_i$를 방금 구한 Attention 가중치 $a_{1,i}$로 가중합하여 구한다.
\[c_1 = \sum_i a_{1,i} h_i\]
이제 디코더는 이전 시점의 은닉 상태 $s_{t-1}$, 이전 시점의 예측값(출력 토큰) $y_{t-1}$, 그리고 현재 시점의 컨텍스트 벡터 $c_t$를 이용하여 매 시점마다 예측값을 출력한다. Seq2Seq과의 차이점은, 컨텍스트 벡터를 매 시점 새롭게 계산하여 사용한다는 점이다.
이 과정을 반복하면 매 시점 동일한 컨텍스트 벡터를 사용하는 대신, 입력에서 그때그때 연관된 정보를 참고하여 더 좋은 성능으로 예측을 수행할 수 있다. 이로써 입력 시퀀스 전체를 하나의 고정된 벡터로 압축해야 했던 bottleneck이 사라졌고, 매 시점마다 입력 시퀀스의 다른 부분에 더 집중할 수 있게 되면서 예측 성능이 향상되었다.
아래 슬라이드를 보면, 영어를 프랑스어로 번역하는 작업을 수행할 때 더 연관된 단어에 높은 가중치가 할당된 것을 시각적으로 확인할 수 있다.
이때 어떤 단어가 더 연관이 있고 어떤 정보를 더 참조해야 하는지는 인간이 직접 지시하는 것이 아니라, 모델이 역전파를 통해 직접 optimize되며 학습한 결과이다. 즉 위의 Attention 과정은 모두 미분 가능한(differentiable) 연산으로 구성되어 있다.
Query, Key, Value
위에서 살펴본 attention 과정을 조금 더 일반화된 형태로 표현하면, Query, Key, Value라는 세 종류의 벡터를 이용해 정리할 수 있다. 이 표현 방식은 이후 self-attention과 Transformer의 핵심 구성 요소가 되므로 중요하다.
앞서 구한 attention 과정을 다시 보면, 두 가지 역할이 섞여 있다는 것을 알 수 있다.
디코더의 은닉 상태 $s_{t-1}$은 “지금 무엇을 찾고 싶은가”에 대한 질의 역할을 한다. –> Query
인코더의 은닉 상태 $h_i$는 Query와의 유사도를 계산하는 데 사용되는 동시에(–> Key), 실제로 가중합되어 컨텍스트 벡터를 구성하는 데도 사용된다(–> Value).
즉, 위 방식에서는 $h_i$가 Key와 Value 역할을 동시에 수행하고 있었던 것이다. 이를 일반화하면, 하나의 입력 벡터로부터 서로 다른 선형 변환(Matrix Multiply)을 통해 Query, Key, Value를 각각 만들어낼 수 있다.
Attention Layer
\[Q = XW_Q, \quad K = XW_K, \quad V = XW_V\]
여기서 $X$는 입력 벡터(들의 집합), $W_Q, W_K, W_V$는 각각 Query, Key, Value를 만들기 위해 학습되는 가중치 행렬이다. Key와 Value를 분리함으로써 “어떤 기준으로 유사도를 비교할지”(Key)와 “실제로 어떤 정보를 가져올지”(Value)를 독립적으로 학습할 수 있게 된다.
행렬의 shape을 구체적으로 살펴보면 다음과 같다.
입력 시퀀스에 $N$개의 벡터(토큰)가 있고, 각 입력 벡터의 차원이 $D$라고 하자. 즉 $X$의 shape은 $(N, D)$이며, $X$의 각 행(row)이 하나의 토큰에 대응한다.
$W_Q, W_K$: shape $(D, D_Q)$ → $Q, K$의 shape은 $(N, D_Q)$
$W_V$: shape $(D, D_V)$ → $V$의 shape은 $(N, D_V)$
$Q$와 $K$는 이후 내적을 해야 하므로 반드시 같은 차원 $D_Q$를 가져야 한다. 반면 $V$는 내적 연산에 참여하지 않고 가중합에만 쓰이기 때문에 이론적으로는 $Q, K$와 다른 차원 $D_V$를 가져도 무방하다. 다만 실제 구현에서는 연산이 편리하도록 $D_Q = D_K = D_V$로 동일하게 설정하는 것이 일반적이다.
$Q$의 $i$번째 행: $i$번째 토큰이 “무엇을 찾고 싶은가”를 나타내는 쿼리 벡터
$K$의 $j$번째 행: $j$번째 토큰의 정보를 드러내는 키 벡터
$V$의 $j$번째 행: $j$번째 토큰이 실제로 “전달할 정보”를 담은 값 벡터
전체 계산 과정은 다음과 같이 정리된다.
1. 유사도 계산
Query와 모든 Key 사이의 유사도를 내적으로 구한다.
\[\text{score} = \frac{QK^T}{\sqrt{D_Q}}\]
$Q$의 shape은 $(N, D_Q)$, $K^T$의 shape은 $(D_Q, N)$이므로, $QK^T$의 shape은 $(N, N)$이 된다. 이 행렬의 $(i, j)$ 원소는 $i$번째 토큰의 Query와 $j$번째 토큰의 Key 사이의 유사도를 의미한다. 즉,
행(row) $i$: $i$번째 토큰(쿼리)이 나머지 모든 토큰(키)과 얼마나 유사한지를 담은 벡터
열(column) $j$: $j$번째 토큰(키)이 나머지 모든 토큰(쿼리)으로부터 얼마나 주목받는지를 담은 벡터
벡터의 차원 $D_Q$가 커질수록 내적 값의 분산이 커져 softmax의 gradient가 매우 작아지는 문제가 있어, 이를 방지하기 위해 $\sqrt{D_Q}$로 나누어 스케일링한다. 이를 Scaled Dot-Product Attention이라 한다.
2. 가중치 계산 (softmax)
\[A = \text{softmax}\left(\frac{QK^T}{\sqrt{D_Q}}\right)\]
여기서 핵심은 softmax를 행(row) 방향, 즉 axis=1로 적용한다는 점이다. $(N, N)$ 행렬에서 행 $i$는 “토큰 $i$가 쿼리일 때, 모든 토큰(키) $j = 1, \dots, N$에 대해 계산한 유사도”를 담고 있다. 이 행에 softmax를 적용하면, 하나의 쿼리 $i$가 나머지 모든 키들에 나눠주는 가중치의 합이 1이 되도록 정규화된다.
\[A_{i,j} = \frac{\exp(\text{score}_{i,j})}{\sum_{k=1}^{N} \exp(\text{score}_{i,k})}\]
만약 axis=0(열 방향)으로 softmax를 적용한다면 이는 “하나의 키가 모든 쿼리들로부터 받는 관심의 합이 1”이 되도록 정규화하는 것이 되어 의미가 완전히 달라진다. 우리가 원하는 것은 “각 쿼리가 자신의 관심을 여러 키에 어떻게 분배하는가”이므로, 반드시 행(row) 방향, axis=1로 계산해야 한다.
3. 출력 계산
이 가중치로 Value들을 가중합하여 최종 출력을 구한다.
\[\text{Attention}(Q, K, V) = AV = \text{softmax}\left(\frac{QK^T}{\sqrt{D_Q}}\right)V\]
$A$의 shape은 $(N, N)$, $V$의 shape은 $(N, D_V)$이므로 $AV$의 shape은 $(N, D_V)$가 된다. 즉 출력의 $i$번째 행은, $i$번째 토큰(쿼리)이 자신의 attention 가중치(행 $i$)로 모든 토큰의 Value 벡터를 가중합한 결과이다.
\[\text{output}_i = AV_i = \sum_{j=1}^{N} A_{i,j} V_j\]
여기서 $V_j$는 $V$의 $j$번째 행, 즉 $j$번째 토큰의 value 벡터 전체를 가리킨다 ($X, Q, K$와 마찬가지로 $V$도 “행 = 토큰”). $A$의 열 인덱스 $j$와 $V$의 행 인덱스 $j$가 정확히 같은 토큰을 가리키기 때문에, 별도의 transpose 없이 $AV$가 그대로 계산된다.
Note. $AV$를 실제로 계산할 때는 $D_V$개의 열에 대해 각각 $AV_{:,k} = \sum_j A_{i,j}V_{j,k}$ 형태로 (병렬로) 계산되는데, 열 하나만 떼어보면 “모든 토큰의 $k$번째 feature 값들”일 뿐 특정 토큰의 정보가 아니다. 하지만 모든 열에 걸쳐 동일한 가중치 $A_{i,j}$가 재사용되므로, $D_V$개 차원별 계산 결과를 이어붙이면 $\sum_j A_{i,j}V_j$, 즉 토큰 단위로 value 벡터 전체를 가중합한 것과 정확히 같아진다.
입력이 $N$개의 벡터였던 것처럼, 출력 역시 $N$개의 벡터(shape $(N, D_V)$)로, 입력 토큰 개수는 그대로 유지되면서 각 토큰의 표현이 attention을 통해 재구성된다.
이것이 기본적인 하나의 Attention Layer를 이룬다.
Cross-Attention
Cross-Attention은 글의 맨 처음에 다뤘던 Seq2Seq + Attention 구조와 동일한 구조로, 디코더의 은닉 상태 $s_{t-1}$이 Query로, 인코더의 은닉 상태들 $h_i$가 Key/Value로 사용되었던 과정을, Q/K/V 프레임워크로 다시 표현한 것이 바로 cross-attention이다. (Query와 Key&Value의 출처가 동일하지 않음)
\[Q = X_1 W_Q, \quad K = X_2 W_K, \quad V = X_2 W_V\]
Query: 정보를 요청하는 쪽의 $X_1$으로부터 계산
Key, Value: 정보를 제공하는 쪽의 $X_2$로부터 계산
Self-Attention
Self-Attention은 Query, Key, Value가 모두 동일한 입력으로부터 계산되는 경우이다.
\[Q = XW_Q, \quad K = XW_K, \quad V = XW_V\]
앞서 정리한 Attention Layer의 수식 자체는 동일하고, $Q, K, V$가 모두 같은 입력 $X$로부터 계산된다는 점이 핵심이다.
병렬 처리 가능 (RNN과의 결정적 차이): RNN 기반 Seq2Seq에서는 $h_t$를 구하기 위해 반드시 $h_{t-1}$이 먼저 계산되어야 했으므로, 시퀀스를 순차적으로만 처리할 수 있었다. 반면 self-attention은 $QK^T$ 연산이 하나의 행렬곱으로 이루어지므로, 시퀀스 내 모든 토큰에 대한 attention을 한 번에(병렬로) 계산할 수 있다.
Permutation Equivariant: self-attention 연산 자체는 입력 토큰의 순서를 구분할 수 있는 어떤 정보도 담고 있지 않다. 입력 토큰들의 순서를 뒤섞으면(permute), 출력 역시 동일하게 순서만 뒤섞인 채로 나온다. 하지만 자연어처럼 순서가 의미에 중요한 영향을 미치는 데이터를 다룰 때 이는 문제가 되므로, Transformer는 각 토큰에 positional encoding을 더해준다.
Masked Self-Attention: 디코더에서 self-attention을 사용할 경우, 아직 생성되지 않은 미래 시점의 토큰 정보를 미리 참고해버리면 문제가 된다. 이를 방지하기 위해 attention score 행렬에서 미래 위치에 해당하는 원소를 $-\infty$로 마스킹한 뒤 softmax를 적용하여, 해당 위치의 가중치가 0이 되도록 만든다. 이를 masked self-attention이라 하며, Transformer 디코더의 핵심 구성 요소이다.
Multi-Head Attention
지금까지 살펴본 attention layer는, 입력을 $D_Q$차원의 Q, K, V벡터로 선형변환해서 어텐션을 수행하였다. 그런데 문장 안에는 동시에 여러 종류의 관계가 존재한다(인접한 단어 관계, 주어-동사 관계, 의미적으로 먼 관계). Attention layer가 단 하나의 Q, K, V만 사용한다면, 이런 다양한 종류의 관계를 전부 하나의 유사도 계산에 넣어야 해서 표현력이 제한된다.
Multi-Head Attention은 하나의 큰 attention을 계산하는 대신 여러 개의 작은 attention을 독립적으로 병렬 계산한 뒤 합치는 방식으로 해결한다. 각각의 작은 attention을 head라고 부른다.
계산 과정
head의 개수를 $h$라 하자. $i$번째 head는 자신만의 가중치 행렬 $W_Q^{(i)}, W_K^{(i)}, W_V^{(i)}$를 가지고, 입력 $X$ (shape $(N, D)$)로부터 자신만의 $Q^{(i)}, K^{(i)}, V^{(i)}$를 계산한다.
\[Q^{(i)} = XW_Q^{(i)}, \quad K^{(i)} = XW_K^{(i)}, \quad V^{(i)} = XW_V^{(i)}\]
이때 각 head는 전체 차원 $D_Q$를 $h$등분한 $D_Q / h$ 차원을 가진다. 즉 $W_Q^{(i)}, W_K^{(i)}$의 shape은 $(D, D_Q/h)$, $W_V^{(i)}$의 shape은 $(D, D_V/h)$이며, 각 head의 $Q^{(i)}, K^{(i)}$는 shape $(N, D_Q/h)$, $V^{(i)}$는 shape $(N, D_V/h)$를 갖는다. (전체 $h$개 head의 차원을 모두 합치면 원래의 $D_Q$, $D_V$가 된다.)
각 head는 앞서 정의한 attention 연산을 독립적으로 그대로 수행한다.
\[\text{head}_i = \text{Attention}(Q^{(i)}, K^{(i)}, V^{(i)}) = \text{softmax}\left(\frac{Q^{(i)}{K^{(i)}}^T}{\sqrt{D_Q/h}}\right)V^{(i)}\]
이 $h$개의 head 출력을 마지막 축(feature 축)을 따라 이어붙이면(concatenate) shape $(N, D_V)$가 되고, 여기에 최종적으로 학습 가능한 행렬 $W_O$ (shape $(D_V, D_{out})$)를 곱해 최종 결과물을 얻는다.
\[\text{MultiHead}(Q, K, V) = \text{Concat}(\text{head}_1, \dots, \text{head}_h)\,W_O\]
3. Transformer
Transformer는 (Multi-Head Self-Attention + MLP)로 구성된 하나의 블록을 반복해서 쌓아 만든 아키텍처이다. Attention이 토큰들 사이에서 정보를 주고받는 역할을 한다면, MLP는 각 토큰이 받은 정보를 개별적으로 가공하는 역할을 한다.
3.1 Transformer Block
하나의 Transformer block은 다음 네 가지 구성 요소로 이루어진다.
Multi-Head Self-Attention: 토큰들 사이에서 정보를 교환한다. (지금까지 다룬 유일하게 토큰 간 상호작용이 일어나는 연산이다.)
Residual Connection: 입력을 sub-layer의 출력에 더해준다.
Layer Normalization (Norm): 각 토큰 벡터를 정규화한다.
MLP (Feed-Forward Network): 각 토큰에 독립적으로 적용되는 두 개의 linear layer
이를 수식으로 정리하면 다음과 같다. 입력 $X$ (shape $(N, D)$)에 대해:
\[X' = \text{LayerNorm}\big(X + \text{MultiHeadSelfAttention}(X)\big)\]
\[Z = \text{LayerNorm}\big(X' + \text{MLP}(X')\big)\]
Residual Connection: Transformer block을 매우 깊게(수십~수백 층) 쌓다 보면, 역전파 과정에서 gradient가 소실되거나 최적화가 어려워지는 문제가 발생한다. Residual connection은 입력을 그대로 다음 층까지 우회 경로(shortcut)로 전달해, gradient가 손실 없이 앞쪽 층까지 흘러갈 수 있게 해준다.
Layer Normalization: 학습을 안정화하고 각 층의 입력 분포를 일정하게 유지하기 위함이다. 각 토큰 벡터 하나마다 feature 차원 $D$ 방향으로 평균과 분산을 구해 정규화한다. 즉 토큰 하나하나가 독립적으로 정규화되므로, 시퀀스 길이나 배치 크기에 영향받지 않는다. ((seq_len, embedding_dim) 행렬이 있으면, 각 행(row) - 즉 토큰 하나마다 embedding_dim 차원 방향으로 평균/분산을 구해서 정규화)
MLP (Feed-Forward Network): 시퀀스의 모든 위치(토큰)에 대해 동일한 가중치를 공유하는 두 개의 linear layer와 그 사이의 활성화 함수(ReLU 또는 GELU)로 구성되며, 보통 중간 차원을 원래보다 확장했다가 다시 원래 차원으로 축소한다.
\[\text{MLP}(x) = W_2\,\sigma(W_1 x + b_1) + b_2\]
MLP는 토큰 $i$의 벡터만 입력으로 받아 토큰 $i$의 벡터만 출력하므로, 토큰 간 정보 교환이 전혀 일어나지 않는다 –> 즉 Transformer block 하나는 “attention으로 정보를 모으고(토큰 간 정보교환) → MLP로 그 정보를 가공한다(연산)”는 두 단계를 반복하는 구조이다.
3.2 Positional Encoding
앞서 self-attention의 특징에서 다뤘듯, self-attention과 MLP는 모두 permutation equivariant하다. 입력 토큰의 순서를 뒤섞으면 출력도 그대로 순서만 뒤섞여 나올 뿐, 순서 정보 자체를 활용하지 못한다. 하지만 언어처럼 순서가 의미에 결정적인 데이터를 다루려면 각 토큰이 시퀀스 내에서 몇 번째 위치에 있는지를 모델에게 알려줘야 한다.
이를 위해 각 토큰의 임베딩에 위치 정보를 담은 Positional Encoding 벡터를 더해준다.
\[X_{\text{input}} = \text{Embedding}(\text{tokens}) + PE\]
원 논문(Attention is All You Need)에서는 서로 다른 주기를 갖는 sin, cos 함수를 이용한 sinusoidal positional encoding을 사용했다. 위치 $pos$, 차원 인덱스 $i$에 대해:
\[PE_{(pos,\, 2i)} = \sin\!\left(\frac{pos}{10000^{2i/D}}\right), \qquad PE_{(pos,\, 2i+1)} = \cos\!\left(\frac{pos}{10000^{2i/D}}\right)\]
이 방식의 한계점을 보완한 RoPE(Rotary Position Embedding) 방식이 이후 제안되었다.
3.3 Encoder-Decoder 구조
Transformer block을 여러 개 쌓아 Encoder와 Decoder를 구성하고, 이 둘을 연결하면 원 논문의 전체 Transformer 아키텍처가 된다.
Encoder: 입력 시퀀스 전체를 받아, Transformer block($=$ Self-Attention + MLP)을 $N$번 반복해서 쌓는다. 각 block을 지날 때마다 모든 토큰이 서로를 참고하며 문맥 정보를 더 풍부하게 담은 표현으로 갱신한다.
Decoder: 출력 시퀀스를 한 토큰씩 생성하며, 각 Transformer block마다 두 종류의 attention을 사용한다.
Masked Self-Attention: 지금까지 생성된 출력 토큰들끼리 self-attention을 수행하되, 미래 시점의 토큰은 볼 수 없도록 마스킹한다(masked self-attention).
Cross-Attention: Query는 디코더 자신의 표현에서, Key와 Value는 encoder의 최종 출력에서 가져온다. 디코더가 매 시점 인코더의 정보를 참고해 다음 토큰을 예측한다.
각 attention 다음에는 동일하게 residual connection, LayerNorm, MLP가 뒤따른다. 이러한 Decoder block을 $N$번 쌓은 뒤, 마지막에 linear layer와 softmax를 거쳐 다음 토큰에 대한 확률 분포를 출력한다.
3.4 RNN과의 비교
RNN (Seq2Seq)
Transformer
토큰 간 정보 전달 경로
순차적 ($O(N)$ 단계를 거쳐야 먼 토큰에 도달)
모든 토큰이 attention을 통해 1단계($O(1)$)만에 서로 직접 연결
학습 시 병렬화
불가능 (이전 시점의 은닉 상태가 있어야 다음 시점 계산 가능)
가능 (전체 시퀀스에 대한 attention을 행렬곱으로 한 번에 계산)
시간복잡도/메모리
시퀀스 길이 $N$에 대해 선형 ($O(N)$), 은닉 차원 $D$에 대해 $O(D^2)$
attention score 행렬이 $(N,N)$이므로 $N$에 대해 이차 ($O(N^2)$)
순서 정보
재귀 구조 자체에 순서가 내재됨
별도의 positional encoding으로 순서를 명시적으로 주입해야 함
-
[ML] VIME: Extending the Success of Self- and Semi-supervised Learning to Tabular Domain
논문 정보
제목: VIME: Extending the Success of Self- and Semi-supervised Learning to Tabular Domain
링크: NeurIPS 2020
이미지·자연어에서 자기지도(self-supervised)·준지도(semi-supervised) 학습은 적은 라벨로도 뛰어난 성능을 내는 핵심 기술이 되었다. 하지만 이 기법들은 이미지의 공간적 구조(spatial structure)나 언어의 의미적 구조(semantic structure)에 강하게 의존하며, 이러한 과제는 표 형태의 정형 데이터(tabular data)에는 그대로 옮길 수 없다.
VIME(Value Imputation and Mask Estimation, 값 대체 및 마스크 추정)은 이 차이를 메우기 위해, tabular 데이터에 맞는 새로운 자기지도 과제와 새로운 준지도 데이터 증강법을 함께 제안한다.
1. 핵심 용어
1.1 Semi-supervised learning (준지도 학습)
지도 학습과 자기지도 학습의 중간에 있는 방식이다.
지도 학습은 라벨링이 되어 있는 데이터를 사용하는데, 보통 이러한 데이터의 양은 매우 적다. 이렇게 적은 데이터뿐이면 모델이 그 소수 샘플에 overfit되기 쉽다. 준지도 학습은 여기에 라벨 없는 대량의 데이터까지 함께 활용해서 이 문제를 완화한다.
핵심 직관은 “라벨은 없어도, 입력 데이터 $x$의 분포 자체가 정보를 준다”는 것이다. 준지도 학습은 크게 두 갈래로 볼 수 있다.
Entropy minimization(엔트로피 최소화): 라벨이 없는 데이터에 대해 모델이 자신 있게(예측하는 p가 클수록 엔트로피가 줄어듦) 예측하도록 유도.
Consistency regularization(일관성 정칙화): “데이터에 변형을 가해서 새로운 input을 만들었을 때에도 예측 결과는 그대로여야 한다”는 가정을 바탕으로 모델을 regularize하는 방법
VIME의 뒷쪽 부분이 준지도 학습이며, 이때 앞쪽에서 학습한 인코더를 재사용한다.
1.2 Pretext task (사전 과제) & Downstream task
자기지도 학습에서 “진짜 풀고 싶은 과제 대신, 표현을 학습시키려고 일부러 만들어 푸는 과제”이다. 말 그대로 표현 학습을 위한 “구실용 과제”이다.
이미지의 pretext task 예: 회전 각도 맞히기, 직소 퍼즐 맞추기, 흑백 이미지에 색 입히기
언어의 pretext task 예(BERT): 가려진 단어 맞히기, 다음 문장 예측
이 과제들의 정답은 데이터에서 생성된 것이므로 내재되어 있다. 중요한 건 이 가짜 과제를 잘 풀려면 데이터의 본질을 이해해야 하도록 설계하는 것으로, 그 과정에서 인코더가 쓸모 있는 표현을 배우게 된다. VIME의 핵심 기여가 바로 tabular데이터에 맞는 새로운 pretext task를 제안한 것이다.
pretext task로 표현을 배운 뒤, 실제로 풀고 싶은 진짜 과제(예: 질병 예측, 소득 분류)를 downstream task라고 한다. “상류(pretext)에서 배운 걸 하류(downstream)에서 쓴다”는 흐름의 비유.
1.3 Encoder
인코더 $e: \mathcal{X} \to \mathcal{Z}$ 는 입력 $x$를 받아 표현 $z = e(x)$ 를 뽑아내는 신경망이다. VIME에서 자기지도 학습이 끝나고도 계속 재사용되는 부분이다. 나머지 추정기들은 표현을 잘 배우게 하기 위한 보조 장치이고, 학습이 끝나면 버린다.
1.4 Mask vector & Value imputation (마스크 벡터와 값 대체)
Mask vector(마스크 벡터) $\mathbf{m}$: 어떤 특징을 오염시킬지 표시하는 0/1 벡터이다. $m_j = 1$이면 $j$번째 특징을 오염(교체), $m_j = 0$이면 원본 유지.
Value imputation(값 대체): 오염하기로 한 자리에 새 값을 채워 넣는 것. 원래 “imputation”은 통계학에서 결측값을 그럴듯한 값으로 메우는 것을 뜻하기도 한다.
2. VIME의 원리
VIME은 자기지도 학습(1단계) 으로 좋은 인코더를 먼저 만든 뒤, 그 인코더를 준지도 학습(2단계) 에 재사용한다.
2.1 오염 샘플 만들기 (Value Imputation)
각 특징 자리마다 성공 확률이 $p_m$인 베르누이 시행(Bernoulli trial)을 한 번씩, 총 $d$번 진행한다.
\[\mathbf{m} = [m_1, \dots, m_d]^\top \in \{0,1\}^d\]
그다음 마스크가 1인 자리는 오염값 $\bar{x}$로, 0인 자리는 원본 $x$로 채워 오염 샘플 $\tilde{x}$를 만듭니다($\odot$는 원소별 곱).
\[\tilde{\mathbf{x}} = g_m(\mathbf{x}, \mathbf{m}) = \mathbf{m} \odot \bar{\mathbf{x}} + (1 - \mathbf{m}) \odot \mathbf{x}\]
여기서 오염값 $\bar{x}_j$는 그 열 $j$의 경험적 주변 분포 $\hat{p}_{X_j}$에서 뽑는다. –> $\tilde{x}$는 여전히 “실제 데이터처럼 생긴, 그럴듯한” 샘플이 된다.
2.2 두 개의 pretext task
오염된 $\tilde{x}$를 인코더가 출력한 표현 $z = e(\tilde{x})$를 받아 두 개의 추정기가 각자 과제를 푼다.
1. Mask vector estimation(마스크 벡터 추정): VIME의 새로운 pretext task. 추정기 $s_m: \mathcal{Z} \to [0,1]^d$ 가 어느 자리가 오염되었는지를 예측한다. 즉 마스크 $\mathbf{m}$을 복원한다.
2. Feature vector estimation(특징 벡터 추정): 기존 오토인코더식 복원 과제. 추정기 $s_r: \mathcal{Z} \to \mathcal{X}$ 가 오염되기 전 원본 값은 무엇이었는지 예측. 즉 원본 $\mathbf{x}$를 복원하려고 한다.
두 추정기와 인코더를 함께 학습시킨다.
\[\min_{e,\, s_m,\, s_r} \;\;
\mathbb{E}_{\mathbf{x} \sim p_X,\; \mathbf{m} \sim p_m,\; \tilde{\mathbf{x}} \sim g_m(\mathbf{x},\mathbf{m})}
\Big[\, l_m(\mathbf{m}, \hat{\mathbf{m}}) + \alpha \cdot l_r(\mathbf{x}, \hat{\mathbf{x}}) \,\Big]\]
여기서 $\hat{\mathbf{m}} = (s_m \circ e)(\tilde{\mathbf{x}})$, $\hat{\mathbf{x}} = (s_r \circ e)(\tilde{\mathbf{x}})$ 이며, $\alpha$는 두 손실의 균형을 맞추는 하이퍼파라미터이다.
마스크 추정 손실 $l_m$ 은 mask vector 각 차원에 대한 binary cross-entropy의 평균이다.
\[l_m(\mathbf{m}, \hat{\mathbf{m}}) = -\frac{1}{d}\sum_{j=1}^{d}
\Big[\, m_j \log \hat{m}_j + (1 - m_j)\log\!\left(1 - \hat{m}_j\right) \Big]\]
복원 손실 $l_r$은 연속형 특징에 대한 MSE이다. (범주형 특징은 cross-entropy loss로 대체함.)
\[l_r(\mathbf{x}, \hat{\mathbf{x}}) = \frac{1}{d}\sum_{j=1}^{d}\left(x_j - \hat{x}_j\right)^2\]
2.3 인코더는 무엇을 배우는가?
두 과제를 잘 풀려면 인코더는 반드시 특징들 사이의 상관관계(correlation) 를 포착해야 한다.
$s_m$(마스크 추정): 어떤 특징 값이 주변의 상관된 특징들과 어긋나 있으면, 그 자리는 오염됐을 가능성이 높다고 판단할 수 있다.
$s_r$(복원): 오염되지 않고 남아 있는 상관된 특징들로부터 오염된 자리의 원래 값을 추론.
즉 두 과제 모두 “특징 간 상관 구조를 알아야” 풀리도록 설계되어, 인코더가 그 구조를 표현 $z$에 담게 된다. 이미지의 공간적 상관, 언어의 앞뒤 단어 상관을 배우는 것과 같은 원리를, 상관 구조가 명시적이지 않은 표 데이터에 이식한 것이 VIME의 핵심이다.
2.4 준지도 학습
이제 학습된 인코더 $e$를 freeze 하고, 그 위에 예측기 $f$를 추가한다.
$f_e = f \circ e$, $\hat{y} = f_e(x)$ 로 두며, 최종 목적함수는 지도 손실과 비지도 손실의 합이다.
\[\mathcal{L}_{\text{final}} = \mathcal{L}_s + \beta \cdot \mathcal{L}_u\]
지도 손실 $\mathcal{L}_s$: 라벨 있는 데이터로 계산하는 표준 손실. (mean squared error for regression or categorical cross-entropy for classification)
\[\mathcal{L}_s = \mathbb{E}_{(x,y)\sim p_{XY}}\big[\, l_s(y,\, f_e(x)) \,\big]\]
비지도(일관성) 손실 $\mathcal{L}_u$: 라벨 없는 데이터로 계산. 같은 샘플 $x$와, 그것을 오염시킨 $\tilde{x}$에 대한 예측이 비슷해지도록 강제하는 것이다. (Consistency regularization)
\[\mathcal{L}_u = \mathbb{E}_{x \sim p_X,\; m \sim p_m,\; \tilde{x} \sim g_m(x,m)}
\Big[\, \big\lVert f_e(\tilde{x}) - f_e(x) \big\rVert^2 \,\Big]\]
$\mathcal{L}_u$가 강제하는 규칙은 “같은 데이터를 조금 오염시켜도, 모델의 예측은 흔들리면 안된다.”는 것이다.
수식을 다시 보면 두 예측의 차이를 제곱해서, 이걸 0에 가깝게(= 두 예측이 같게) 만들라는 것이다.
\[\mathcal{L}_u = \mathbb{E}\Big[\, \big\lVert \underbrace{f_e(\tilde{x})}_{\text{오염본 예측}} - \underbrace{f_e(x)}_{\text{원본 예측}} \big\rVert^2 \,\Big]\]
데이터의 오염은 무작위로 진행된다. 그래서 오염본을 딱 1개만 만들면, 극단적인 오염이 걸리거나 원본과 거의 같게 되는 경우가 생길 수 있다.
그래서 실제로는 한 샘플 $x$마다 오염본을 $K$개 만들어($\tilde{x}_1, \dots, \tilde{x}_K$) 그 예측들의 흩어짐을 평균을 낸다. “이 샘플을 여러 방식으로 흔들어봤을 때, 예측이 얼마나 널뛰나(분산)” 를 재는 것이고, 그 널뜀을 줄이라는 것이다.
\[\hat{\mathcal{L}}_u = \frac{1}{N_b K}\sum_{i=1}^{N_b}\sum_{k=1}^{K}
\big\lVert f(z_{i,k}) - f(z_i) \big\rVert^2\]
여기서 $N_b$는 배치 크기, $z_{i,k} = e(\tilde{x}_{i,k})$, $z_i = e(x_i)$ 이다. 내부합 $\sum_k$가 예측들의 평균, 외부합 $\sum_i$가 배치 내 샘플들에 대한 평균이다.
또한 이 일관성 손실의 “변형(오염)”은 1단계 자기지도 학습으로 얻은 인코더를 통과한 오염이다.
1단계: 오염을 만들어 “어디가 오염됐나 / 원본이 뭐였나”를 풀며 인코더를 학습
2단계: 똑같은 오염 방식을 이번엔 “흔들어도 예측이 같아야 한다”는 일관성 신호로 재사용
같은 오염 도구를 두 단계에서 다른 목적으로 두 번 사용하는 구조이다.
3. 실험 결과
Genomics data: VIME이 지도(ElasticNet)·자기지도(Context Encoder)·준지도(MixUp) 모두를 앞섰고, 많은 경우 라벨을 절반만 쓰고도 다른 기법과 비슷한 성능을 보임.
Clinical data: 두 가지 치료 예측(호르몬 요법/근치 요법)에서 VIME이 최고 AUROC를 기록.
Public tabular data: 라벨 10% / 비라벨 90% 세팅에서 VIME이 도메인에 관계없이 최고 정확도를 보임.
4. SCARF와의 비교
SCARF 설명 글
항목
VIME
SCARF
자기지도 손실
복원 + 마스크 추정
대조 학습 (InfoNCE)
pretext task
“어디가 오염됐나 + 원본이 뭐였나” 복원
“같은 원본에서 나온 두 view 짝짓기”
오염 방식
각 열의 경험적 주변 분포에서 교체 (동일)
각 열의 경험적 주변 분포에서 교체 (동일)
미세조정 범위
인코더 고정, 예측기만 학습
인코더 포함 전체 미세조정
준지도 요소
있음 (일관성 손실 $\mathcal{L}_u$)
없음 (순수 사전학습→미세조정)
핵심 차이는 “오염을 어떻게 활용하느냐” 이다. VIME은 오염을 복원, 마스크 추정 과제의 재료로 쓰고, SCARF는 같은 오염을 대조 학습의 view 생성에 사용한다. SCARF 논문은 이 대조 방식이 VIME의 복원 방식보다 효과적이라고 보고했다.
-
[ML] SCARF: Self-Supervised Contrastive Learning using Random Feature Corruption
논문 정보
제목: SCARF: Self-Supervised Contrastive Learning using Random Feature Corruption
arXiv: 2106.15147
비전(SimCLR)이나 NLP(BERT, GPT-3)에서는 self-supervised pretraining이 크게 성공했는데, 그 핵심은 도메인에 맞는 “view 생성 방법”이었다(비전의 crop/color distortion, NLP의 token masking). 그런데 정작 현실에서 가장 흔한 데이터 형태인 테이블 형태 데이터(tabular data) 에서는 이런 기법이 거의 활용되지 못하고 있었다. 이미지의 “자르기(crop)”, NLP의 “토큰 마스킹(token masking)” 같은 데이터 변형 기법들이 전부 도메인 특화(domain-specific) 라서, 열(column)마다 의미가 제각각인 표 데이터에는 그대로 쓸 수 없기 때문이다.
SCARF는 이 문제를 아주 단순한 아이디어로 해결한다. 입력 특징(feature) 중 일부를 무작위로 골라, 그 값을 “그 열에 실제로 등장했던 다른 값”으로 바꿔치기하는 것이다.
1. 핵심 용어
1.1 Self-supervised learning (자기지도 학습)
Supervised learning(지도 학습) 은 (입력, 정답 라벨) 쌍으로 학습한다. 문제는 label이 있는 데이터는 사람이 일일이 붙여야 하므로 비용이 들고, 양도 적은 경우가 있다. 반면 라벨이 없는 데이터는 세상에 넘쳐난다.
Self-supervised learning은 이 라벨 없는 “데이터 자체로부터 문제를 스스로 만들어” 학습하는 방식이다. “self(스스로) + supervised(지도)”라는 이름 그대로, 정답을 사람이 주는 게 아니라 데이터 구조에서 자동으로 만들어낸다.
BERT: 문장에서 단어를 일부 가리고 “가려진 단어를 맞혀라”는 문제를 스스로 출제 → 정답은 원래 그 자리에 있던 단어
SimCLR: 같은 이미지를 두 가지로 변형시킨 후 같은 이미지에서 나왔는지 판단
이렇게 학습하면 “고양이 vs 개” 같은 특정 과제와 무관하게, 입력 데이터의 일반적이고 유용한 표현(representation)자체를 학습하게 된다.
1.2 Pretraining & Fine-tuning (사전학습과 미세조정)
자기지도 학습은 보통 두 단계로 진행된다.
Pretraining(사전학습): 라벨 없는 대량의 데이터로 위에서 말한 self-supervised 방식으로 인코더(encoder) 를 먼저 훈련시킨다. 이 단계의 목표는 정확한 분류가 아니라 “좋은 표현을 뽑아내는 능력”을 갖추는 것이다.
Fine-tuning(미세조정): 사전학습된 인코더 위에 분류기(classification head) 를 얹고, 이번엔 라벨이 있는 (적은) 데이터로 실제 과제(예: 분류)에 맞게 전체를 조금 더 학습시킨다.
핵심은 사전학습이 좋은 출발점(초기 가중치)을 만들어주기 때문에, 미세조정 단계에서 라벨이 적어도 높은 성능이 나온다는 것이다. SCARF는 이 전형적인 구도를 따른다.
참고로 SCARF는 미세조정 단계에서 인코더 f까지 전부 다시 학습시킨다. (VIME은 인코더를 얼려두고 분류기만 학습한다는 점이 다르다.)
1.3 Representation & Embedding (표현, 임베딩)
신경망이 입력을 통과시키면서 만들어내는 중간 벡터 표현을 representation 또는 embedding이라고 부른다. 예를 들어 128차원 입력이 인코더를 지나 256차원 벡터가 되었다면, 그 256차원 벡터가 이 입력의 “표현”이다. 좋은 표현이란 비슷한 입력끼리는 가깝고, 다른 입력끼리는 먼 벡터 공간을 만드는 것을 뜻한다.
1.4 View (뷰)
이 논문의 핵심 개념이다. View란 “같은 원본 데이터를 살짝 다르게 변형한 버전” 을 말한다.
이미지에서의 view: 같은 사진을 무작위로 자르거나(crop), 색을 왜곡하거나(color distortion), 흐리게(blur) 만든 이미지
NLP에서의 view: 같은 문장에서 일부 토큰을 마스킹한 데이터
SCARF에서의 view: 같은 행(row) 데이터에서 일부 특징(feature)을 무작위로 오염시킨 데이터
“같은 원본에서 나온 두 view는 서로 가깝게, 다른 원본에서 나온 view들은 서로 멀게” 만들도록 학습하면, 신경망은 표면적인 변형에 흔들리지 않고 본질적인 표현을 배우게 된다. 즉 view는 어떤 변형에도 불변인(invariant) 특징을 배우도록 하기 위해 사용하는 것이다.
1.5 Contrastive learning (대조 학습)
위에서 설명한 같은 것끼리 가깝게 하고, 다른 것끼리 멀게 만들도록 학습시키는 것을 contrastive(대조) learning이라고 합니다.
Positive pair(양성 쌍): 같은 원본에서 나온 두 view. → 가깝게 만들어야 함
Negative pair(음성 쌍): 서로 다른 원본에서 나온 view들. → 멀게 만들어야 함
이를 하나의 수식(손실 함수)으로 표현한 것이 InfoNCE이다.
1.6 InfoNCE
“Information Noise Contrastive Estimation”의 줄임말로, 대조 학습에서 가장 널리 쓰이는 손실 함수(loss function)이다.
이 손실을 최소화하는 것은 두 view가 공유하는 정보의 양, 즉 상호 정보량(mutual information)을 최대화하는 것과 같다. 두 view의 공유 정보란 “오염에도 살아남은 본질적인 정보”이므로, 이를 최대화한다는 건 노이즈에 흔들리지 않고 데이터의 핵심을 담는 표현을 학습한다는 뜻이다. (손실 ↓ = 상호 정보량 ↑)
수식을 뜯어보면 사실 “소프트맥스 분류(softmax classification)” 와 똑같은 구조이다. SCARF의 손실 수식은 다음과 같다.
\[\mathcal{L} = -\frac{1}{N} \sum_{i=1}^{N} \log
\frac{\exp\!\left(s_{i,i}/\tau\right)}
{\dfrac{1}{N}\displaystyle\sum_{k=1}^{N} \exp\!\left(s_{i,k}/\tau\right)}\]
배치(batch) 안에 N개의 원본이 있고, 각 원본 i마다 원본 view $z^{(i)}$ 와 오염된 view $\tilde{z}^{(j)}$ 두 개가 있다고 하면,
$s_{i,j}$ : $z^{(i)}$ 와 오염 view $\tilde{z}^{(j)}$ 사이의 코사인 유사도이다. 두 벡터를 각자 길이로 나눠 정규화한 뒤 내적한 값으로, 방향이 얼마나 비슷한지를 -1 ~ 1로 나타낸다.
$s_{i,k}$ 에서 i=k인 경우 같은 원본에서 나온 positive pair의 유사도이므로 최대화 해야하는 값이고, k≠i인 경우 다른 원본과의 negative pair 유사도이므로 최소화하도록 한다.
τ (temperature, 온도): 표현의 스케일을 조절하는 값이다. 작을수록 가장 큰 유사도에 극단적으로 집중하고, 클수록 완만해진다. SCARF는 기본값으로 τ = 1을 사용(즉 그냥 순수 소프트맥스).
분자 $\exp\left(s_{i,i}/\tau\right)$ 는 positive pair의 유사도 점수이고, 분모는 이를 포함하여 배치 전체 view와의 유사도 점수를 총합한 것이다. (분모에서 i는 고정이고 k만 변함)
이 분수는 “배치 안의 N개의 후보 중에서 자신의 진짜 짝(positive)을 골라낼 확률이다. 이 확률을 1에 가깝게 하는 것이 목표이므로, 즉 -log 값을 0에 가깝게 최소화하는 것이 학습 목표이다.(진짜 짝과의 유사도(s_ii)를 키우고 나머지(s_ik)를 줄이도록)
배치사이즈가 N이면 문제는 “1개의 positive와 N−1개의 negative 중 고르기”가 되는데, 배치가 클수록 negative가 많아져 문제가 어려워지고, 보통 어려운 문제가 표현을 더 잘 학습시킨다. (다만 뒤에서 보듯 SCARF는 배치 크기에 크게 민감하지 않다.)
1.7 Empirical marginal distribution (경험적 주변 분포)
Marginal distribution(주변 분포): 여러 변수 중 딱 하나의 변수만 떼어놓고 본 값의 분포이다. 표 데이터로 치면 “특정 한 열(column)”에 있는 값들의 분포.
Empirical(경험적): 이론적 수식이 아니라 실제 데이터에 등장한 값들로 만든 분포라는 뜻.
즉 어떤 열의 empirical marginal distribution이란 “학습 데이터에서 그 열이 실제로 가졌던 값들의 집합” 이고, 등장한 빈도를 그대로 반영한 분포이다. SCARF는 여기서 균등하게(uniformly) 하나를 뽑아 오염에 사용한다.
“uniform”은 “N개의 행을 각각 1/N 확률로 똑같이 뽑는다” 는 뜻이지, “회사원·학생·자영업·무직 네 종류가 나왔을때 각각을 1/4로 뽑는다”는 뜻이 아니다. 등장한 빈도를 반영하여 뽑는다.
2. SCARF의 원리
SCARF는 네트워크를 3가지로 분해하며 각각 256 은닉차원을 가지는 ReLU 네트워크이다. $f$는 4 layers, 나머지 $g$와 $h$는 2 layers이다.
$f$(encoder): 입력에서 표현을 뽑는 본체. 최종적으로 우리가 재사용하고 싶은 건 오직 이 인코더 $f$이다.
$g$(pre-train head): 대조 학습 전용 보조 신경망. 출력을 L2 정규화하여 벡터들이 반지름 1인 초구(unit hypersphere) 위에 놓이게 만든다. 이 정규화가 실무적으로 매우 중요하다고 알려져 있다. 사전학습이 끝나면 $g$는 버려진다.
$h$(classification head): 미세조정 단계에서 $f$의 출력을 받아 레이블을 예측하는 분류기.
view를 어떻게 만드는가
미니배치의 각 샘플 $x^{(i)}$ 에 대해:
오염할 특징 개수 결정. 전체 특징 수가 $M$ 이고 오염률(corruption rate) $c$ 가 $0.6$ 이면, $q = \lfloor c \cdot M \rfloor$ 개, 즉 약 60%의 특징을 오염시키기로 설정.
오염할 위치 무작위 선택. ${1, \dots, M}$ 에서 크기 $q$ 인 부분집합 $I_i$ 를 균등 무작위로 뽑는다. 샘플마다, 매 스텝마다 다른 위치를 뽑는다.
선택된 특징을 오염. 오염 대상으로 뽑힌 위치 $j$ 의 값은, 그 열 $j$ 의 empirical marginal distribution $\hat{X}_{c_j}$ 에서 새로 뽑은 값 $v$ 로 교체한다. 뽑히지 않은 위치는 원본 값 그대로 유지.
\[\tilde{x}^{(i)}_j =
\begin{cases}
x^{(i)}_j & \text{if } j \notin I_i \quad \\
v, \quad v \sim \hat{X}_{c_j} & \text{if } j \in I_i \quad
\end{cases}\]
구체적인 예시로, 한 사람의 데이터가 이렇게 있다.
나이
소득
직업
지역
자녀수
34
5200
회사원
서울
2
여기서 ‘소득’과 ‘직업’이 오염 대상으로 뽑혔다면, 각 열이 데이터셋에서 실제로 가졌던 값 중 무작위로 하나씩 뽑아 교체한다.
나이
소득
직업
지역
자녀수
34
5200 3100
회사원 학생
서울
2
이렇게 만들어진 오염본이 바로 원본의 view이고, (원본, 오염본)이 하나의 positive pair가 된다.
이렇게 “그 열의 실제 값으로” 교체 하면 원래 그 열의 “단위”와 분포를 그대로 유지하여 좋다. 따로 하이퍼파라미터를 만들 필요도 없고, 특징 스케일링 방식(z-score, min-max 등)에 불변(invariant) 하다.
손실 계산과 최적화
앞서 만든 두 view를 각각 $f \to g$ 에 통과시켜 임베딩 $z^{(i)}$, $\tilde{z}^{(i)}$ 를 얻고, InfoNCE loss를 계산한다. 여기서 $z^{(i)} = g\bigl(f(x^{(i)})\bigr)$, $\tilde{z}^{(i)} = g\bigl(f(\tilde{x}^{(i)})\bigr)$ 이다.
모든 $i$ 에 대해 $z^{(i)}$ 와 $\tilde{z}^{(i)}$ (같은 원본) → 가깝게, 즉 $s_{i,i}$ 를 크게
$i \neq j$ 인 $z^{(i)}$ 와 $\tilde{z}^{(j)}$ (다른 원본) → 멀게, 즉 $s_{i,j}$ 를 작게
$f$, $g$ 의 파라미터는 경사하강(SGD) 방식으로 갱신한다. 실제 모든 실험에서 사용한 구체적인 옵티마이저는 Adam(학습률 $0.001$, 배치 크기 $128$)이다.
사전학습을 몇 epoch 돌려야 하는지는 데이터셋마다 다르기에, 검증 데이터에 대한 InfoNCE loss를 이용해 early stopping을 할 수도 있다. 라벨 없는 검증 데이터로 $(x^{(i)}, \tilde{x}^{(i)})$ 쌍을 미리 한 번 만들어 고정된 정적 검증셋을 만들고, 학습 내내 이 검증 셋의 손실을 추적한다.
학습 손실 곡선은 매 스텝 무작위 오염 때문에 noisy 하지만, 검증 손실 곡선은 검증셋을 한 번 만들어 고정하므로 매끄럽다. 또 조기 종료 기준으로 InfoNCE “손실”을 쓰는 게 InfoNCE “에러(오분류율)”를 쓰는 것보다 성능이 좋았다.
3. 실험
데이터셋: OpenML-CC18 벤치마크의 실제 분류 데이터셋 69개(원래 72개에서 이미지 데이터인 MNIST, Fashion-MNIST, CIFAR-10 제외). 각 데이터셋을 70%/10%/20%로 train/validation/test 분할하고, 30번씩 반복 실험.
세 가지 시나리오에서 사전학습이 테스트 정확도를 얼마나 올리는지 측정하였다.
전체 라벨(fully-supervised): 라벨을 100% 사용.
준지도(semi-supervised): 라벨을 25%만 주고 나머지 75%는 라벨 없이.
라벨 노이즈(label noise): 학습 라벨의 30%를 무작위 클래스로 오염.
세 상황 모두에서 SCARF가 도움이 되었다.
먼저 라벨이 완벽하게 다 주어진 상황에서 SCARF는 기존 기법들(mixup·label smoothing·dropout·distillation) 위에 얹었을 때 약 1~2%의 상대적 향상을 더했다.
라벨이 25%뿐인 준지도 상황에서 효과가 가장 극적이었으며, 다른 준지도 전용 기법 위에서도 2~4% 향상되었다.
라벨의 30%가 무작위로 틀어진 노이즈 상황에서는 향상 폭이 2~3%.
핵심은 “SCARF가 기존 기법을 대체하는 게 아니라 보완한다(complements)” 는 사실이다. 만약 SCARF가 mixup이나 dropout과 똑같은 원리로 작동한다면, 이미 그 기법들을 쓰고 있는 모델에 SCARF를 더 얹어봐야 효과가 겹쳐서 추가 이득이 없을텐데, 잘 튜닝된 기법들 위에 얹어도 여전히 성능이 오른다는 건 그것들과 다른 메커니즘, 데이터 구조에서 표현을 학습하는 방식으로 작동한다는 증거이다. 실용적으로, 지금 쓰는 파이프라인을 갈아엎지 않고 위에 한 겹만 더 얹으면 된다는 뜻이라 매력적이다.
저자들이 언급한 한계: 입력 데이터에 존재하는 편향(bias)을 표현이 그대로 학습·강화할 수 있으며, 이를 완화하는 방법은 향후 과제로 남김
비교에 사용된 주요 baseline들
종류
방법
범용(정확도·노이즈·distill)
Label smoothing, Dropout, Mixup, Self-distillation
사전학습(핵심 비교군)
Autoencoder 3종(no-noise / additive-noise / SCARF-corruption AE), Discriminative SCARF
라벨 노이즈 전용
Deep k-NN, Bi-tempered loss
준지도 전용
Self-training, Tri-training
실무 참고
XGBoost (부록의 절대 정확도 비교)
4. Ablation Study
논문은 SCARF의 각 설계 선택이 정말 필요한지 꼼꼼히 실험하였다. (Ablation은 “구성 요소를 하나씩 제거/변경해 그 기여도를 측정하는” 실험을 말함)
Marginal 샘플링이 다른 오염 방식보다 우수하다: 다른 대안(오염 없음, 평균값으로 교체, 노이즈 추가, joint(행 전체를 다른 샘플에서 통째로 가져오기), 0으로 만들기(feature dropout))보다 SCARF의 marginal 방식이 여러 스케일링 하에서 가장 견고했음.
배치 크기에 둔감하다: SimCLR 등은 배치를 5000까지 키워야 좋아지지만, SCARF는 128을 넘겨도 유의미한 개선이 없었다. 대규모 배치를 위한 엔지니어링 부담이 적다.
오염률 c는 50~80% 구간에서 성능이 안정적이라 기본값 0.6을 권장함. 온도 τ도 마찬가지로 안정적이며 기본값 1(순수 소프트맥스)을 권장.
오염 세부 튜닝: 부록에 따르면 인코더에 들어가는 두 view를 모두 오염시키는 것보다 한쪽 view만 오염시키는 게 낫고(양쪽을 60%씩 오염하면 두 view가 공유하는 정보가 너무 적어 문제가 지나치게 어려워짐), 배치 내 샘플마다 다른 위치를 오염시키는 게 같은 위치를 쓰는 것보다 낫다.
-
[ML] Revisiting Deep Learning Models for Tabular Data
논문 정보
제목: Revisiting Deep Learning Models for Tabular Data
arXiv: 2106.11959
코드: yandex-research/rtdl-revisiting-models
1. Introduction
이미지, 오디오, 텍스트에서의 성공 이후로, 테이블(정형) 데이터에도 딥러닝을 적용하려는 시도가 늘고 있다.
잠재적으로 GBDT보다 높은 성능을 낼수도 있고, 입력의 일부만 테이블이고 나머지는 이미지/오디오인 멀티모달 파이프라인을 gradient 기반으로 end-to-end 학습할 수 있다는 점에서 DL 아키텍쳐는 정형 데이터에서도 매력적일 수 있다.
하지만, 이 분야에 ImageNet(CV)이나 GLUE(NLP) 같은 확립된 벤치마크가 없어 논문마다 서로 다른 데이터셋을 쓰며 제안된 모델들이 서로 제대로 비교되지 않는다.
그래서 “어떤 DL 모델이 일반적으로 더 좋은가”, “딥러닝이 GBDT를 넘어섰는가”에 대한 답이 불분명하다.
적당한 노력으로 여러 태스크에서 안정적인 성능을 내는 신뢰할 만한 베이스라인이 부족하다. MLP가 그 역할을 해왔지만, 다른 모델들에 비해 성능이 충분하지 않음.
이 논문이 정리한 네 가지 기여(Contribution)는 다음과 같다.
Tabular 데이터용 주요 DL 모델들을 다양한 task에서 공정하게 평가하여 상대적 성능을 조사.
ResNet 계열 아키텍처가 효과적인 베이스라인임을 보임.
Transformer를 정형 데이터에 각색한 FT-Transformer를 제안. 대부분의 태스크에서 최고 성능을 보이며, 다른 DL 모델보다 넓은 범위의 태스크에서 잘 작동하는 “보편적(universal)” 아키텍처임을 확인한다.
GBDT와 딥러닝 모델 사이에 아직 보편적 승자는 없다.
2. Related Work
딥러닝을 제외한, 전통적인(=shallow) 머신러닝 방법들 중에서는 GBDT가 정형 데이터의 최고 성능(SOTA) 자리를 차지하고 있다(XGBoost, LightGBM, CatBoost). 이들은 구현 디테일이 달라도 대부분의 태스크에서 성능 차이가 크지 않다.
한편 최근 제안된 딥러닝 모델들은 대략 세 갈래로 나눌 수 있다.
(1) Differentiable Trees (미분 가능한 트리)
결정 트리 앙상블의 강력함에서 출발한 계열. 결정 트리는 미분 불가능해서 end-to-end 학습을 할 수 없으므로, 내부 노드의 결정 함수를 smooth하여 트리 함수와 라우팅을 미분 가능하게 만든다(NODE 등). 일부 태스크에서 GBDT를 이기기도 하지만, 이 논문 실험에서는 ResNet을 일관되게 이기지 못했다.
(2) Attention-based Models (어텐션 기반)
어텐션 모듈을 tabular 데이터에 도입한 계열(TabNet, AutoInt 등). 하지만 실험 결과, 잘 튜닝한 ResNet이 기존 어텐션 기반 모델들을 능가했다. 그럼에도 저자들은 Transformer를 정형 데이터에 제대로 적용하는 방법을 찾아냈고, 그 결과가 FT-Transformer다.
(3) 곱셈적 상호작용의 명시적 모델링
추천 시스템·CTR 예측 문헌에서 MLP가 feature 간 곱셈적 상호작용을 잘 모델링하지 못한다는 비판이 있었다. 이에 feature product를 MLP에 넣는 방법들이 제안됐다(DCN V2 등). 하지만 이 논문에서는 잘 튜닝한 베이스라인보다 우월하지 않았다.
3. Models for tabular data problems
Notation
데이터셋 $D = {(x_i, y_i)}_{i=1}^n$
각 객체 $x_i = (x_i^{(num)}, x_i^{(cat)})$ — 수치형·범주형 feature
feature 총 개수 $k$
$D = D_{train} \cup D_{val} \cup D_{test}$ (val은 early stopping과 hyperparams튜닝, test는 최종 평가에만 사용)
이진 분류 $Y={0,1}$, 다중 분류 $Y={1,\dots,C}$, 회귀 $Y=\mathbb{R}$ 에 대해서 진행
3.1 MLP
\(\text{MLP}(x) = \text{Linear}(\text{MLPBlock}(\dots(\text{MLPBlock}(x))))\)
\(\text{MLPBlock}(x) = \text{Dropout}(\text{ReLU}(\text{Linear}(x)))\)
3.2 ResNet
ResNet이 입력에서 출력까지 명확한 경로(clear main path)를 갖도록 블록을 단순화했다. 이 구조가 최적화에 유리했다.
\(\text{ResNet}(x) = \text{Prediction}(\text{ResNetBlock}(\dots(\text{ResNetBlock}(\text{Linear}(x)))))\)
\(\text{ResNetBlock}(x) = x + \text{Dropout}(\text{Linear}(\text{Dropout}(\text{ReLU}(\text{Linear}(\text{BatchNorm}(x))))))\)
\(\text{Prediction}(x) = \text{Linear}(\text{ReLU}(\text{BatchNorm}(x)))\)
3.3 FT-Transformer
Feature Tokenizer + Transformer. 모든 feature(수치형·범주형)를 임베딩으로 바꾼 뒤, 그 임베딩들에 Transformer 레이어를 쌓는다.
\[\text{FT-Transformer}(x) = \text{Prediction}(\text{Block}(\dots(\text{Block}(\text{FeatureTokenizer}(x)))))\]
(a) Feature Tokenizer
입력 feature $x$를 임베딩 $T \in \mathbb{R}^{k \times d}$로 변환한다. feature $x_j$에 대한 임베딩은:
\[T_j = b_j + f_j(x_j) \in \mathbb{R}^d, \quad f_j : X_j \to \mathbb{R}^d\]
여기서 $b_j$는 $j$번째 feature bias이고,
수치형: $f_j^{(num)}$은 벡터 $W_j^{(num)} \in \mathbb{R}^d$와 element-wise 곱
\(T_j^{(num)} = b_j^{(num)} + x_j^{(num)} \cdot W_j^{(num)} \in \mathbb{R}^d\)
범주형: $f_j^{(cat)}$은 lookup table $W_j^{(cat)} \in \mathbb{R}^{S_j \times d}$
\(T_j^{(cat)} = b_j^{(cat)} + e_j^{T} W_j^{(cat)} \in \mathbb{R}^d\)
($e_j$는 해당 범주형 feature의 one-hot 벡터)
모든 토큰을 쌓으면:
\[T = \text{stack}\left[T_1^{(num)}, \dots, T_{k^{(num)}}^{(num)}, T_1^{(cat)}, \dots, T_{k^{(cat)}}^{(cat)}\right] \in \mathbb{R}^{k \times d}\]
여기서 feature bias($b_j$)가 중요한 설계 요소다. 이걸 빼면 성능이 눈에 띄게 떨어진다.
(b) Transformer
여기에 [CLS] 토큰(출력 토큰)을 붙이고 $L$개의 Transformer 레이어 $F_1, \dots, F_L$을 적용한다:
\[T_0 = \text{stack}[\,[\text{CLS}],\ T\,], \qquad T_i = F_i(T_{i-1})\]
PreNorm 변형을 사용한다(최적화가 쉬움). 즉 정규화를 각 residual branch의 시작에 둔다.
PreNorm 세팅에서 좋은 성능을 위해 첫 번째 Transformer 레이어의 첫 정규화를 제거해야 했다.
Norm: LayerNorm
\(\text{Block}(x) = \text{ResidualPreNorm}(\text{FFN},\ \text{ResidualPreNorm}(\text{MHSA},\ x))\)
\(\text{ResidualPreNorm}(\text{Module}, x) = x + \text{Dropout}(\text{Module}(\text{Norm}(x)))\)
\(\text{FFN}(x) = \text{Linear}(\text{Dropout}(\text{Activation}(\text{Linear}(x))))\)
(c) Prediction
마지막 레이어의 [CLS] 토큰 표현으로 예측한다:
\[\hat{y} = \text{Linear}(\text{ReLU}(\text{LayerNorm}(T_L^{[\text{CLS}]})))\]
Limitations
ResNet 같은 단순 모델보다 학습에 더 많은 자원과 시간이 든다.
vanilla MHSA의 feature 수에 대한 Quadratic 복잡도 때문에 feature가 너무 많은 데이터에는 확장이 어렵다. (완화책: 어텐션 근사, 또는 FT-Transformer를 단순 구조로 distillation)
3.4 비교 대상 모델들
tabular 데이터 전용으로 설계된 기존 모델들도 함께 비교했다.
모델
계열 / 특징
SNN
SELU 활성화로 더 깊은 학습을 가능케 한 MLP 계열
NODE
oblivious decision tree의 미분 가능 앙상블
TabNet
feature 재가중과 FFN을 번갈아 쓰는 recurrent 구조
GrowNet
gradient boosted 약한 MLP들 (분류·회귀만 지원)
DCN V2
MLP 모듈 + feature crossing 모듈
AutoInt
feature를 임베딩 후 어텐션 기반 변환 적용
XGBoost
대표적 GBDT 구현
CatBoost
oblivious decision tree를 weak learner로 쓰는 GBDT
4. Experiments
이 논문은 아키텍처가 부여하는 inductive bias 자체를 평가하는 데 초점을 둔다. 따라서 pretraining, 추가 loss, data augmentation, distillation, learning rate warmup/decay 같은 기법은 사용되지 않았다.
데이터셋 (11개)
다양한 공개 데이터셋 11개를 사용하며, 각 데이터셋은 정확히 하나의 train-val-test split을 공유한다. 랭킹 문제(Microsoft, Yahoo)는 pointwise 방식으로 회귀 취급.
약자
데이터셋
#객체
#수치
#범주
metric
#클래스
CA
California Housing
20,640
8
0
RMSE
–
AD
Adult
48,842
6
8
Acc.
2
HE
Helena
65,196
27
0
Acc.
100
JA
Jannis
83,733
54
0
Acc.
4
HI
Higgs Small
98,050
28
0
Acc.
2
AL
ALOI
108,000
128
0
Acc.
1000
EP
Epsilon
500,000
2000
0
Acc.
2
YE
Year
515,345
90
0
RMSE
–
CO
Covertype
581,012
54
0
Acc.
7
YA
Yahoo
709,877
699
0
RMSE
–
MI
Microsoft
1,200,192
136
0
RMSE
–
Preprocessing
기본적으로 scikit-learn의 quantile transformation 사용.
Helena, ALOI에는 standardization 적용(ALOI는 이미지 데이터라 CV 관례를 따름).
Epsilon은 전처리가 오히려 성능을 저하시켜 raw feature 사용.
모든 알고리즘에서 회귀 문제의 타깃은 standardization적용.
Tuning
대부분 모델은 Optuna로 베이지안 최적화(TPE) 수행. 나머지는 각각의 원래 논문이 권장한 설정 조합을 순회.
하이퍼파라미터는 validation set 성능으로 선택(test는 절대 튜닝에 안 씀).
Evaluation
튜닝된 각 구성에 대해 서로 다른 랜덤 시드 15개로 실험 후 test 성능 보고.
Ensembles
각 모델·데이터셋마다 15개 단일 모델을 크기가 같은 3개 그룹으로 나눠, 그룹 내 예측을 평균 → 앙상블 3개 확보.
학습 세부
분류: cross-entropy, 회귀: MSE.
Optimizer: TabNet·GrowNet은 Adam, 나머지는 AdamW. learning rate schedule 없음.
Early stopping: patience = 16 (16+1 epoch 동안 val 개선 없으면 종료).
범주형: XGBoost는 one-hot, CatBoost는 내장 지원, NN은 동일 차원 임베딩.
5. 결과
Comparing DL models
ResNet은 효과적인 베이스라인. 평균 순위 3.3으로 DL 모델 중 2위.
FT-Transformer는 대부분 task에서 최고 성능(평균 순위 1.8)
튜닝이 MLP·ResNet 같은 단순 모델을 경쟁력 있게 만든다. 따라서 가능하면 베이스라인도 튜닝하기를 권한다.
NODE는 몇몇 태스크에서 높은 성능을 보이지만, 복잡한 구조임에도 ResNet보다 6개 데이터셋(HE, JA, HI, AL, EP, CO)에서 낮은 성능을 보여줌.
Comparing DL models and GBDT
Default 하이퍼파라미터 관점
FT-Transformer 앙상블이 대부분 GBDT 앙상블을 능가
default FT-Transformer 앙상블이 tuned FT-Transformer 앙상블과 거의 비슷한 성능을 보임.
Tuned 하이퍼파라미터 관점
제대로 튜닝하면 GBDT가 일부 데이터셋(CA, AD, YA)에서 우위를 점하고, 그 격차가 유의미하다.
즉 딥러닝이 GBDT를 보편적으로 이기는 것은 아니다. 또한 벤치마크 자체가 약간 “DL-friendly”하게 편향됐을 수 있음을 저자들도 인정함.
GBDT는 클래스가 많은 다중 분류에는 부적합하다(Helena에서 성능 저조, ALOI는 학습이 너무 느려 튜닝 불가).
6. Analysis
When FT-Transformer is better than ResNet?
두 모델의 성능 차이가 극적으로 변하도록 합성 태스크를 설계했다.
\[x \sim N(0, I_k), \qquad y = \alpha \cdot f_{GBDT}(x) + (1-\alpha)\cdot f_{DL}(x)\]
$f_{GBDT}$: 랜덤하게 구성한 30개 결정 트리의 평균 예측 (GBDT에 유리하게 설계)
$f_{DL}$: 랜덤 초기화된 3개 hidden layer MLP (ResNet에 유리하게 설계)
$\alpha$가 0(=DL-friendly)에서 1(=GBDT-friendly)로 변할 때:
$\alpha$가 작을 때(DL-friendly): ResNet과 FT-Transformer 모두 비슷하게 준수한 성능을 보이며 CatBoost를 능가함.
$\alpha$가 커질 때(GBDT-friendly): ResNet의 상대 성능이 급격히 하락.
반면 FT-Transformer는 전 구간에서 경쟁력을 유지.
이 실험은 “FT-Transformer가 ResNet보다 잘 근사하는 함수 유형”이 존재함을 보여주며, 그 함수가 결정 트리 기반이라는 점이 관찰(= GBDT가 우세한 데이터셋에서 FT-Transformer의 개선이 가장 확실함)과 일치한다.
Obtaining feature importances from attention maps
FT-Transformer의 어텐션 맵을 feature importance의 원천으로 활용할 수 있다. $i$번째 샘플에 대해 [CLS] 토큰의 평균 어텐션 맵 $p_i$를 구하고, 이를 전체 샘플에 대해 평균낸다:
\[p = \frac{1}{n_{samples}} \sum_i p_i, \qquad p_i = \frac{1}{n_{heads} \times L} \sum_{h,l} p_{ihl}\]
($p_{ihl}$ = $l$번째 레이어, $h$번째 head의 [CLS] 어텐션 맵)
장점은 효율성 — 샘플당 forward 한 번이면 된다. 이를 Integrated Gradients(IG), Permutation Test(PT)와 비교(PT 랭킹과의 rank correlation 측정):
단순 어텐션 맵 평균이 IG와 비슷한 수준의 feature importance를 준다. IG는 수십~수백 배 느릴 수 있고 PT는 (feature 수 + 1)번의 forward가 필요한 반면, 이 방법은 forward 한 번이면 되므로 비용 대비 효과가 좋은 선택이다. (단, IG와 성능이 비슷하다는 게 IG와 importance가 동일하다는 뜻은 아니다.)
그 외
FT-Transformer는 ResNet보다 느리다. feature가 많은 Yahoo(699개)에서는 약 13.8배 오버헤드가 발생 — 어텐션의 이차 복잡도 탓.
FT-Transformer는 랜덤 샘플링된 몇 개 구성만으로도 좋은 지표에 도달한다. 즉 FT-Transformer의 강한 성능이 “더 오래 튜닝해서”가 아니라는 것도 확인된다. 반대로 다른 알고리즘은 iteration을 크게 늘려도 의미 있는 개선이 없었다.
Conclusion
이 논문은 Tabular 데이터에서 딥러닝 모델의 성능을 확인하고, 베이스라인 수준을 향상시켰다.
ResNet 계열의 단순한 구조가 효과적인 베이스라인이 될 수 있음을 보였다.
FT-Transformer를 제안했고, 대부분의 태스크에서 다른 딥러닝 모델을 능가했다.
이 새로운 베이스라인들을 GBDT와 비교한 결과, 일부 태스크에서는 여전히 GBDT가 우위를 지킨다는 점도 확인했다.
-
[ML] XGBoost
참고한/사용된 자료
Krish Naik의 ‘Complete Data Science,Machine Learning,DL,NLP Bootcamp’ 강의 내용을 참고하여 작성한 글입니다.
Udemy
강의자료(Github)
XGBoost
XGBoost(Extreme Gradient Boosting)는 Gradient Boosting을 기반으로, 여기에 정규화(Regularization)와 효율적인 분기 탐색 방식을 더해 성능과 속도를 개선한 알고리즘이다. Gradient Boosting과 마찬가지로 여러 개의 Weak Learner(Tree)를 순차적으로 학습시켜 이전 모델의 잔차를 보완해나가는 큰 틀은 동일하지만, 트리를 어떤 기준으로 분기할지, 그리고 각 리프(Leaf)의 예측값을 어떻게 정할지를 정의하는 방식이 조금 다르다.
학습 과정 (Regressor 기준)
먼저, 모든 샘플에 대해 동일한 초기 예측값에서 시작한다. Gradient Boosting과 마찬가지로 첫 Base Model은 보통 예측 레이블의 평균값을 출력하며, 그 후 실제값과 초기 예측값(평균)의 차이인 잔차 $R_1$를 계산한다.
다음으로 의사결정 트리를 생성하는데, 원래의 타겟값이 아닌 이 잔차를 예측하도록 학습된다. 모든 데이터 $(X, R_1)$를 가진 하나의 Root Node에서 시작해, 가능한 모든 (Feature, threshold) 조합에서 데이터를 둘로 나눴을 때의 Gain을 계산한다.
Gain을 계산하기 위해서 Similarity Score를 계산해야 한다. Similarity Score는 한 노드 안에 모인 잔차($R_1$)들이 얼마나 “일관된 방향을 가리키는지”를 나타내는 지표다. 잔차들이 서로 비슷한 방향(부호와 크기)을 가질수록 값이 커진다.
\[\text{Similarity Score} = \frac{\left(\displaystyle\sum_{i=1}^{n} r_i\right)^2}{n + \lambda}\]
$r_i$ : 해당 노드에 속한 $i$번째 샘플의 잔차
$n$ : 해당 노드에 속한 샘플 수
$\lambda$ : 정규화(Regularization) 파라미터
Gain은 하나의 노드를 둘(Left/Right)로 나눴을 때, 그 분기가 얼마나 좋은 선택인지를 나타내는 지표다. 분기 전(Root)보다 분기 후(Left + Right)의 Similarity 합이 클수록 좋은 분기다.
\[\text{Gain} = \text{Similarity}_{\text{Left}} + \text{Similarity}_{\text{Right}} - \text{Similarity}_{\text{Root}}\]
이렇게 각 (Feature, threshold) 조합에 대해 구한 트리에서 Gain이 가장 큰 조합으로 트리를 분기한다. 이 과정을 트리가 정해진 깊이(max_depth)에 도달하거나 더 이상 나눌 데이터가 없을 때까지 반복한다. 트리가 완성되면, 이렇게 만들어진 분기들 중 불필요한 것을 가지치기한다.
※ 만약 가장 좋은 Gain조차 정해둔 임계값 $\gamma$보다 작다면(즉 $\text{Gain} - \gamma < 0$), 그 분기는 가지치기한다. $\gamma$는 트리가 너무 잘게 쪼개지는 것을 막는 Hyperparameter로, 트리를 최대 깊이까지 모두 성장시킨 뒤 가지치기(post-pruning)를 적용한다.
트리가 생성되었으면 각 리프(Leaf)의 출력값을 계산한다. 데이터가 들어오면 기준에 따라 leaf node에 도달하게 되고, 도달한 노드에 속한 잔차들을 바탕으로 그 리프의 최종 출력값을 계산한다. 회귀의 경우 아래 식처럼 정규화 항이 포함된 평균값을 사용한다.
\[\text{Output Value} = \frac{\displaystyle\sum_{i=1}^{n} r_i}{n + \lambda}\]
이렇게 구한 트리의 예측값에 Learning Rate $\alpha$를 곱하여 반영한다.
\[\hat{y} \leftarrow \hat{y} + \alpha \cdot (\text{Output Value})\]
이렇게 갱신된 예측값을 기준으로 잔차를 다시 계산하고, 같은 과정을 반복해 다음 트리를 학습시킨다. 이 과정을 정해진 트리 개수만큼 반복하며 모델 전체를 학습시킨다.
※ Similarity Score, Output value 수식 모두 분모에 $\lambda$가 들어있다. $\lambda$가 커질수록 두 값 모두 작아지는데, 이는 노드에 속한 샘플 수($n$)가 적거나 잔차 합이 우연히 크게 나온 경우(이상치의 영향 등)에 모델이 과도하게 확신을 갖지 않도록 억제하는 효과를 낸다.
XGBoost Classifier
log-odds
Classifier의 차이를 이해하려면 먼저 log-odds 개념을 짚고 넘어가야 한다. log-odds는 확률 $p$를 아래처럼 변환한 값이다.
\[\text{log-odds} = \log\left(\frac{p}{1-p}\right)\]
확률 $p$는 항상 $[0, 1]$ 사이 값이어야 하지만, log-odds는 $-\infty$부터 $+\infty$까지 자유로운 실수값을 가진다. Boosting은 여러 트리의 출력값을 계속 더해나가는 방식인데, 만약 이 덧셈을 확률 공간에서 그대로 하면 값이 금방 [0, 1]에서 벗어나 확률로서의 의미가 깨진다. 그래서 트리의 출력을 확률이 아닌 log-odds 공간에서 자유롭게 더하고, 확률이 필요한 순간에만 sigmoid 함수로 변환한다.
\[p = \frac{1}{1 + e^{-\text{log-odds}}}\]
예를 들어 $p=0.5$일 때 log-odds는 정확히 0이고, $p$가 1에 가까워질수록 log-odds는 $+\infty$로, 0에 가까워질수록 $-\infty$로 발산한다.
Classifier의 차이점
Classifier의 전체 흐름(잔차 계산 → 분기 → Output Value 계산 → 예측값 업데이트)은 Regressor와 동일하지만, 다루는 값의 형태와 일부 수식이 달라진다.
초기 예측값: 회귀에서는 보통 평균값에서 출발하지만, 분류에서는 확률 0.5, 즉 log-odds값 0에서 출발한다.
잔차: 실제 클래스($y_i \in {0, 1}$)와 예측 확률 $p_i$의 차이로 정의된다.
\[r_i = y_i - p_i\]
Similarity Score / Output Value의 분모: 회귀에서는 단순히 샘플 개수 $n$을 사용했지만, 분류에서는 각 샘플의 $p_i(1-p_i)$ 값을 모두 더한 값으로 바뀐다. 이는 로지스틱 손실 함수를 사용하기 때문에 나타나는 차이로, 예측 확률이 0.5에 가까워 불확실한 샘플일수록 이 값이 커진다.
\[\text{Similarity Score} = \frac{\left(\displaystyle\sum_{i=1}^{n} r_i\right)^2}{\displaystyle\sum_{i=1}^{n} p_i(1-p_i) + \lambda}\]
\[\text{Output Value} = \frac{\displaystyle\sum_{i=1}^{n} r_i}{\displaystyle\sum_{i=1}^{n} p_i(1-p_i) + \lambda}\]
예측값 업데이트와 확률 변환: Output Value는 확률이 아니라 log-odds 공간에 더해진다. 즉 log-odds를 업데이트한 뒤, 이를 다시 시그모이드 함수로 변환해야 확률값을 얻을 수 있다.
\[\text{log-odds} \leftarrow \text{log-odds} + \alpha \cdot (\text{Output Value})\]
\[p = \frac{1}{1 + e^{-\text{log-odds}}}\]
정리하면, Regressor와 Classifier의 차이는 결국 “분모에 어떤 값을 쓰는가”와 “결과값을 어느 공간(실수값 vs log-odds)에서 다루는가” 두 가지로 요약할 수 있다. 트리를 분기하고 Gain을 비교해 가장 좋은 구조를 찾는 핵심 로직 자체는 두 경우 모두 동일하다.
-
[ML] Gradient Boosting
참고한/사용된 자료
Krish Naik의 ‘Complete Data Science,Machine Learning,DL,NLP Bootcamp’ 강의 내용을 참고하여 작성한 글입니다.
Udemy
강의자료(Github)
Gradient Boosting 소개
Gradient Boosting은 Ensemble 기법 중 Boosting 계열에 속하는 알고리즘이다. AdaBoost와 마찬가지로 여러 개의 Weak Learner를 순차적으로 학습시켜 하나의 Strong Learner를 만들어내지만, 순차적으로 보완하는 대상이 다르다. AdaBoost가 샘플의 가중치를 조정해가며 이전에 틀렸던 샘플에 더 집중하는 방식이라면, Gradient Boosting은 이전 모델이 설명하지 못한 잔차(residual)를 다음 Weak Learner가 직접 학습하도록 하는 방식으로 작동한다.
기본 아이디어
먼저 아주 단순한 초기 예측값(예: 전체 타겟값의 평균)으로 모델을 시작한다. 첫 Weak Learner는 모든 입력 $X$에 대해 타겟값의 평균을 항상 내놓는다.
이 초기 예측값과 실제값의 차이, 즉 잔차 $R_1$을 계산한다. 다음 Weak Learner는 원래의 타겟값이 아닌 이 잔차를 예측하도록 학습된다.
새로 학습된 Weak Learner의 예측값(잔차)을 기존 예측값 $\hat{y}$에 더해 전체 예측을 업데이트한다. 이때 예측한 값을 그대로 더하는 것이 아니라, Learning Rate $\alpha$를 곱하여 더한다.
이 과정을 정해진 횟수만큼 반복하면서, 매 라운드마다 잔차를 조금씩 줄여나가는 방식이다.
즉 각 Weak Learner는 “정답이 무엇인가”를 직접 맞히는 것이 아니라, “지금까지의 모델이 아직 설명하지 못한 오차가 무엇인가”를 맞히는 역할을 한다. 라운드가 거듭될수록 잔차는 점점 작아지고, 전체 모델의 최종 예측값은 실제값에 점점 가까워진다.
\[F(x) = \sum_{t=1}^{T} \alpha_t \, h_t(x)\]
$h_t(x)$ : $t$번째 Weak Learner의 예측값
$\alpha_t$ : $t$번째 Weak Learner의 가중치(Learning Rate)
$T$ : 전체 반복(Weak Learner) 횟수
왜 ‘Gradient’라는 이름이 붙었는가
일반적인 경사하강법(Gradient Descent)은 파라미터 $\theta$를 손실 함수의 그래디언트 반대 방향으로 조금씩 이동시키며 손실을 줄여나가는 방법이다.
\[\theta \leftarrow \theta - \eta \cdot \frac{\partial L}{\partial \theta}\]
Gradient Boosting은 이와 같은 아이디어를 가중치가 아니라 예측값 $\hat{y}$ 자체에 적용한 것이다. 회귀 문제에서 자주 쓰는 손실 함수인 제곱오차(Squared Error)를 예로 들어보자.
\[L(y, \hat{y}) = \frac{1}{2}(y - \hat{y})^2\]
이 손실 함수를 예측값 $\hat{y}$에 대해 미분하면 다음과 같다.
\[\frac{\partial L}{\partial \hat{y}} = -(y - \hat{y})\]
여기에 음수를 붙인 negative gradient는,
\[-\frac{\partial L}{\partial \hat{y}} = y - \hat{y} = \text{잔차}\]
즉 잔차 자체가 손실 함수의 negative gradient와 정확히 같다. 그래서 앞서 살펴본 “다음 Weak Learner가 잔차를 학습해서 예측값에 더한다”는 과정은, 사실 “예측값 $\hat{y}$를 손실이 줄어드는 방향(negative gradient 방향)으로 한 걸음 이동시킨다”는 것과 동일한 의미다.
\[\hat{y} \leftarrow \hat{y} + \alpha \cdot \left(-\frac{\partial L}{\partial \hat{y}}\right)\]
여기서 앞서 등장했던 Learning Rate $\alpha$는, 일반적인 경사하강법의 학습률과 정확히 같은 역할을 한다. 즉 Gradient Boosting이라는 이름은 “예측값을 파라미터처럼 취급하고, 손실 함수의 그래디언트를 따라 조금씩 개선한다”는 학습 방식 자체를 그대로 표현한 이름이다.
이렇게 손실 함수의 관점에서 바라보면, 손실 함수를 제곱오차가 아닌 다른 함수(예: 분류 문제의 로그 손실)로 바꾸는 것만으로 같은 프레임워크를 회귀, 분류 등 다양한 문제에 적용할 수 있다.
실제로 AdaBoost는 지수 손실 함수를 최소화하는 특수한 경우로 볼 수 있고, Gradient Boosting은 이를 임의의 손실 함수로 일반화한 프레임워크라고 볼 수 있다. (AdaBoost는 Gradient Boosting의 한 특수 사례로 해석할 수 있다.)
-
[ML] AdaBoost
참고한/사용된 자료
Krish Naik의 ‘Complete Data Science,Machine Learning,DL,NLP Bootcamp’ 강의 내용을 참고하여 작성한 글입니다.
Udemy
강의자료(Github)
1. AdaBoost 소개
AdaBoost(Adaptive Boosting)는 Ensemble 기법 중 Boosting 계열에 속하는 알고리즘이다. 성능이 약한 여러 개의 Weak Learner를 순차적으로 학습시켜, 이들을 결합해 하나의 Strong Learner를 만들어내는 것이 핵심 아이디어다.
Random Forest와 같은 Bagging 계열 알고리즘이 여러 모델을 독립적으로 학습시킨 뒤 다수결(또는 평균)로 결과를 결정하는 것과 달리, AdaBoost는 Weak Learner를 순차적으로 학습시키면서 이전 모델이 잘못 예측한 데이터에 가중치를 부여해 다음 모델이 이를 보완하도록 만든다. 즉, 각 모델이 독립적이지 않고 이전 모델의 결과에 종속적으로 학습된다는 점이 가장 큰 차이다.
Bias-Variance 관점에서 보는 AdaBoost
“High Bias, Low Variance 상태에서 Low Bias, High Variance 상태로 이동한다”는 분산이 커지는게 언뜻 성능 저하처럼 보일 수 있지만, 실제로는 그렇지 않다.
모델의 일반화 오차는 대략 다음과 같이 분해할 수 있다.
\[전체 오차 ≈ Bias² + Variance + 줄일 수 없는 노이즈\]
우리가 궁극적으로 줄여야 하는 것은 분산 그 자체가 아니라 이 오차의 합이라는 점이 핵심이다.
Weak Learner 1개: 단순한 모델은 데이터를 충분히 학습하지 못해 Bias가 크다. 대신 모델 구조가 단순해서 학습 데이터가 조금 바뀌어도 예측이 크게 흔들리지 않으므로 Variance는 작다.
AdaBoost의 결합 과정: Weak Learner를 순차적으로 이어 붙여 이전 모델이 틀린 부분에 가중치를 부여해 학습하기 때문에, 모델 전체의 표현력(capacity)이 커지고 Bias가 줄어든다. 다만 표현력이 커진 만큼 학습 데이터의 패턴에 더 민감해지므로 Variance는 다소 증가한다.
표현력(capacity)이란?
모델이 표현할 수 있는 함수/결정 경계(decision boundary)가 얼마나 복잡한가를 뜻한다. Weak Learner 1개는 단순한 경계만 그릴 수 있다면, 이런 Weak Learner를 여러 개 결합하면 각 경계들이 겹쳐지면서 훨씬 복잡한 결정 경계를 만들 수 있게 된다. 즉 Weak Learner를 더할수록 앙상블 전체의 표현력이 커지는 것이다.
이때 Bias가 줄어드는 폭이 Variance가 늘어나는 폭보다 훨씬 크기 때문에, 전체 오차는 오히려 감소하고 앙상블의 예측 성능은 개별 Weak Learner보다 좋아진다. 따라서 “Low Bias, High Variance”라는 표현은 Weak Learner 하나와 비교했을 때 상대적으로 그렇다는 의미이지, Variance가 안좋은 수준까지 높아진다는 뜻은 아니다.
물론 Weak Learner 개수를 무한정 늘린다고 성능이 계속 좋아지는 것은 아니다. 어느 시점부터는 Bias는 거의 줄어들지 않고 Variance만 커지는 구간이 나타나는데, 이것이 바로 과적합(Overfitting)으로 이어진다. 그래서 실전에서는 반복 횟수를 적절히 제한하거나, Learning Rate를 통해 각 Weak Learner의 기여도를 낮추는 방식으로 Variance 증가를 통제한다.
Random Forest와의 비교
구분
Random Forest (Bagging)
AdaBoost (Boosting)
학습 방식
독립적 · 병렬 학습
순차적 학습
결과 결합 방식
다수결 / 평균
가중 합
개별 모델 특성
Low Bias, High Variance (깊은 트리)
High Bias, Low Variance (Weak Learner)
앙상블의 목적
Variance 감소
Bias 감소
두 알고리즘 모두 “적당한 Bias + 적당한 Variance”를 만들기 위한 알고리즘이며, Random Forest는 이미 Variance가 큰 모델들을 평균 내어 Variance를 낮추는 전략이고, AdaBoost는 Bias가 큰 모델들을 순차적으로 보완해 Bias를 낮추는 전략이다.
2. AdaBoost 설계
Weak Learner: Decision Stump
일반적으로 Boosting 계열 앙상블 모델에서 Weak Learner로는 Decision Tree가 많이 사용되며, AdaBoost에서는 그중 가장 단순한 형태인 깊이가 1인 Decision Tree를 사용한다. 이를 Decision Stump라고 부른다.
깊이가 1이라는 것은 Root Node에서 딱 한 번의 분기만 일어난 형태이다. 즉 전체 Feature 중 단 하나의 Feature와 하나의 기준값(threshold)만으로 데이터를 두 그룹(Yes/No)으로 나누는, 가장 얕은 형태의 분류기다. 그만큼 단독으로는 예측 성능이 낮지만(High Bias), 계산이 빠르고 특정 데이터에 과하게 민감하게 반응하지 않는다는(Low Variance) 장점이 있다. 앞서 살펴본 것처럼 AdaBoost는 이런 Decision Stump를 여러 개 순차적으로 쌓아 올려 Bias를 낮춰가는 구조다.
최종 예측 수식
최종 분류 결과 F(x)는 각 Weak Learner의 예측값에 가중치를 곱해 더한 형태로 표현된다.
\[F(x) = α₁ · m₁(x) + α₂ · m₂(x) + ... + αₙ · mₙ(x)\]
$mₜ(x)$: t번째 Weak Learner(Decision Stump)가 입력 x에 대해 내놓은 예측값. 이진 분류 문제에서는 보통 +1 또는 -1의 값을 갖는다.
$αₜ$: t번째 Weak Learner의 가중치(Performance of Stump). 해당 Stump가 학습 데이터를 얼마나 잘 맞췄는지에 따라 결정되며, 오차율이 낮을수록(=잘 맞출수록) 값이 커지고, 오차율이 높을수록 최종 예측에 미치는 영향력은 작아진다.
즉 모든 Stump가 동등한 영향력을 행사하는 것이 아닌, 더 잘 맞춘 Stump가 더 큰 영향을 가진다는 것이 AdaBoost 결합 방식의 핵심이다.
3. AdaBoost 작동 과정
Decision Stump 생성
Decision Stump를 만들기 위해 기준이될 Feature를 무작위로 고르는 것이 아니라, 모든 Feature와 가능한 모든 분기 기준(threshold)을 탐색하여 가중 오차(weighted error, Total Error)가 가장 작은 조합을 선택한다. 완전 탐색에 가까운 방식이다.
먼저 데이터의 각각의 샘플은 가중치를 가지고 있다. 맨 처음에는 모든 샘플의 가중치가 동일하게 $w_i = 1/N$로 설정되며, 이후에도 전체 가중치의 합이 항상 1이 되도록 정규화가 유지된다.
이후 아래의 과정을 통해 매 반복마다 Decision Stump를 생성한다.
모든 Feature를 하나씩 후보로 검토한다.
각 Feature에 대해 데이터를 정렬한 뒤, 가능한 분기 기준(threshold) 후보를 만든다. 보통 정렬된 값들 사이의 중간점(midpoint)을 후보로 삼는다.
각 (Feature, threshold) 조합마다 그 기준으로 데이터를 Yes/No 두 그룹으로 나눴을 때의 Weighted Error를 계산한다.
모든 (Feature, threshold) 조합 중 가중 오차가 가장 낮은 조합을 최종 Stump로 선택한다.
Total Error(Weighted Error)는 일반적으로 다음과 같이 정의된다.
\[\text{Total Error} = \frac{\displaystyle\sum_{i \,:\, \hat{y}_i \neq y_i} w_i}{\displaystyle\sum_{i=1}^{N} w_i}\]
AdaBoost에서는 매 반복마다 샘플 가중치의 총합이 1이 되도록 정규화하기 때문에, 분모가 항상 1이 되어 다음과 같이 단순화된다.
\[\text{Total Error} = \sum_{i \,:\, \hat{y}_i \neq y_i} w_i\]
$w_i$ : $i$번째 샘플의 가중치
$\hat{y}_i \neq y_i$ : 예측값과 실제값이 다른, 즉 오분류된 샘플
$N$ : 전체 샘플 개수
강의자료에서는 샘플 1개가 오분류 되어 $1/7$이 Total Error가 되었다.
선택 로직 자체는 매 반복마다 동일하지만, 맨처음에는 모든 샘플의 가중치가 동일($w_i = 1/N$)하다는 점에서 첫 번째 Stump는 전체 데이터셋 기준으로 가장 잘 나누는 Feature와 threshold를 찾는 과정이다.
두 번째 반복부터는 이전 라운드에서 틀린 샘플의 가중치가 커진 상태이기 때문에, 같은 탐색 과정을 거치더라도 틀렸던 샘플들을 더 잘 맞추는 방향으로 다른 Feature/threshold가 선택될 가능성이 높아진다.
Performance of Decision Stump
Total Error는 위에서처럼 (Feature, threshold) 조합의 Stump를 선택하는 기준임과 동시에 Performance of Stump($\alpha$)를 계산하는데 필요한 값이다.
Performance of Stump($\alpha$)는 다음과 같이 구한다:
\[\alpha = \frac{1}{2} \ln\left(\frac{1 - \text{Total Error}}{\text{Total Error}}\right)\]
Updating Weights
이제 각 샘플의 분류 결과에 따라 정분류된 샘플의 가중치는 줄이고, 오분류된 샘플의 가중치는 증가시킨다.
샘플 $i$의 새 가중치는 다음과 같이 계산한다. (분류 기준 설명)
\[w_i \leftarrow w_i \cdot e^{-\alpha \, y_i \, h(x_i)}\]
여기서 $y_i$는 실제 레이블, $h(x_i)$는 이번 Stump의 예측값으로 둘 다 $+1$ 또는 $-1$ 값을 갖는다. 이 식은 정분류/오분류 여부에 따라 아래처럼 두 가지 경우로 나뉜다.
\[w_i \leftarrow
\begin{cases}
w_i \cdot e^{-\alpha} & (\text{정분류된 경우}) \\
w_i \cdot e^{\alpha} & (\text{오분류된 경우})
\end{cases}\]
$\alpha > 0$인 일반적인 상황을 기준으로 보면, 정분류된 샘플은 $e^{-\alpha} < 1$이 곱해져 가중치가 줄어들고, 오분류된 샘플은 $e^{\alpha} > 1$이 곱해져 가중치가 커진다. 이렇게 갱신된 가중치는 전체 합으로 나누어 다시 총합이 1이 되도록 정규화한다.
\[w_i \leftarrow \frac{w_i}{\displaystyle\sum_{j=1}^{N} w_j}\]
Boosting by Reweighting
다음 Weak Learner에게 데이터를 전달할 때, 데이터셋 자체는 그대로 두고 각 샘플의 가중치를 계속 누적해서 업데이트하는 방식이다.
다음 Weak Learner는 이 가중치를 직접 반영한 가중 손실(weighted Gini, weighted error 등)을 계산해 위 과정을 반복한다. 가중치는 매 반복마다 리셋되지 않고, 이전 값 위에 이어서 갱신되는 방식이다.
Boosting by Resampling
강의자료에서 설명하는 방식으로, 가중치를 확률분포로 변환한 뒤(누적합으로 Bins를 만들고) 그 분포를 따라 N개의 샘플을 복원추출(bootstrap)한다.
이렇게 하면 가중치가 큰(=Error가 컸던) 샘플일수록 뽑힐 확률이 높아지고, 실제로 다음 데이터셋에 여러 번 중복돼서 포함된다. 그 결과 다음 Weak Learner는 이전에 틀렸던 샘플들을 자연스럽게 더 많이 마주치며 학습하게 된다.
새로 뽑힌 데이터셋 안에서는 중요한 샘플일수록 이미 여러 번 중복되어 들어가 있으므로, 가중치로 별도의 중요도를 표시할 필요가 없다. 그래서 다음 learner는 이 새 데이터셋을 대상으로 모든 샘플이 다시 균등한 가중치($w_i = 1/N$)에서 시작한다.
두 방식은 같은 목표를 다르게 구현한 것이다. “가중치가 큰 샘플에게 다음 라운드에서 더 큰 영향력을 준다”는 목표는 동일하지만, Reweighting은 가중치를 숫자로 직접 전달하는 방식이고 Resampling은 그 가중치만큼 샘플을 물리적으로 복제해서 전달하는 방식이다. 표본 크기가 충분히 크다면 기댓값 관점에서 두 방식은 동등한 효과를 낸다. 다만 Resampling은 복원추출 과정에서 추가적인 무작위성(sampling noise)이 개입되어, 운이 나쁘면 중요한 샘플이 이번 라운드에 하나도 뽑히지 않을 수도 있다. 이 때문에 scikit-learn 등 실제 구현체 다수는 Resampling 대신 Reweighting(가중 손실 기반 분기)을 사용한다.
Final Prediction
이후의 학습 과정은 지금까지의 과정(Decision Stump 생성 → Performance 계산 → Weight Update → 다음 데이터셋 구성)을 정해진 반복 횟수 $T$만큼 반복하는 것이다. 매 반복마다 새로 구성된 데이터셋을 받은 Weak Learner는 다시 균등한 가중치로 시작해 같은 과정을 되풀이한다.
이렇게 $T$개의 Decision Stump $m_1, m_2, \dots, m_T$와 각각의 Performance $\alpha_1, \alpha_2, \dots, \alpha_T$가 모두 구해지면, 앞서 다룬 최종 예측 수식에 따라 이들을 가중합한 뒤 부호(sign)를 취해 최종 클래스를 결정한다.
※ 이 방식은 이진 분류 기준이며, 다중 클래스 분류의 경우 다른 방식의 확장이 필요하다
\[F(x) = \text{sign}\left(\sum_{t=1}^{T} \alpha_t \, m_t(x)\right)\]
-
[ML] Decision Tree & Random Forest
참고한/사용된 자료
위키백과
https://process-mining.tistory.com/42
Data Science Diary
1. Decision Tree
Decision Tree는 Tree 구조를 활용해 데이터의 Entropy와 불순도(Impurity)가 최소화되는 방향으로 분기하며 분류 혹은 회귀를 수행하는 지도학습 알고리즘이다.
여러 레이블, 혹은 여러 분포의 값이 섞여있는 데이터에서 시작하여 트리의 leaf node에 도달하면 최대한 어떤 범주의 데이터들로만 구성되도록 데이터를 나누는 것이다.
불순도(Impurity)
불순도란 어떤 데이터 집합에 서로 다른 범주(Class)의 데이터가 얼마나 섞여 있는지를 나타내는 수치이다. 수치가 높을수록 여러 범주의 데이터가 섞여 있는 상태이고, 반대로 수치가 0에 가까울수록 하나의 범주로만 깔끔하게 구성된 순수한 상태(Pure)를 의미한다.
의사결정나무는 자식 노드로 분기할 때 이 불순도를 최대한 줄여나가는 방향으로 기준을 설정한다. 불순도를 측정하는 대표적인 지표로 엔트로피(Entropy)와 지니 불순도(Gini Impurity)가 있다.
Entropy
어떤 노드 $S$에 대한 엔트로피는 다음과 같은 수식으로 계산한다.
\[Entropy(S) = - \sum_{i=1}^{c} p_i \log_2(p_i)\]
$c$ : 데이터의 범주(Class) 개수
$p_i$ : 해당 노드에서 $i$번째 범주가 차지하는 비율 (확률)
엔트로피는 0~1 사이의 값을 가지며 데이터가 50:50 비율일때 최댓값을 가진다.
지니 불순도(Gini Impurity)
지니 불순도는 데이터 집합에서 하나의 데이터를 뽑아 무작위로 레이블을 예측할 때, 잘못 분류될 확률을 의미한다.
임의로 뽑은 데이터가 실제 $i$번째 범주일 확률 = $p_i$
내가 그 데이터를 $i$번째 범주라고 무작위로 예측할 확률 = $p_i$
그렇다면 이 예측이 적중(맞을) 확률 = $p_i \times p_i = p_i^2$
모든 범주에 대해 예측이 전체적으로 맞을 확률 = $\sum_{i=1}^{c} p_i^2$
우리가 원하는 건 ‘순수한 정도’가 아니라 ‘불순한 정도(틀릴 확률)’이므로, 전체 확률인 1에서 맞을 확률을 뺀다.
\[Gini(S) = 1 - \sum_{i=1}^{c} p_i^2\]
엔트로피와 마찬가지로 데이터의 순도가 높을수록 0에 가까워지며, 50:50 비율일때 최댓값 0.5를 가진다.
Information Gain
정보 획득량(Information Gain)은 어떤 특징을 기준으로 데이터를 분할했을 때, 분할 전후로 불순도가 얼마나 감소했는가를 나타내는 지표이다. 불순도의 감소 수준을 나타내므로 Information Gain이 높을수록 좋은 분할이라는 것을 알 수 있다.
⇒ 결정 트리 알고리즘은 정보 획득을 최대화하는 방향으로 학습이 진행
엔트로피를 사용하여 설명하면, 정보 획득량은 부모 노드의 엔트로피에서 자식 노드들의 엔트로피 가중 평균을 뺀 값으로 계산한다.
\[IG(S, A) = Entropy(S) - \sum_{v \in Values(A)} \frac{|S_v|}{|S|} Entropy(S_v)\]
$S$ : 분할하기 전 부모 노드의 전체 데이터 집합
$A$ : 데이터를 분할하는 기준 (속성)
$S_v$ : 속성 $A$의 조건에 따라 분할된 자식 노드의 데이터 집합
$|S|$ : 부모 노드의 전체 데이터 개수
$|S_v|$ : 자식 노드의 데이터 개수
가지치기(Pruning)
트리의 분할이 너무 많아진다면 과적합이 발생할 수 있다. 트리의 최대 깊이, Leaf Node의 개수, 한 노드의 최소 데이터 수를 제한하여 적절한 크기의 트리가 만들어지도록 한다.
사전 가지치기 (Pre-Pruning): 트리가 생성될때부터 최대 깊이, 최소 데이터 수 제한 등으로 성장을 미리 멈춤. (데이터셋의 크기가 클때 사용하여 시간소요를 줄임)
사후 가지치기 (Post-Pruning): 트리를 완전히 성장시킨 후, 성능 향상에 크기 기여하지 않는 가지들을 제거하여 단순화. (시간 소요와 비용이 크기 때문에 적은 데이터셋을 다룰때 사용)
2. Ensemble, Bagging, Boosting
Ensemble
Ensemble(앙상블)은 머신러닝에서 여러 개의 개별 모델을 결합하여 하나의 더 강력한 모델을 만드는 기법이다. 단일 모델이 아닌 여러 모델의 예측을 결합하여 성능을 높이는 방식이며, 약한 모델(weak learner) 여러 개를 조합하면 더 강력한 모델(strong learner)이 될 수 있다는 것을 보여준다.
대표적인 앙상블 기법으로 배깅(Bagging)과 부스팅(Boosting)이 있다.
Bagging
Bagging(Bootstrap Aggregating) 은 여러 모델을 ‘병렬’로 학습시키는 기법이다.
Bootstrap (데이터 분할): 원본 데이터셋에서 복원 추출(중복 허용)을 통해 랜덤하게 여러 개의 서브 데이터셋을 생성한다.
각각의 데이터셋으로 동일한 알고리즘의 모델을 여러개 독립적으로 학습시킨다.
Aggregating (결과 종합): 각 모델의 예측 결과를 종합하여 최종 예측값을 결정.
결과적으로 모델의 분산을 줄이고 일반화 성능을 높이는 효과가 생긴다. 비유하자면 학생들이 혼자 시험을 풀고, 이후 다수결로 정답을 고르는 것이다. 각자 틀리는 문제는 다를 수 있지만, 함께 하면 정답률이 올라간다.
Boosting
배깅이 병렬의 성질을 가졌다면, 부스팅은 여러 모델을 ‘직렬(순차적)’로 연결하여 학습하는 기법이다.
첫 번째 모델이 전체 데이터를 학습하고 예측을 수행.
이때 예측이 틀린 데이터에 더 큰 가중치를 부여한다.
두 번째 모델은 앞선 모델이 틀린 데이터에 더 집중하여 학습을 진행.
이 과정을 순차적으로 반복하며 이전 모델의 약점을 계속해서 보완해 나간다.
앞선 모델의 실수를 다음 모델이 고쳐나가는 방식이므로 모델의 편향(Bias)을 줄이고 예측 정확도를 극대화하는 데 매우 효과적이다. 단, 순차적으로 학습해야 하므로 속도가 상대적으로 느리고 과적합에 빠질 위험이 있다.
3. Random Forest
Random Forest는 여러 개의 Decision Tree를 Bagging기법으로 학습시키는 모델이다. 또한 개별 트리들이 서로 너무 비슷해져 앙상블의 효과가 떨어지는 것을 막기 위해 ‘데이터’와 ‘특징(Feature)’에 대해 무작위성을 부여한다.
데이터 무작위성 (Bootstrap Sample): 원본 데이터셋에서 복원 추출을 통해 여러 개의 서브 데이터셋을 무작위로 만든다. (Row 단위의 무작위 추출)
특징 무작위성 (Feature Subset): 트리가 분기할 때, 모든 특징을 고려하는 것이 아니라 일부 특징만 무작위로 선택하여 그중에서 최적의 기준을 찾는다. (Column 단위의 무작위 추출)
만약 압도적으로 중요한 특징이 있다면, 모든 트리가 그 특징 위주로만 분기하여 똑같이 생긴 나무들만 만들어질 것이다. -> 특징 선택에 제한을 두면 다양하고 독립적인 형태의 트리들이 생성될 수 있다.
랜덤 포레스트의 학습과 예측은 다음과 같은 단계로 진행된다.
데이터를 여러 개의 데이터셋으로 분할(Bootstrap)한다.
각각의 데이터셋을 이용해 트리를 만든다. 이때 분기할 때마다 전체 특징(Feature) 중 일부를 무작위로 골라(Subset), 그 안에서 불순도를 가장 많이 줄이는 기준으로 분기한다.
1번과 2번의 과정을 반복하여, 서로 조금씩 다른 형태로 훈련된 여러개의 의사결정트리를 만든다.
각 트리는 Bootstrap 데이터와 선택된 Subset으로 의사결정트리를 만든다.
결과 집계 (Aggregation): 새로운 데이터가 들어오면 모든 트리가 각자 예측을 수행한다. 분류 문제라면 가장 많이 나온 결과를 다수결로 선택하고, 회귀 문제라면 모든 트리의 예측값을 평균(Mean) 내어 최종 예측값을 결정한다.
장점: 단일 의사결정나무의 치명적인 단점인 과적합 문제를 효과적으로 해결하며, 노이즈 데이터에 강하고 예측 성능이 매우 뛰어나다.
단점: 수많은 트리가 복합적으로 작용하므로, 의사결정나무처럼 “왜 이런 결과가 나왔는지” 직관적으로 해석하기가 어렵다.
-
CS231n 7강. Recurrent Neural Networks
Youtube: Stanford CS231N 2025
강의자료
1. Recurrent Neural Network
순환 신경망(RNN)은 입력과 출력을 시퀀스 단위로 처리하는 모델이다. 기존의 Feed-Forward 신경망이 고정된 크기의 입력만 받을 수 있었던 것과 달리, 가변적인 크기의 입력을 받을 수 있고, 데이터의 “순서(Order)”와 “시간적 흐름(Time-step)”을 처리할 수 있는 것이 가장 큰 특징이다.
아래 슬라이드는 RNN의 유형을 입력, 출력의 갯수에 따라 분류한 것이다.
다른 신경망들과 마찬가지로 입력에 대해 가중치(Weight) 연산을 거쳐 출력을 내는 것은 동일하지만, RNN의 가장 큰 차이점은 은닉 상태(hidden state)라는 내부 메모리를 가진다는 점이다.
은닉 상태 $h_t$는 RNN 셀이 다음 시점 $t+1$의 자신에게 보내는 값으로, 해당 시점까지의 과거 정보들을 압축한 벡터(Vector)이다. 이를 현재 시점의 입력값 $x_t$와 함께 사용하여 새로운 결과를 계산한다.
Vanilla RNN의 수식
먼저, RNN의 hidden state update를 점화식으로 표현하면 다음과 같다.
\[h_t = f_W(h_{t-1}, x_t)\]
함수 $f_W$는 파라미터(가중치) $W$를 가지며, 매 타임스텝마다 동일한 함수와 동일한 가중치가 재사용된다.
Hidden State가 아닌 실제 Output을 출력할때는 또 다른 가중치인 $W_{hy}$를 사용한다.
가장 기본적인 형태인 Vanilla RNN의 수식으로 전개하면 아래와 같다.
\[h_t = \tanh(W_{hh}h_{t-1} + W_{xh}x_t)\]
\[y_t = W_{hy}h_t\]
이전 상태의 정보 $h_{t-1}$와 현재의 입력 $x_t$가 각각의 가중치 행렬 $W_{hh}$, $W_{xh}$와 곱해지고, 이 두 값을 더한 뒤, 활성화 함수인( $\tanh$ )를 통과시켜 현재 시점의 은닉 상태 $h_t$를 업데이트한다.
Backpropagation through time (BPTT)
RNN은 시간 흐름에 따라 연산 과정을 펼쳐놓았을 때, 각 시점의 Loss를 이용하여 역전파를 통해 학습한다. 이처럼 시간의 흐름에 거슬러 올라가는 역전파를 Backpropagation Through Time (BPTT)라고 한다.
이후 설명은 Many to Many 유형을 기준으로 설명된다.
\[L = \sum_{t=1}^{T} L_t\]
모든 시점의 출력에 대한 손실을 구해 더한 전체 손실 $L$을 기반으로, 매 시점마다 공유되는 가중치 $W$의 그래디언트를 누적하여 업데이트한다.
이때 핵심은 “모든 시점에서 동일한 가중치 행렬 $W$($W_{hh}, W_{xh}, W_{hy}$)가 공유된다”는 점이다.
일반적인 깊은 신경망(DNN)은 층(Layer)마다 서로 다른 가중치를 가지지만, RNN은 시간에 따라 펼쳐진 각 단계마다 완전히 똑같은 가중치 $W$가 사용된 상태이다. 다변수 함수의 연쇄 법칙에 의해, 동일한 변수 $W$가 여러 결과($L_1, L_2, \dots, L_T$)에 영향을 미쳤다면, 전체 그래디언트는 각 손실이 $W$에 미치는 그래디언트를 모두 더한 값이 된다.
\[\frac{\partial L}{\partial W} = \sum_{t=1}^{T} \frac{\partial L_t}{\partial W}\]
나아가, 특정 시점 $t$의 손실 $L_t$가 가중치 $W$에 미치는 영향($\frac{\partial L_t}{\partial W}$)을 구하려면, 현재 시점 $t$뿐만 아니라 과거의 모든 시점($k=1, 2, \dots, t$)에서 $W$가 은닉 상태에 기여한 영향을 시간 역순으로 전부 거슬러 올라가며 곱하고 더해야 한다.
\[\frac{\partial L_t}{\partial W} = \sum_{k=1}^{t} \frac{\partial L_t}{\partial y_t} \frac{\partial y_t}{\partial h_t} \frac{\partial h_t}{\partial h_k} \frac{\partial h_k}{\partial W}\]
계산 및 업데이트 과정 요약:
시퀀스의 맨 마지막 시점 $T$부터 시작하여 과거 방향(왼쪽)으로 역전파를 진행.
각 시점 $t$에서 역전파되어 내려오는 그래디언트는 두 갈래로 나뉜다:
하나는 아래로 내려가 입력층의 가중치에 대한 그래디언트를 구하고,
다른 하나는 이전 시점 $t-1$의 은닉 상태($h_{t-1}$) 방향으로 흘러감
이렇게 시간의 역방향으로 흘러가는 그래디언트들이 각 시점의 가중치 $W$ 노드를 지나칠 때마다, 해당 위치에서 계산된 그래디언트 값들을 각각의 가중치 행렬( $dW_{hh}, dW_{xh}, dW_{hy}$ )에 계속해서 누적하여 저장.
시퀀스의 맨 처음($t=1$)까지 역전파가 모두 끝나면, 최종적으로 누적된 그래디언트를 이용해 가중치 $W$를 단 한 번 업데이트한다.
Truncated BPTT
이론적으로는 전체 시퀀스에 대해 Full BPTT를 수행하는 것이 맞지만, 실무적으로 시퀀스의 길이($T$)가 매우 길어지면(예: 수천 단어가 넘는 문장이나 긴 비디오) 다음과 같은 문제가 발생한다.
메모리 및 계산량 폭발: 역전파를 수행하려면 순전파 때 계산했던 모든 타임스텝의 은닉 상태($h_t$)와 활성화 값을 메모리에 유지해야 한다. 시퀀스가 길어질수록 필요한 메모리와 계산량이 증가.
기울기 소실/폭발 (Gradient Vanishing / Exploding): 동일한 가중치 행렬 $W$가 $T$만큼 반복해서 곱해지기 때문에, 역전파 과정에서 그래디언트가 사라지거나(Vanishing) 무한대로 커지는(Exploding) 문제가 발생.
이러한 대규모 시퀀스 데이터를 학습시키기 위한 현실적인 해결책으로 Truncated BPTT를 소개한다. 전체 시퀀스를 한 번에 처리하지 않고, 일정한 타임스텝 크기(t=10, 20…)로 쪼개어 학습하는 방식이다.
지정된 청크 크기만큼만 순전파를 진행하고, 해당 구간 내의 시점별 손실($L_t$)을 계산한다.
해당 청크 내에서만 역전파를 수행하여 가중치 $W$를 업데이트.
다음 청크 구간으로 넘어갈 때, 이전 청크의 마지막 은닉 상태 $h_t$ 값을 전달하여 다음 순전파의 초기값으로 사용한다.
다음 청크의 역전파를 계산할 때는 이전 청크로 그래디언트가 넘어가지 않도록 한다. (메모리를 절약하면서 과거의 정보 맥락을 유지)
위의 설명은 매 타임스텝마다 Loss가 발생하는 Many-to-Many를 가정한 것이고, Many-to-One 구조에서는 Loss가 마지막 청크의 최종 시점에만 존재한다.
따라서 마지막 출력층이 포함된 청크의 그래디언트만 계산하고 이전 청크는 아예 업데이트 하지 않거나(엄격한 Truncated BPTT), 출력층 청크의 가장 왼쪽 그래디언트만 메모리에 저장해두고 이전 청크에게 넘겨주는 방식을 적용할 수 있다.(Memory-Efficient BPTT)
Memory-Efficient BPTT
일부 중간 시점의 값만 캐시하여 완전히 그래디언트를 단절시키지 않는 방법을 Memory-Efficient BPTT라고 한다.
전체를 청크로 나누어 순전파를 진행할 때, 내부의 모든 연산 결과를 저장하지 않고 각 청크의 시작 지점의 은닉 상태(이전 청크에서 넘어온 최종 은닉 상태)만 메모리에 저장
마지막 출력층이 포함된 청크에서 최종 Loss를 기반으로 역전파를 수행할 때, 역전파가 해당 청크의 시작점(가장 왼쪽)에 도달하면, 그 위치의 그래디언트를 끊지 않고 저장해둠.
1번 과정에서 저장해 둔 은닉 상태 경계값을 초기값으로 삼아 해당 청크 구간만 순전파를 다시 한 번 계산. 이를 통해 지워졌던 활성화 값들을 복구한 뒤, 2번에서 기억해 둔 그래디언트를 이어받아 역전파를 마저 진행.
이 방식을 사용하면 역전파 시 순전파를 중복해서 다시 계산해야 하므로 연산 시간은 늘어나지만, 메모리 사용량을 단일 청크 크기만큼으로 고정시킨 채 Full BPTT의 효과를 얻을 수 있다.
2. LSTM (Long Short-Term Memory)
Vanilla RNN의 한계
앞서 말했듯이, Vanilla RNN은 시퀀스의 Time step이 길어질수록 과거의 정보가 뒤로 갈수록 점점 손실되는 장기 의존성 문제(Long-term dependency problem)를 겪는다.
역전파(Backpropagation) 과정에서 은닉 상태 $h_t$의 미분값을 구하려면 동일한 가중치 행렬 $W_{hh}$가 타임스텝의 수만큼 거듭제곱($W_{hh}^t$)되어 곱해진다. 이때 가중치 행렬의 가장 큰 특이값(Singular value)이 1보다 크면 기울기가 무한대로 치솟는 Gradient Exploding이, 1보다 작으면 0으로 수렴해버리는 Gradient Vanishing 현상이 발생한다. (보통 활성화 함수인 $\tanh$의 미분값이 1보다 작기 때문에 Vanishing 문제가 빈번하게 발생함.)
이러한 고질적인 Vanilla RNN의 한계를 극복하기 위해 제안된 구조가 LSTM(Long Short-Term Memory)이다.
LSTM(Long Short Term Memory)의 핵심 구조
LSTM은 기존 RNN의 은닉 상태 $h_t$ 외에도, 정보가 단절 없이 흐를 수 있는 셀 상태(Cell State, $c_t$)를 추가로 도입하고, 은닉 상태 또한 계산하는 방식이 조금 달라진다.
“은닉 상태 = 단기기억, 셀 상태 = 장기기억”
Vanilla RNN은 이전 은닉 상태 $h_{t-1}$와 현재 입력 $x_t$을 받아 $\tanh$ 함수를 거쳐 새로운 $h_t$를 만들었다면, LSTM은 이 두 값을 입력으로 받아 4개의 Gate를 통과시키며 정교하게 정보를 조작한다.
이전 은닉 상태 $h_{t-1}$과 현재 입력 $x_t$ 벡터에 각각 다른 가중치 행렬 $W$를 곱하여 4개의 게이트 값을 계산한다. 수식적으로는 표기의 간결함을 위해 두 벡터를 병합하여(Concat) 하나의 큰 가중치 행렬 $W$를 곱하는 것으로 표현한다.
\[\begin{pmatrix} i \\ f \\ o \\ g \end{pmatrix} = \begin{pmatrix} \sigma \\ \sigma \\ \sigma \\ \tanh \end{pmatrix} W \begin{pmatrix} h_{t-1} \\ x_t \end{pmatrix}\]
$i$ (Input Gate): 새로운 정보를 ‘얼마나(How much)’ 반영할지 결정하는 필터이다. 시그모이드($\sigma$)를 통과하여 0~1 사이의 값을 가지며, 클수록 정보를 더 받아들임.
$f$ (Forget Gate): 과거의 정보를 얼마나 잊을지 결정한다. 시그모이드($\sigma$)를 통과하므로 0~1 사이의 값을 가지며, 0에 가까울수록 과거 정보($c_{t-1}$)를 많이 지우고, 1에 가까울수록 정보를 보존.
$g$ (Gate Gate(?)): 셀 상태에 새롭게 기록하고 싶은 ‘정보의 내용물(What)’ 자체이다. $\tanh$를 통과하여 -1~1 사이의 값을 가지며, 어떤 방향(+ 또는 -)으로 상태를 변화시킬지 그 후보값을 나타냄.
$o$ (Output Gate): 업데이트된 최종 셀 상태($c_t$)를 바탕으로, 다음 은닉 상태($h_t$)로 얼마나 내보낼지 결정함.
State 업데이트
위에서 구한 4개의 Gate 값을 이용하여 LSTM은 두 가지 상태(Cell State와 Hidden State)를 업데이트한다. ($\odot$ 기호는 요소별 곱셈(Element-wise multiplication)을 의미.)
1) Cell State 업데이트 ($c_t$)
\[c_t = f \odot c_{t-1} + i \odot g\]
$f \odot c_{t-1}$ (과거 기억 필터링): 이전 셀 상태에 Forget Gate를 곱해 불필요한 과거 기억을 삭제.
$i \odot g$ (새로운 기억 융합): 현재 시점의 정보 내용물($g$)에 Input Gate($i$)의 반영 비율을 곱하여 필터링한 뒤, 그 결과를 과거 기억에 덧셈(+)으로 추가.
2) Hidden State 업데이트 ($h_t$)
\[h_t = o \odot \tanh(c_t)\]
새롭게 완성된 $c_t$를 $\tanh$에 통과시킨 뒤, Output Gate $o$와 곱해 최종적으로 외부(출력층 및 다음 시점)로 보낼 은닉 상태 $h_t$를 계산.
Do LSTMs solve the vanishing gradient problem?
핵심은 Cell State($c_t$)의 덧셈(+) 연산에 있다.
Vanilla RNN은 매 타임스텝마다 가중치 행렬 $W$를 ‘행렬 곱’으로 누적하기 때문에 그래디언트가 소실되었다. 하지만 LSTM의 $c_t$ 업데이트 식($c_t = f \odot c_{t-1} + i \odot g$)을 보면, $c_{t-1}$에서 $c_t$로 넘어갈 때 행렬 곱셈이 아닌 덧셈(+)을 통해 과거 정보를 이어받는다.
덧셈 연산은 역전파 시 그래디언트를 1 그대로 흘려보내주는 역할을 한다. 즉, Forget Gate $f$가 1에 가까운 값을 유지해 준다면, 아무리 먼 과거의 정보라도 방해를 받지 않고 전달될 수 있다. 이는 ResNet의 Skip Connection과 유사한 성질이다.
-
CS231n 6강. CNN Architectures
Youtube: Stanford CS231N 2025
강의자료
1. Components of CNNs
이전 강의에서 CNN의 핵심인 Convolution Layer와, Pooling Layer에 대해 살펴보았었다. 이외에도 CNN을 이루는 몇가지 Layer들을 소개한다.
Normalization Layer
Normalization Layer(정규화 계층)은 Input data의 값 분포를 최적의 분포(Optimal Distribution)로 정규화하기 위해 학습하는 계층이다. 기본적으로 표준정규화를 통해 $N(0, 1)$의 분포를 따르게 만든 후 학습한 파라미터에 따라 Scale/Shift를 진행한다.
이렇게 정규화를 하면 다음과 같은 효과를 얻을 수 있다:
최적화가 안정화된다: 각 레이어로 들어가는 입력의 분포가 일정하게 유지되어 Loss의 변동도 안정됨. 학습 과정이 훨씬 안정적으로 진행된다.
더 큰 Learning Rate를 사용할 수 있다: 그래디언트의 스케일이 안정적으로 유지되어, 정규화 없이는 발산했을 큰 학습률로도 수렴할 수 있다.
그래디언트 소실/폭주가 완화된다: 데이터 분포가 활성화 함수의 saturating 구간(예: sigmoid, tanh의 양 끝)으로 치우치는 것을 막아, 역전파 시 그래디언트가 안정적으로 전달된다.
약한 규제(regularization) 효과가 있다: 특히 BatchNorm처럼 미니배치 단위로 통계를 구하는 경우, 배치마다 통계값이 조금씩 달라지는 노이즈가 더해져 일종의 regularization처럼 작동.
대부분의 정규화 계층은 이 표준정규화 방식을 사용하며, 각 종류별 차이점은 이 data를 정규화할 통계적 수치를 구하는 방식이다. 해당 강의에서는 LayerNorm이 대표적으로 소개되었다.
정육면체에서 세 축은 각각 batch($N$), channel($C$), spatial($H, W$)을 의미하고, 색칠된 영역이 하나의 평균/분산을 공유하는 단위다. 즉 정규화 종류별 차이는 “이 색칠 영역을 어떻게 잡느냐”의 차이다.
Batch Norm
각 채널마다 배치 전체와 공간 차원 $(N, H, W)$에 걸쳐 통계를 계산한다. 정육면체로 보면 채널 축의 한 칸을 고정하고 N · H,W 방향으로 펼쳐진 영역이 하나의 정규화 단위가 된다.
배치 통계에 의존하므로 배치 크기에 민감하다. 배치가 작으면 통계가 불안정해져 성능이 떨어진다.
학습과 추론의 동작이 다르다. 학습 중에는 미니배치 통계를 사용하면서 running mean/variance를 EMA로 누적하고, 추론 시에는 이 누적값을 사용한다.
CNN에서 표준적으로 사용된다.
:: EMA(Exponential Moving Average - 지수이동평균)
\[\mu_{\text{running}} \leftarrow \alpha \cdot \mu_{\text{running}} + (1-\alpha)\cdot \mu_B\]
\[\sigma_{\text{running}}^2 \leftarrow \alpha \cdot \sigma_{\text{running}}^2 + (1-\alpha)\cdot \sigma_B^2\]
Layer Norm
각 샘플마다 채널과 공간 차원 $(C, H, W)$ 전체에 걸쳐 통계를 계산한다. 배치 방향으로는 전혀 섞이지 않으므로 배치 크기에 독립적이고, 학습·추론 동작이 동일하다.
배치 통계를 쓰지 않아 배치가 작거나 1이어도 잘 동작한다.
RNN, Transformer처럼 시퀀스 길이가 가변적이고 배치 의존성을 피하고 싶은 구조에서 주로 사용된다.
Instance Norm
각 샘플의 각 채널마다 공간 차원 $(H, W)$에 대해서만 통계를 계산한다.
Group Norm
채널을 $G$개의 그룹으로 나눈 뒤, 각 샘플의 그룹마다 (그룹 내 채널들 + 공간)에 걸쳐 통계를 계산한다.
$G=1$이면 LayerNorm, $G=C$(채널 수)이면 InstanceNorm과 같아지는, 둘 사이를 일반화한 형태다.
Regularization: Dropout
각 순전파(forward pass)마다 레이어의 뉴런 출력(활성값) 일부를 랜덤하게 0으로 만든다. 각 뉴런을 비활성화할 확률 $p$는 하이퍼파라미터이며, 보통 0.5 정도를 사용한다.
만약 특정 뉴런들이 항상 함께 켜져 어떤 특징을 “분담”해서 학습하면, 그 조합에 과하게 의존하게 된다. 랜덤하게 뉴런을 꺼버리면 어떤 뉴런이 사라질지 모르므로, 각 뉴런은 서로 의존하지 않고 그 자체로 유용한 특징을 중복해서 학습할 수 있다.
추론 시에는 뉴런을 끄지 않고 전부 사용한다. 그런데 학습 때는 평균적으로 $(1-p)$ 비율의 뉴런만 켜져 있었는데, 추론 때는 전부 켜지므로 다음 레이어로 들어가는 입력의 기댓값(스케일)이 학습 때보다 커진다.
이 차이를 맞춰주기 위해 스케일을 보정하는 과정이 필요하다.
Activation Functions
각 층의 출력에 비선형성을 추가해주는 역할을 하며, 일반적으로 신경망은 선형 층과 활성화 함수를 번갈아 쌓는 구조로 이루어진다.
Sigmoid
입력값을 $[0,1]$ 범위의 값으로 압축한다. 이전에는 인기가 많았으나 양 끝쪽의 gradient가 0에 가까워 중첩되어 사용될 경우 역전파에서 기울기가 소실되는 Vanishing Gradient문제로 인해 오늘날 잘 사용되지 않음. 다만 출력을 확률처럼 $[0, 1]$로 해석할 수 있어, 이진 분류의 마지막 출력층으로는 여전히 사용된다.
ReLU
$f(x) = max(0,x)$ 로 표현되어 굉장히 계산이 단순하고 양수 범위에서는 기울기 소실 문제가 완화되었다. 실제로 sigmoid/tanh보다 수렴도 빠르다.
다만 $X<0$의 음수에서 기울기가 죽는 Dead Cell이 되어 버리는 점과, 출력에 음수가 없어, sigmoid와 마찬가지로 zero-centered가 아니다.
이러한 단점을 완화한 변형으로 음수 구간에 작은 기울기를 부여하는 Leaky ReLU, Transformer 계열에서 표준적으로 쓰이는 GELU 등이 있으며, 그래도 대부분의 경우 기본 ReLU로 충분하다.
2. CNN Architectures
VGGNet, ResNet 등 대표적인 CNN 구조들이 간략히 소개되었다. 이 글에서는 7x7 conv 한 번 대신 3x3 conv를 세 번 쌓는 것이 더 효율적이라는 점만 짚고 넘어간다. (둘은 동일한 receptive field를 갖지만, 3x3을 쌓는 쪽이 파라미터 수는 더 적고 비선형성(ReLU)이 더 많이 들어간다.)
ResNet에 대한 내용은 다른 글에서 이미 다루었다: ResNet 포스트
3. Build and Train CNNs
모델을 성공적으로 학습시키기 위한 다양한 전략을 소개한다.
Weight Initialization
가중치의 초기값은 학습 과정에서 굉장히 중요한 요소이다. 학습이 시작될 때 각 층을 거친 활성값(activation)의 분포가 어떻게 변하는지를 좌우하기 때문이다.
먼저, 모든 가중치를 0이나 동일한 값으로 초기화하면 안 된다. 모든 뉴런이 완전히 똑같은 출력과 똑같은 gradient를 갖게 되어 학습 내내 같은 값으로만 업데이트되기 때문이다. 그래서 가중치는 랜덤하게 초기화하되, 스케일을 잘 잡아야 한다.
너무 작으면: 층을 거칠수록 활성값이 점점 줄어 0으로 수렴한다. 활성값이 0이면 그에 의존하는 gradient도 0이 되어, 깊은 층일수록 학습이 되지 않는다.
너무 크면: 활성값이 점점 커지며, sigmoid·tanh 같은 함수는 양 끝 구간으로 몰려 gradient가 소실되고, 그렇지 않은 경우엔 값이 발산하며 Gradient Exploding이 일어난다.
Xavier Initialization
층의 입력 차원 $D_{in}$을 이용해 가중치의 분산을 $\frac{1}{D_{in}}$로 맞추는 방식이다. 즉 표준정규분포에서 뽑은 값에 $\sqrt{\frac{1}{D_{in}}}$를 곱한다. 입력 뉴런이 많을수록 가중치를 작게 만든다.
Kaiming Initialization
위 방식을 ReLU에 맞춰 보정한 방식이다. ReLU는 입력의 음수 절반을 0으로 죽여버리므로 출력의 분산이 대략 절반으로 줄어드는데, 이 손실을 보상하기 위해 분산에 2를 곱한 $\frac{2}{D_{in}}$을 사용하여 스케일한다. ReLU 계열을 쓰는 현대 CNN에서 표준적으로 사용된다.
Data Preprocessing
학습 데이터 전체에서 구한 채널별 평균과 표준편차를 이용해, 입력 이미지를 평균 0, 표준편차 1에 가깝게 정규화하는 것이 일반적이다. 데이터를 zero-centered하게 만들어두면 학습 초기의 최적화가 안정적으로 진행된다. 이때 사용한 통계값(평균·표준편차)은 검증·테스트 데이터에도 동일하게 적용한다.
Data augmentation
데이터 증강이라 불리며, 훈련 데이터를 변형하여 데이터의 크기와 다양성을 늘리는 기법이다. 좌우 반전, 크기 조절, 색상 변형, 일부 자르기(crop) 등으로 데이터를 변형시켜 학습시킨다. 모델이 이런 변형에 불변(invariant)하도록 유도해 일반화 성능을 높이는 효과를 갖는다. 변형은 훈련 시에만 적용한다.
Transfer Learning
학습시키려는 데이터가 너무 적거나 컴퓨팅 자원이 충분하지 않을 때, 사전 훈련된(pre-trained) 모델을 활용하는 방법이다. 보통 CNN모델의 앞쪽 레이어는 edge·texture 같은 범용적인 특징을 학습하므로 다른 task에도 그대로 재사용할 수 있다는 점을 이용한다.
Feature Extractor: 기존 모델의 CNN 레이어들은 동결(freeze)하고, 분류층만 새 데이터에 맞게 훈련한다. 데이터가 매우 적을 때 적합하다.
Fine-Tuning: 분류층뿐 아니라 기존 CNN 레이어의 일부(또는 전체)도 함께 학습시킨다. 데이터가 어느 정도 확보됐을 때 사용하며, 보통 사전 학습된 가중치가 망가지지 않도록 작은 learning rate로 학습한다.
-
Dictionary & Hash Table
참고자료
Dictionary
key-element 쌍의 집합을 저장하는 자료구조. 동일한 key를 가진 항목이 여러 개 허용됨.
메서드
설명
find(k)
key k를 가진 항목의 iterator 반환. 없으면 end 반환
put(k, o)
(k, o) 삽입 후 iterator 반환
erase(k)
key k를 가진 항목 제거
size(), empty()
크기 확인
Dictionary 구현 방식 비교
Log File (Unsorted list 기반)
임의 순서로 항목을 저장하는 비정렬 시퀀스.
연산
복잡도
이유
put
O(1)
맨 앞/뒤에 그냥 삽입
find
O(n)
전체 탐색 필요
erase
O(n)
전체 탐색 후 제거
적합한 경우: 삽입이 압도적으로 많고 탐색/삭제가 드문 경우 (e.g. 로그인 기록)
Search Table (Sorted array 기반)
key 순서로 정렬된 배열.
연산
복잡도
이유
find
O(log n)
Binary Search 사용
put
O(n)
삽입 위치 뒤 원소들을 shift
erase
O(n)
제거 후 원소들을 shift
적합한 경우: 탐색이 압도적으로 많고 삽입/삭제가 드문 경우 (e.g. 신용카드 인증)
Binary Search
정렬된 배열 기반으로 구현할 경우 이진 탐색을 통해 find(k)를 O(log n)에 수행.
Algorithm BinarySearch(int[] S, k, l, h):
if l > h:
return null // 탐색 실패
m ← (l + h) / 2
if S[m].key() = k:
return S[m] // 탐색 성공
else if k < S[m].key():
return BinarySearch(S, k, l, m-1) // 왼쪽 절반
else:
return BinarySearch(S, k, m+1, h) // 오른쪽 절반
매 단계마다 탐색 범위가 절반으로 줄어들며, $l = m = h$ 가 되면 종료
Hash Table
Hash Function
key를 정수 범위 [0, N-1]로 매핑하는 함수를 해쉬 함수라고 한다.
h(x) = x mod N (정수 key 예시)
h(x)의 결과를 hash value라 부름
item (k, o)를 index i = h(k)에 저장
Hash Function은 보통 2단계로 구성됨
h(x) = h2(h1(x))
단계
함수
역할
Hash code
h1: keys → integers
key를 정수로 변환
Compression
h2: integers → [0, N-1]
정수를 테이블 인덱스로 압축
Compression Function 종류
Division:
h2(y) = y mod N
N은 소수(prime)로 선택 → key 패턴에 관계없이 균등 분산 보장
MAD (Multiply, Add and Divide):
h2(y) = (ay + b) mod N
a, b는 음이 아닌 정수, 단 a mod N ≠ 0 (아니면 모든 key가 b로 매핑됨)
Collision (충돌)
서로 다른 key가 같은 인덱스로 매핑되는 경우가 발생한다. 이때 다양한 방법으로 이를 처리하는 것이 요구됨.
Separate Chaining
각 셀에 linked list를 연결해 충돌 항목들을 저장.
테이블[i] → [entry1] → [entry2] → ...
구현은 간단하나 테이블 외부에 추가 메모리 필요
Linear Probing (Open Addressing)
충돌 시 테이블의 다음 빈 셀을 순환하며 탐색한다.
탐색 위치: (h(k) + j) mod N (j = 0, 1, 2, ...)
find(k) 알고리즘:
Algorithm find(k):
i ← h(k)
p ← 0
repeat:
c ← A[i]
if c = ∅ → return null // 빈 셀 → 탐색 실패
if c.key() = k → return c // 탐색 성공
i ← (i + 1) mod N
p ← p + 1
until p = N
return null
삭제 처리: AVAILABLE 레이블 사용
단순히 셀을 비우면 find가 중간에 끊겨 이후 항목을 찾지 못할 수도 있다.
→ 삭제 시 셀을 AVAILABLE로 표시.
find: AVAILABLE은 건너뛰고 계속 탐색(탐색 루프가 중단되지 않음)
put: AVAILABLE 또는 빈 셀에 삽입
단점: 충돌 항목들이 뭉쳐서 클러스터를 형성 → 연쇄 충돌 발생 가능
Double Hashing
두 번째 해시 함수 d(k)로 step size를 결정.
탐색 위치: (h(k) + j * d(k)) mod N (j = 0, 1, 2, ...)
h(k) → 시작 위치
d(k) → 건너뛸 간격 (step size)
두 함수 모두 원래 key k를 직접 입력으로 받음.
일반적인 d(k) 공식:
d(k) = q - (k mod q)
d(k) ≠ 0 (0이면 무한루프)
q는 N보다 작은 소수
d(k)의 범위: 1 ~ q (0이 절대 안 나옴)
Hash Table 성능
항목
값
최악 복잡도
O(n): 모든 key가 같은 셀로 충돌
기대 복잡도
O(1): load factor가 낮을 때
Load Factor: α = n / N (저장된 항목 수 / 테이블 크기)
Open Addressing에서 삽입 시 기대 probe 횟수:
1 / (1 - α)
→ α가 1에 가까울수록 성능 급격히 저하. α를 낮게 유지하는 것이 중요.
소수(Prime)를 쓰는 이유
대상
소수 사용 이유
N (테이블 크기)
모든 셀 탐색 보장 + key 균등 분산
q (d(k)의 mod)
step size 다양성 보장 (d(k) 범위 = 1~q)
조건
q < N
구현 방식 최종 비교
구현
find
put
erase
적합한 상황
Log File (unsorted)
O(n)
O(1)
O(n)
삽입 위주
Search Table (sorted)
O(log n)
O(n)
O(n)
탐색 위주
Hash Table
O(1) 기대
O(1) 기대
O(1) 기대
일반적인 경우
-
-
CS231n 5강. Image Classification with CNNs
Youtube: Stanford CS231N 2025
강의자료
딥러닝 아키텍처가 주목받기 이전에는, 이미지 분류와 같은 대부분의 task에서 Feature Extraction을 사람이 직접 수행하였다. 분류에 중요하다고 판단되는 feature를 직접 정의하는 코드를 작성하고, 이를 벡터와 같은 수치 배열로 변환한 뒤 Linear Classifier 등의 분류기에 입력으로 사용하는 방식이었다.
반면 오늘날의 딥러닝 아키텍처는, 이러한 Feature Extraction 과정조차 모델이 학습을 통해 스스로 수행한다. 이 방식이 사람이 직접 feature를 설계하는 것보다 더 정확할 뿐 아니라, 사람이 미처 포착하지 못한 feature까지 찾아낼 수 있다는 것이 핵심 아이디어다.
실제로 이 아이디어는 옳았고, 지난 십여 년간 End-to-End Learning 기반의 Neural Network가 다양한 분야에서 뛰어난 성능을 보여줬다. 이번 강의에서는 이미지 분류 task에서 이러한 feature 학습을 가능하게 하는 CNN(Convolutional Neural Network) 구조에 대해 다룬다.
1. CNN: Convolutional Neural Network
앞선 강의에서 이미지를 모델의 입력으로 사용할 때, 이를 1차원 벡터로 flatten하는 과정에서 이미지의 공간적 구조(spatial structure)가 손실되는 문제가 있었다. CNN은 이 문제를 해결하면서 이미지 분류에서 훨씬 뛰어난 성능을 보이는 아키텍처이다.
CNN 아키텍처는 기본적으로 합성곱층(Convolutional Layer) 과 풀링층(Pooling Layer), 그리고 활성화 함수가 번갈아 쌓이는 구조로 이루어진다.
Fully Connected Layer와 Template Matching
Fully Connected Layer에서의 dot product는 본질적으로 template matching이다. 두 벡터가 같은 방향을 가리킬수록 내적값이 커지고, 직교하면 0에 가까워지기 때문이다.
Fully Connected Layer의 핵심을 정리하면 다음과 같다:
가중치 행렬은 일종의 template의 집합(bank of templates) 이다.
각 template은 입력과 동일한 크기(size)를 가진다.
출력은 각 template과 입력 간의 matching score이다.
Convolutional Layer
CNN은 이러한 template matching과 filter bank의 개념을 Convolutional Layer로 일반화한다. Fully Connected Layer와의 핵심적인 차이는 두 가지다:
필터가 입력 size와 같을 필요가 없다.
이미지를 flatten하지 않는다. 더이상 이미지를 펼치지 않고 입력 이미지의 공간적 구조 (C × H × W) 를 그대로 유지한다.
필터(filter, 혹은 kernel) 는 작은 크기의 sub-image(가중치 텐서)로, 입력 이미지 전체를 슬라이딩 윈도우(sliding window) 방식으로 이동하며 각 위치에서 dot product를 수행한다. 이때 필터의 채널 수는 입력 이미지의 채널 수와 반드시 일치해야 한다.
이러한 필터와 이미지의 부분 내적을 모든 위치에 대해 반복하면 2D activation map이 생성되며, 이 map의 각 값은 이미지의 해당 위치가 필터 패턴과 얼마나 일치하는지를 나타낸다.
필터를 여러 개 사용하면 각 필터마다 하나의 activation map이 생성될 것이고, 이를 쌓으면 최종적으로 (필터 수 × H’ × W’) 크기의 출력 텐서가 만들어진다. 이때 필터의 수가 곧 출력의 채널 수가 된다.
정리하면, Convolutional Layer의 출력은 activation map이며, 학습을 통해 조정되는 값은 필터의 가중치이다. CNN은 이러한 합성곱층(선형 연산) 과 활성화 함수 또는 풀링층(비선형 연산) 이 번갈아 쌓이는 구조이다.
What do Conv filters learn?
필터가 직접 경사하강법을 통해 학습한 결과는 이미지의 특징을 추출할 수 있는 값들일 것이다. 실제로 AlexNet의 첫번째 layer의 filter를 확인해보면 다음과 같다.
이미지를 학습한 대부분의 CNN에서 앞쪽 레이어는 특정 방향의 edge, 색상 대비와 같은 저수준(low-level) 특징을 학습하고, 깊은 레이어로 갈수록 이러한 저수준 특징들이 조합되어 텍스처, 패턴, 물체의 일부나 전체와 같은 고수준(high-level) 특징을 학습하게 된다.
이렇게 모델이 데이터를 단순한 특징에서 점점 추상적인 특징을 학습하는 과정을 계층적 특징 학습(hierarchical feature learning)이라고 하며, CNN이 이러한 특성을 보이는 것은 각 필터의 수용 영역(receptive field)의 크기가 점점 넓어지기 때문이다.
Receptive Field
Receptive Field란 activation map의 한 픽셀이 영향을 받는 원본 입력 이미지의 영역을 말한다.
예를 들어 필터 크기가 3×3이라면, 1번째 레이어 activation map의 한 픽셀은 원본 이미지의 3x3의 정보를 담고 있을 것이다. 이때 그 다음 레이어는 이 activation map 위에서 또 3×3을 보는데, 해당 map이 이미 원본의 3x3을 담고 있으니 2번째 activaion map의 한 픽셀은 원본 이미지의 5×5 영역에 영향을 받는 것이다.
이렇게 레이어가 쌓일수록 receptive field는 점점 커져, $L$번째 레이어의 receptive field 크기는 대략 $1 + L*(필터 크기-1)$로 증가한다.
\[\text{Receptive Field Size} = 1 + L \times (K - 1)\]
결국 깊은 레이어의 뉴런일수록 원본 이미지의 더 넓은 영역을 종합해서 하나의 값을 만들어내기 때문에, 자연스럽게 지역적인 특징보다 더 추상적인 특징을 학습하는 것이다.
한편, 필터들이 서로 다른 특징으로 수렴할 수 있는 이유는 학습 시작 시 필터가 서로 다른 값으로 랜덤 초기화되기 때문이다. 만약 모든 필터가 동일한 값으로 초기화된다면, 역전파 시 동일한 gradient를 받아 결국 같은 특징만을 학습하는 대칭 문제(Symmetry Problem) 가 발생한다.
Stride, Padding
Stride: 필터가 이미지(데이터) 위를 이동할 때의 이동 간격이다.
Padding: 이미지의 가장자리를 특정 값(0)으로 채우는 것이며, 보통 합성곱 연산 후에도 이미지의 크기를 유지하고 싶을때 사용된다.
입력 크기 $W$, 필터 크기 $K$, 패딩 $P$, 스트라이드 $S$가 주어졌을 때, 출력 크기 $W’$는 다음과 같다:
\[W' = \frac{W - K + 2P}{S} + 1\]
예를 들어 입력이 7×7, 필터가 3×3, Padding=0, Stride=1이면:
\[W' = \frac{7 - 3 + 0}{1} + 1 = 5\]
출력은 5×5가 된다. Padding=1로 설정하면:
\[W' = \frac{7 - 3 + 2}{1} + 1 = 7\]
입력과 동일한 7×7 크기가 유지된다.
Pooling
activation map의 크기를 줄이는데 stride를 크게하는 방법 외에도 Pooling을 사용한다. 합성곱과 비슷하게 kernel을 map위에서 슬라이딩하는데, 합성곱 대신 풀링의 종류에 따라 값을 계산한다.
대표적인 풀링 방식은 두 가지다:
Max Pooling: kernel 영역 내의 최댓값을 취한다. 영역에서 가장 강한 신호만을 남김.
Average Pooling: kernel 영역 내의 평균값을 취한다.
아래는 kernel 크기 2×2, stride=2로 Max Pooling을 수행하는 예시로, 출력 크기가 절반으로 줄어드는 것을 확인할 수 있다.
주의할 점은 Average Pooling은 합성곱과 마찬가지로 선형(linear) 연산인 반면, Max Pooling은 최댓값을 선택하는 비선형(non-linear) 연산이라는 점이다. 이 때문에 Max Pooling은 별도의 활성화 함수 없이도 비선형성을 추가하는 효과가 있다.
Translation Equivariance
Equivariance(등변성)은 입력에 변환을 가했을 때 출력도 동일한 변환이 적용되는 성질이다.
\[f(T(x))=T(f(x))\]
입력 x를 먼저 변환하고 함수 f를 적용한 결과와, 함수 f를 먼저 적용하고 변환한 결과가 같다. 합성곱 연산은 같은 필터가 이미지의 모든 위치에 동일하게 적용(weight sharing)되기 때문에 사진처럼 개구리의 위치가 달라도 feature map에서 동일한 패턴이 이동한 위치에 그대로 나타난다.
-
CS231n 4강. Neural Networks and Backpropagation
Youtube: Stanford CS231N 2025
강의자료
1. Activation Function
선형 연산만으로 신경망을 구성하면 여러 개의 선형층을 쌓더라도 결국 하나의 선형층과 동일하게 표현된다. 이는 기존에 Linear Classifier와 마찬가지로 선형적으로 구분 가능한 문제만 해결할 수 있으며, 복잡한 비선형 관계를 학습할 수 없다.
이 한계를 극복하기 위해 활성화 함수(Activation Function) 를 사용한다. 활성화 함수는 각 층의 출력에 비선형성을 추가해주며, 일반적으로 신경망은 선형 층과 활성화 함수를 번갈아 쌓는 구조로 이루어진다.
또한, 한 층의 모든 뉴런이 다음 층의 모든 뉴런과 연결된 구조를 Fully Connected Layer(Dense Layer) 라고 한다.
대표적인 활성화 함수로는 ReLU, Sigmoid, tanh 등이 있으며, 각 함수의 세부적인 특징과 장단점은 이후 강의에서 다뤄질 예정이다.
2. Backpropagation
이전 강의에서 다룬 optimizer가 loss의 gradient를 이용해 파라미터를 업데이트 하는 역할을 했다면, 역전파(Backpropagation)는 딥러닝에서 각 파라미터에 대한 gradient를 실제로 계산하는 표준적인 방법이다.
역전파에 대해 본격적으로 다루기 전에, 강의에서는 신경망 구조를 계산 그래프(Computational Graph) 형태로 나타낸다.
Computaional Graph
Computational Graph는 계산의 구조를 node와 edge로 나타낸 것으로, 노드에는 연산을 표기하고 엣지에는 연산 결과를 화살표와 함께 표시한다.
이로써 전체 연산 구조를 시각화할 수 있음과 동시에, 특정 부분의 지역적인 계산에만 집중할 수도 있다.
역전파의 원리
우리가 역전파를 하는 목적은 Loss를 최소화 시키기 위해, 모델의 파라미터(가중치W나 편향b)를 어느 방향으로 얼마나 조정해야 하는지 알아내기 위함이다. 이를 식으로 나타내보면 Loss L에 대한 W의 편미분으로 나타낼 수 있다.
\[∂L/∂W\]
이때, 딥러닝 모델은 복합적인 여러 수학적 연산의 결합물이다. 단순히 L을 W에 대해 편미분하는 것은 간단하지만, 조정해야할 파라미터가 Loss에서 여러 단계의 연산을 거쳐야만 접근 가능한 경우도 있다. 이를 연쇄법칙(Chain Rule)을 통해 loss에서부터의 변화량을 모델의 모든 노드에까지 전파할 수 있다.
위의 슬라이드를 보면 초록색 숫자는 순전파 결과값이고, 빨간색 숫자가 각 노드의 gradient이다.
이때, 위의 계산 그래프를 보다시피 역방향으로 gradient를 구할때 전체 연산을 처음부터 끝까지 풀어서 미분할 필요가 없다. 자신의 상위 노드(upstream)에서 전달된 gradient에 자신의 연산에 대한 local gradient를 곱하기만 하면, 해당 노드의 gradient를 구할 수 있다.
지역 그래디언트(Local Gradient): 현재 노드의 연산에 대한 미분값
상류 그래디언트(Upstream Gradient): 현재 노드보다 출력(Loss) 쪽에 가까운, 이미 계산된 gradient
하류 그래디언트(Downstream Gradient): 현재 노드보다 입력 쪽에 가까운 노드의 gradient
Details
PyTorch와 같은 라이브러리의 autograd도 이와 동일한 방식으로 동작한다. 연산의 종류에 따라 gradient를 구하는 규칙이 미리 정의되어 있으니, 그 값을 downstream으로 계속 전달하고 누적하는 방식이다.
대표적인 두 연산을 예시로 보면:
덧셈 f(x) = c + x에서 x에 대한 미분은 1이다. 따라서 downstream으로는 1 × upstream gradient, 즉 upstream gradient를 그대로 전달한다.
곱셈 f = a × b에서 ∂f/∂a = b, ∂f/∂b = a이다. 따라서 각각 b × upstream gradient, a × upstream gradient를 downstream으로 전달한다.
3. Backpropagation in Tensor
앞서 원리 설명을 위해 역전파 대상이 scalar였다면, 실제 파라미터는 vector, matrix와 같은 n-dimensional array일 것이다. 기본적인 원리는 동일하나, scalar에서의 미분 대신 야코비안 행렬(Jacobian Matrix) 이 local gradient로 등장한다.
야코비안 행렬 J의 (i, j) 원소는 ∂y_i / ∂x_j로 정의된다. 그런데 ReLU와 같은 원소별 연산(element-wise operation) 에서는 출력 y_i가 오직 입력 x_i에만 의존하므로 i ≠ j인 경우 ∂y_i / ∂x_j = 0이 된다. 즉 야코비안이 대각 행렬(diagonal matrix) 이 되어 대부분의 원소가 0인 sparse matrix가 된다.
파라미터 수가 많아질수록 야코비안 행렬의 크기도 급격히 커지기 때문에, 이를 명시적으로 구성하는 것은 메모리와 연산 측면에서 매우 비효율적이다. 따라서 실제로는 야코비안 행렬을 직접 만들지 않고, upstream gradient와의 곱 결과만 원소별 곱셈으로 바로 계산하는 방식을 사용한다.
Backprop with Matrices
행렬 곱셈 y = xw (x: [N×D], w: [D×M], y: [N×M])의 역전파를 살펴보면, upstream gradient dL/dy의 shape은 y와 동일하게 [N×M]이다.
각 파라미터에 대한 gradient는 다음과 같이 계산된다:
\[\frac{\partial L}{\partial x} = \frac{\partial L}{\partial y} w^T \quad \text{([N×D] = [N×M] × [M×D])}\]
\[\frac{\partial L}{\partial w} = x^T \frac{\partial L}{\partial y} \quad \text{([D×M] = [D×N] × [N×M])}\]
이 공식은 외울 필요 없이, 결과의 shape이 원래 파라미터의 shape과 일치하도록 행렬의 순서와 전치(transpose)를 맞추면 자연스럽게 유도된다. “the only way to make shapes match up!”
-
CS231n 3강. Regularization and Optimization
Youtube: Stanford CS231N 2025
강의자료
1. Regularization
정규화 항은 손실함수에 추가되어 data loss이외에도 패널티를 부여해 모델이 Training Data에 과적합되는 것을 방지한다.
아래와 같이 학습된 모델 f1, f2가 있을때, f1은 모든 데이터의 지점을 통과하여 Training Loss는 f2보다 낮겠지만, 새로운 데이터 (Test Data)에 대한 Loss는 데이터의 추세를 더 잘 반영하는 f2가 더 낮을 것을 알 수 있다.
이처럼 Training loss는 f1이 더 작았지만 f2가 더 학습이 잘 된 모델이라고 할 수 있다. 이렇게 모델이 적당하게 학습할 수 있도록 패널티를 부여하는 것이 Regularization이다.
+) 오컴의 면도날(Occam’s Razor): ‘같은 현상을 설명하는 두 개의 주장이 있다면, 간단한 쪽을 선택하라,
1.1 L2 Regularization
L2 Regularization은 모든 가중치의 제곱합을 손실함수에 추가한다.
제곱을 하기 때문에 가중치가 클수록 패널티가 크게 작용하여 가중치를 빠르게 감소시키는 weight decay효과가 있다. 반대로 이미 값이 작은 가중치는 제곱하면 더 작아져 수치에 거의 영향을 주지 않는다.
이러한 특성으로 인해 L2는 가중치를 전체적으로 작지만 고르고 분산된(spread out) 형태로 만드는 경향이 있다.
전체 Loss는 다음과 같다:
\[L_{total} = L_{data} + \frac{\lambda}{2} W^2\]
여기서 1/2이 붙는 이유는 미분을 깔끔하게 하기 위한 수학적 편의이다.
\[\frac{\lambda}{2} W^2 \quad \xrightarrow{\text{미분}} \quad \frac{\lambda}{2} \cdot 2W = \lambda W\]
1.2 L1 Regularization
L1은 가중치를 제곱하는 대신 절댓값을 취해 더한 값을 사용한다.
제곱을 하지 않기 때문에 작은 가중치의 값을 0이 되게 만들어 해당 feature를 모델에서 제거하는 효과가 있다. 이는 모델을 더 단순하고 희소한(Sparse) 형태로 만든다.
\[L_{total} = L_{data} + \lambda \sum |W|\]
또한 L1, L2 공통적으로 조율모수 λ가 붙는데, 이 값 또한 커질수록 패널티의 영향이 커지며, 분산(Variance)은 감소하고 편향(Bias)이 증가한다.
아래와 같이 x=[1,1,1,1]; w1=[1,0,0,0]; w2=[0.25,0.25,0.25,0.25]일 때, w · x는 모두 1이지만 L2 Regularization을 할 경우 w2가 더 선호하는 가중치가 될 것이다.
L1 Regularization의 경우 이 예제에서는 w1, w2 둘 다 동일한 정규화 값을 가지지만 L1은 w1처럼 Sparse한 형태의 가중치를 선호할 것이다.
항상 어떤 정규화를 옳다거나, 정규화가 무조건 모델을 단순하게 만든다는 원칙은 없으며, 데이터와 모델에 따라 자신이 적절한 정규화 방법을 선택해야 한다.
1.3 ElasticNet
ElasticNet은 이 L1과 L2를 복합적으로 활용하는 정규화 기법이다.
하이퍼파라미터 r을 활용하여 각 기법의 영향도를 조절할 수 있으며, 한가지 기법만을 활용하는 것 또한 가능하다.
\[L_{total} = L_{data} + \lambda \left( r \sum |W| + \frac{(1-r)}{2} \sum W^2 \right)\]
2. Optimization
손실 함수의 값을 최소화하는 모델의 가중치를 찾는 과정이 바로 Optimization(최적화)이다.
이 최적화의 가장 기본이 되는 알고리즘은 Gradient Descent(경사하강법)이다. 손실함수의 경사(기울기)를 따라 내려가면서 가중치를 업데이트하여 손실함수가 가장 작아지는 지점을 찾는 방법이다.
\[W ← W − η · ∇_W L\]
이 수식은 Gradient Descent의 가중치 업데이트 수식이다.
기호
의미
W (우변)
현재 가중치
η (eta)
Learning Rate (학습률) — 한 번에 얼마나 이동할지 결정하는 하이퍼파라미터
∇_W L
손실 함수 L을 가중치 W로 편미분한 값 (Gradient)
W (좌변)
업데이트된 새로운 가중치
Gradient(∇_W L) 는 수학적 정의상 함수값이 가장 가파르게 증가하는 방향과 그 크기를 나타내는 벡터이다.
우리의 목표는 Loss를 최소화하는 것이므로, Gradient의 반대 방향(−) 으로 W를 조정하는 것이다.
딥러닝의 최적화에서는 거의 대부분 이러한 Analytic Gradient를 계산하여 최적화를 진행하고, Numeric Gradient는 구현이 올바른지 디버깅하는 용도로만 활용된다.
Numeric Gradient
Analytic Gradient
방법
h를 아주 작게 설정하여 (f(x+h) - f(x)) / h 로 근사 계산
미적분을 이용해 Gradient를 수식으로 직접 유도
정확도
근사값 (부정확)
정확한 값
속도
매우 느림
빠름
사용 목적
Gradient 구현이 올바른지 검증할 때 (Gradient Check)
실제 학습에 사용
3. Variant of Gradient Descent
3.1. Stochastic Gradient Descent (SGD)
기본적인 경사하강법은 전체 데이터에 대한 Loss와 Gradient를 계산하고 나서야 1회의 가중치 업데이트가 진행이 되었다. 이 방식은 데이터의 크기가 커질 경우 학습 속도가 매우 느릴 것이다.
이를 해결하기 위해 SGD에서는 전체 데이터에서 무작위로 일부 데이터를 뽑은 mini-batch에서 그래디언트를 계산해 W를 여러번 업데이트한다.
원래 SGD는 데이터를 1개씩 뽑아 업데이트 하는 것을 의미하지만, 실제로는 Mini-batch SGD를 통칭하여 SGD라고 부르는 경우가 대부분이다.
Epoch
전체 데이터에 대해 한 번 학습을 완료하는 시점
Batch Size
한 배치에 있는 데이터의 수
Iteration
한 Epoch를 완료하기 위해 반복하는 횟수 (한 Epoch에서 이루어지는 파라미터 업데이트 횟수)
GD
SGD
Mini-batch SGD
Batch Size
전체 데이터 (N)
1
일부 (32, 64, 128)
1 Epoch 당 Iteration
1
N
N / Batch Size
학습 속도
느림
빠르지만 불안정
빠르고 안정적
SGD의 문제점
1. Loss Surface의 경우에 따라 진동이 발생함
손실 함수가 어떤 방향으로는 매우 가파르고, 다른 방향으로는 매우 완만한 형태를 가지는 경우, 우리가 실제로 가고 싶은 곳은 완만한 방향의 끝에 있는 최솟값인데, 가파른 방향의 진동 때문에 지그재그로 비효율적으로 이동하게 된다.
좁고 긴 협곡의 바닥으로 내려가야 하는 상황을 비유하면, 협곡의 양 벽면(가파른 방향) 사이에서 왔다갔다 튕기거나 바깥으로 벗어날 수 있으며, 실제로 가야 할 아래쪽(완만한 방향)으로는 아주 조금씩만 전진하게 된다.
2. 지역 최솟값(Local Minimum) / 안장점(Saddle Point)에 갇힘
손실 함수의 Gradient가 0이 되는 지점에 도달하면 W 업데이트가 멈추게 된다. 이 지점이 전역 최솟값(Global Minimum)이 아니더라도 Gradient가 0이므로 더 이상 탈출할 수 없다.
3. Mini-batch 샘플링에 의한 Gradient 노이즈
모든 데이터가 아닌 일부 샘플링된 데이터를 기반으로 Gradient를 계산하기 때문에 전체 데이터에 대한 손실함수의 그래디언트를 정확히 반영하지는 못한다. 장기적으로는 Loss의 최소 지점을 찾아갈 수 있으나, 매 Step이 항상 올바른 방향을 보장하지는 않는다.
3.2. SGD + Momentum
기존 파라미터가 업데이트되어 온 방향을 기억하여 다음 업데이트에 반영하는 방식이다. 마치 물체가 움직이던 방향으로 관성을 가지는 것과 같다.
아래 식과 코드처럼 감쇠 계수 ρ(rho)가 곱해지며 누적된 Gradient(Vt)를 이용해 파라미터를 업데이트한다.
이로써 gradient=0이 되더라도 누적된 속도(Vt)가 남아있어 빠져나올 수 있고, 여전히 진동 문제는 남아있으나 최솟값을 향하는 방향의 Gradient가 지속적으로 누적되면서 점차 올바른 방향으로의 이동이 강화된다.
3.3. RMSProp
RMSProp은 Gradient의 크기를 추적하여 얼만큼 다음 step의 이동량을 조절하는 알고리즘이다.
Momentum에서는 Gradient의 방향과 크기가 함께 누적되었다면, RMSProp에서는 Gradient를 제곱하여 각 방향이 얼마나 가파른지 크기만을 추적한다. 이 누적된 제곱값으로 파라미터 업데이트 시 Learning Rate를 나눠줌으로써, 가파를수록 적게 움직이고, 완만한 구간에서는 크게 움직이는 효과를 보여준다.
\[S_t = \rho \cdot S_{t-1} + (1 - \rho) \cdot (\nabla_W L)^2\]
\[W \leftarrow W - \eta \cdot \frac{\nabla_W L}{\sqrt{S_t} + \epsilon}\]
기호
의미
$S_t$
Gradient 제곱의 누적값 (이동 평균)
$\rho$
감쇠 계수 (보통 0.9 또는 0.99)
$(\nabla_W L)^2$
현재 Gradient의 제곱
$\epsilon$
0으로 나누는 것을 방지하기 위한 아주 작은 값 (예: $10^{-7}$)
사람이 경사가 심한 곳은 조심히 작은 보폭으로 가고, 완만한 평지는 빠르게 이동하는 것을 생각하면된다.
3.4 Adam
Momentum SGD와 RMSProp의 기법을 합친 알고리즘으로, 오늘날 가장 많이 사용되는 최적화 기법이다.
Momentum: Gradient의 방향을 누적하여 관성을 부여 (1차 모멘트)
RMSProp: Gradient의 제곱을 누적하여 방향별 Learning Rate를 조절 (2차 모멘트)
1차 모멘트 (Momentum)
\[m_t = \beta_1 \cdot m_{t-1} + (1 - \beta_1) \cdot \nabla_W L\]
2차 모멘트 (RMSProp)
\[v_t = \beta_2 \cdot v_{t-1} + (1 - \beta_2) \cdot (\nabla_W L)^2\]
Bias Correction
Momentum과 RMSProp의 계산식은 거의 동일한데, 학습 초기에는 누적된 값이 거의 없어 $m_t$, $v_t$ 모두 0에 가깝게 되고, 이로 인해 업데이트 시 비정상적으로 큰 Step을 뛰게 될 수 있다.
이를 보정하기 위해 $1 - \beta^t$로 나누어주는 Bias Correction을 적용한다. 학습 초기($t$가 작을 때)에는 $\beta^t$가 1에 가까워 보정 효과가 크고, 학습이 진행될수록 $\beta^t$가 0에 수렴하면서 보정의 영향이 사라진다.
\[\hat{m}_t = \frac{m_t}{1 - \beta_1^t}, \quad \hat{v}_t = \frac{v_t}{1 - \beta_2^t}\]
\[W \leftarrow W - \eta \cdot \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon}\]
기호
의미
일반적인 기본값
$m_t$
Gradient의 이동 평균 (1차 모멘트)
-
$v_t$
Gradient 제곱의 이동 평균 (2차 모멘트)
-
$\beta_1$
1차 모멘트 감쇠 계수
0.9
$\beta_2$
2차 모멘트 감쇠 계수
0.999
$\eta$
Learning Rate
$10^{-3}$
$\epsilon$
0으로 나누는 것을 방지하기 위한 작은 값
$10^{-8}$
+) AdamW
기존 Adam에서 L2 Regularization을 적용하면, 정규화 항이 Gradient에 포함된 채로 1차/2차 모멘트 계산에 들어간다. 이렇게 되면 Adaptive Learning Rate(√v_t로 나누는 부분)가 Data Loss의 Gradient뿐만 아니라 Regularization 항까지 함께 스케일링해버린다.
AdamW는 Weight Decay를 Gradient 계산과 분리하여, 파라미터 업데이트 단계에서 직접 적용한다.
\[W \leftarrow W - \eta \cdot \left( \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon} + \lambda W \right)\]
(Weight Decay $\lambda W$를 업데이트 시 별도로 적용. $\lambda W$ 는 L2정규화 항의 미분 결과)
4. Learning Rate Scheduling
전체 학습 과정 동안 반드시 같은 Learning Rate를 유지할 필요는 없으며, 오히려 이를 조절하는 것이 좋은 방법이다. 보통 학습이 진행될수록 LR을 감소시켜 Loss의 최소 지점 근처에서 수렴할 수 있도록 한다.
4.1 Step Decay
고정된 학습 반복 횟수(Epoch)마다 Learning Rate를 일정 비율로 줄이는 방식이다. (ResNet 등에서 사용)
4.2 Cosine Decay
Learning Rate를 코사인 함수의 곡선을 따라 부드럽게 감소시키는 방식이다. 초반에는 천천히 감소하다가 중반에 빠르게 감소하고, 후반에 다시 천천히 감소하는 특성을 가진다.
4.3 Linear Decay
Learning Rate를 학습이 진행됨에 따라 선형적으로 균일하게 감소시키는 방식이다.
4.4 Linear Warmup
초기부터 큰 Learning Rate를 사용하면 학습이 불안정할 수 있다. Linear Warmup은 학습 초기에 LR을 0에서 시작하여 목표 LR까지 점점 증가시켜 초기 학습의 안정성을 확보한다.
-
CS231n 2강. Image Classification with Linear Classifiers
Youtube: Stanford CS231N 2025
강의자료
1. Image Classification: A Core Task in Computer Vision
인간의 시각적 인지 능력은 짧은 순간에도 사진 속 객체가 무엇인지 판단할 수 있다. 고양이와 개를 구분하는 일은 우리에게 무의식적으로도 가능한 쉬운 문제지만, 컴퓨터에게 이는 어려운 도전 과제이다.
먼저 컴퓨터가 이미지를 보는 방식을 알아야 한다.
컴퓨터에서 이미지는 각 픽셀 값의 행렬 또는 텐서로 인식되며, 각 픽셀이 0~255값을 가지는 총 3개의 채널 (R/G/B)로 구성된다. 이처럼 이미 인간의 이미지 인식 방식과 매우 다르기 때문에, 컴퓨터의 이미지 분류에는 다양한 난관이 존재한다.
컴퓨터의 이미지 이해의 난관들
문제
설명
Viewpoint Variation
같은 물체도 촬영 각도에 따라 픽셀 값이 완전히 달라짐
Illumination
조명의 강도나 방향에 따라 동일 객체가 다르게 표현됨
Background Clutter
객체와 배경의 픽셀 값이 유사하면 구분이 어려움
Occlusion
객체 일부가 가려져 있어도 전체를 추론할 수 있어야 함
Deformation
같은 클래스의 객체라도 다양한 형태로 존재함
Intraclass Variation
같은 클래스 내에서도 크기, 색상, 패턴이 다양함
Context
주변 환경, 맥락을 이해해야 올바르게 분류 가능
1. Viewpoint Variation
같은 물체를 찍었더라도 찍은 위치에 따라 그 이미지 픽셀은 완전히 변화된 값을 가지게 된다.
2. Illumination
같은 객체여도 빛의 강도에 따라 이미지에는 다르게 표현된다. 우리는 조명의 차이를 바로 인지할 수 있으나 컴퓨터에겐 이것 또한 어려운 과제이다.
3. Background Clutter
인식하고자 하는 객체가 이미지의 배경과 비슷한 픽셀 값을 가지면 컴퓨터는 구분하기가 힘들어진다.
4. Occlusion
사진과 같이 동물의 일부가 다른 것에 가려져 있을 경우, 일부를 보고 물체를 추론할 수 있어야 한다.
5. Deformation
같은 클래스의 객체여도 다양한 형태로 존재 가능하다.
6. Intraclass variation
같은 클래스의 객체여도 다양한 종류가 있다는 것을 인지해야 한다. (size, color, pattern 이 전부 달라도 모두 cat이다)
7. Context
그림과 같이 우리는 그림자로 인해 개에게 줄무늬 그림자가 생긴 것을 이해할 수 있으나, 컴퓨터는 이를 호랑이로 분류할 수도 있을 것이다. 이처럼 이미지 속 상황에 대한 이해를 하는 능력이 요구된다.
2. K-Nearest Neighbor (KNN) Classifier
KNN은 가장 단순한 데이터 기반 접근법(Data-Driven Approach)의 분류기이다.
분류하려는 이미지와 학습으로 저장된 이미지들 간의 거리(픽셀값의 차이)를 계산하여, 가장 가까운 k개의 이미지를 찾고, 그 중 가장 많은 클래스로 분류한다.
이때 이미지들간의 거리를 측정하는데 다음과 같은 방식이 있다.
L1 Distance (맨해튼 거리): 각 픽셀 값의 차이를 절댓값하여 전부 더한 값.
L2 Distance (유클리드 거리): 각 픽셀 값의 차이를 제곱한 것을 전부 더한 후 제곱근. (일반적인 두 점 사이의 직선 거리)
\[d_1(I_1, I_2) = \sum_p |I_1^p - I_2^p|\]
\[d_2(I_1, I_2) = \sqrt{\sum_p (I_1^p - I_2^p)^2}\]
학습 방식: Lazy Learning
def train(self, X, y):
self.Xtr = X # 전체 학습 데이터를 그대로 저장
self.ytr = y
코드와 같이 train함수에서 그냥 전체 데이터셋을 메모리에 그대로 복사하고 있다. (주석 확인)
이처럼 일반적인 모델처럼 “학습(가중치 업데이트)”을 하는 게 아니라 훈련 데이터를 통째로 외워버리는 것이 KNN의 특징이며, 이를 “게으른 학습(Lazy Learning)” 이라고도 한다.
하지만 이러한 방식으로 인해 한계가 명확하다.
예측 속도: 학습은 빠르지만, 예측 시 전체 데이터와 거리를 계산해야 하므로 매우 느림
낮은 표현력: 픽셀 간 거리는 이미지의 의미적 정보를 담지 못함(Not informative). 이미지를 한 픽셀씩 밀거나, 가리거나, 밝기를 바꾸면 거리값이 크게 변해버림
3. Linear Classifier
파라미터 기반 접근법 (Parametric Approach)의 분류기로, 학습할 때 데이터를 단순히 저장하지 않고, 원하는 결과를 얻기 위해 파라미터(W)에 데이터로부터 얻은 정보를 저장, 최적화 하는 방식이다.
\[f(x, W) = Wx + b\]
$x$: 입력 이미지 (flatten된 벡터, 예: 32×32×3 → 3072차원)
$W$: 가중치 행렬 (학습으로 최적화할 파라미터)
$b$: 편향(Bias)
출력: 각 클래스에 대한 점수(Score)
선형 분류기는 f(x,W) = Wx + b로 나타낼 수 있으며 여기서 x는 입력 이미지로, 보통 선형 분류기에 들어올때 flatten된 상태로 들어온다. (Array of 32x32x3 ->3072 numbers total)
슬라이드의 예시처럼 입력데이터와 W가 곱해지는데, W의 각 행이 해당 클래스의 정보를 담고 있어 결과 값이 그 클래스에 대한 점수를 나타내게 된다.
Visual Viewpoint
W의 각 행을 이미지처럼 2차원의 이미지로 시각화하면, 각 클래스에 대한 특징이 추출된 형상을 확인할 수 있다.
Geometric Viewpoint
$Wx + b$를 공간에 나타내보면, 해당 클래스와 아닌 것을 분리하는 초평면(Hyperplane) 의 법선 벡터 역할을 한다.
하지만 이처럼 선형 분류기는 선형적으로 분리 가능하지 않으면 제대로 클래스를 분류할 수 없다는 치명적인 단점이 있다.
4. Loss Function & Softmax Loss
손실 함수는 모델의 예측이 실제 정답에 비해 얼마나 잘못 되었는지를 측정하는 함수이다. 모델을 학습시킨다는 것은 이 손실함수 값을 최소화하도록 모델의 파라미터를 조정하는 것이다.
Softmax
선형 분류기의 출력인 점수(logit) 그 자체로는 확률적 해석이 불가능하다.
Softmax함수는 여러 개의 출력값을 모두 합쳐서 1(100%)이 되는 확률 분포로 변환해준다. 이로써 분류기의 예측값을 각 클래스에 대한 예측 확률로 변환할 수 있다.
\[\text{softmax}(z_i) = \frac{e^{z_i}}{\sum_j e^{z_j}}\]
아래의 공식은 softmax의 개선된 공식으로, 지수에 입력 z의 최댓값을 빼줌으로써 수치 안정성을 확보한다.
\[\text{softmax}(z_i) = \frac{e^{z_i - \max(z)}}{\sum_j e^{z_j - \max(z)}}\]
Cross-Entropy Loss
확률 분포와 실제 정답(One-hot 벡터)과의 차이를 측정하는 손실 함수이다.
“모델이 예측한 확률 분포가 정답 분포와 얼마나 멀리 있는가?”
\[L = -\sum_{c} y_c \log(\hat{p}_c)\]
$y_c$: 정답 레이블 (정답 클래스는 1, 나머지는 0인 One-hot 벡터)
$\hat{p}_c$: 클래스 $c$의 예측 확률
정답 클래스를 제외한 나머지 $y_c$는 모두 0이므로, 결국 정답 클래스의 예측 확률만 남는다.
만약 정답으로 예측한 클래스의 확률이 1 이라면 손실함수의 값이 0이므로 최소이고, 정답 클래스의 예측확률이 작을 수록 앞에 - 부호로 인해 점점 손실함수의 값은 커지는 식이다.
\[L = -\log(\hat{p}_{y_i})\]
Softmax Loss
Softmax Loss는 Softmax 함수 + Cross-Entropy Loss 를 결합한 것으로, 정답 클래스의 예측 확률에 음의 로그를 취한 형태이다.
\[L_i = -\log\left(\frac{e^{z_{y_i}}}{\sum_j e^{z_j}}\right)\]
-
ResNet - Residual Network
ResNet - Residual Network
“Deep Residual Learning for Image Recognition” 논문에서 발표된 딥러닝 모델로, 많은 레이어를 가지게 된 깊은 신경망을 효과적으로 학습시키기 위해 Residual Learning이라는 개념을 사용한 모델이다.
논문: Deep Residual Learning for Image Recognition
이후에 등장한 많은 모델들에 영향을 준 아키텍쳐로, 논문을 읽고 직접 구현해보며, 공부한 내용을 정리해보았다.
1. ResNet의 등장 배경
AlexNet, VGGNet과 같은 CNN 기반 모델이 이미지 분류에서 좋은 성능을 내던 시절, 모델의 깊이(number of stacked layers)가 깊어질수록 이미지의 특징을 더 잘 추출하여 더 좋은 성능을 보인다는 것이 확인되었다.
그렇다면 무조건 모델의 레이어를 더 쌓는 것만으로 성능 향상을 이룰 수 있는 것일까? 아래의 그림(Figure 1)은 단순히 모델을 더 깊게 설계한다고 해서 성능 향상은 이루어지지 않고, 오히려 error가 증가하여 성능이 저하된다는 점을 보여준다.
레이어를 더 쌓은 56-layer 모델이 20-layer 모델보다 오히려 training error와 test error 모두 높게 나타났다. 이는 overfitting의 문제가 아니라, 모델이 깊어질수록 optimize하기가 어려워지는 degradation 문제임을 의미한다.
Identity Mapping
이를 위한 기본 아이디어로 Identity Mapping이라는 개념이 등장한다.
얕은 모델이 깊은 모델보다 성능이 더 좋다면, 모델을 더 깊게 만들 때 얕은 모델이 내놓은 결과값을 최대한 유지하도록 레이어를 학습시키는 것이다.
즉 만약 어느 얕은 모델을 통과하여 x라는 결과 값이 있다면, 그 이후의 레이어를 통과한 결과 또한 x와 비슷한 값을 내놓는다면, 괜찮은 성능을 보이는 얕은 모델과 적어도 비슷하거나 더 좋은 성능을 내는 모델이 나올 것이다.
이를 수식을 통해 표현해보면, 레이어의 출력을 H(x)라 할 때 기존에는 H(x)=y, 즉 결과가 참 값이 되도록 학습 시켰다면, H(x)=x 가 되도록 학습을 시키는 것이다.
이때 H(x)는 그 레이어의 연산 f( )를 거친 결과물이므로, 레이어의 함수 f를 거쳤을 때 H(x)=f(x)=x가 되도록 학습시키는 것이다.
이러한 identity mapping개념은 ResNet 이전에도 제시되었으나, 실제로 f(x) = x로 학습시키는 것이 굉장히 어려웠다. 하지만 ResNet에서는 이 문제를 조금 다른 방향에서 접근한다.
2. Residual Learning
앞서 봤듯이, 우리가 원하는 것은 H(x) = x, 즉 레이어를 통과해도 값이 유지되는 identity mapping이다.
ResNet은 H(x)를 f(x) 그대로 사용하는 대신, 입력 x를 직접 더해준 H(x) = f(x) + x 로 재정의한다.
Identity Mapping의 목적에 따라 H(x)=x가 되는 것을 원하므로, 최종적으로 f(x)=H(x)-x=0, 즉 잔차(Residual)가 0이 되도록 학습시키는 것으로 목표가 바뀌었다.
이렇게 구조를 바꿈으로써 생기는 장점은 다음과 같다:
f(x)=x를 직접 학습하는 것보다 f(x) = 0을 학습하는 것이 훨씬 수월하다.
입력이 직접 다음 레이어로 전달되므로, 역전파 과정에서도 gradient가 Shortcut을 통해 직접 전달될 수 있다. 이로 인해 Gradient Vanishing 문제가 완화된다.
첫번째 장점에 대해 자세히 얘기하자면, 기존 방식은 레이어가 아무런 힌트 없이 처음부터 f(x) = x라는 답을 스스로 찾아야 했다. 반면 ResNet은 shortcut을 통해 x를 구조적으로 이미 더해주고 있기 때문에, 레이어는 “x에서 얼마나 더 조정해야 하는가”라는 작은 변화량만 학습하면 된다. 기준점이 주어진 상태에서 미세한 조정만 하면 되니 훨씬 수월한 것이다.
이렇게 하나 이상의 레이어를 건너 뛰어 입력 x를 출력에 더해주는 연결을 Skip Connection 혹은 Shortcut이라고 한다. 논문의 ResNet 기본 구조에서는 weight layer 2개를 건너뛰게 설계되었다. (ResNet-50 이상은 3개로 바뀐다.) 기본적으로 이러한 Skip Connection과정이 생겨도 추가되는 모델의 파라미터가 전혀 없으며, dimension이 달라지는 경우에만 소량의 파라미터가 추가된다. (이후 Shorcut methods내용)
또한 이렇게 Skip Connection과 weight Layer, ReLU활성화 함수가 합쳐진 이 블록을 Residual Block이라고 부른다. ResNet은 이러한 Residual Block이 쌓여서 만들어지는 모델이다.
3. Network Architectures
왼쪽은 Skip Connection이 없는 Plain Network, 오른쪽이 Residual Block으로 구성된 ResNet이다.
Skip Connection(Shortcut)의 유무 이외에 다른 설계 원칙은 다음과 같이 동일하다:
VGGNet에서 영감을 받은 구조로, 3x3 kernel(filter)을 사용한다.
Skip Connection은 input과 output의 형태가 동일해야 하기에, 합성곱 연산 전후 특성맵의 shape과 filter의 갯수가 유지되도록 한다.
합성곱 연산에서 stride=2를 통해 특성 맵의 크기가 절반으로 줄어들 경우, 연산량을 일정하게 보존하기 위해 filter의 수 또한 2배로 늘린다.
3에서 특성맵의 크기가 줄어들면 skip connection을 그대로 적용할 수 없다. 이를 위해 shape을 맞추는 과정이 동반된다. (점선 화살표 부분) 아래의 내용에서 자세히 설명한다.
Shortcut methods
a) shortcut의 채널 수를 맞추기 위해 zero-padding을 추가하고, feature map 크기를
맞추기 위해 stride=2를 적용하여 더해준다.
기존에 x가 (28×28×64) (64개의 채널)이었다면 출력은 (14×14×128) (128개의 채널) 이어야 하므로, 부족한 64개 채널을 값이 전부 0인 feature map으로 채워서 128개를 맞춰주는 것이다.
이 방법은 추가되는 파라미터가 없다는 장점이 있으나, 0으로 채운 채널은 학습된 정보가 없다는 한계가 존재한다.
b) 1×1 conv(stride=2)를 shortcut에 적용하여 feature map의 크기와 채널 수를 출력과
동일하게 맞춰준 뒤 더해준다.
Ws가 바로 정사영(projection)에 해당하는 1×1 conv 레이어이다.
입력 x의 shape이 (28×28×64)이고 출력이 (14×14×128)이라면, 둘을 그대로 더할 수 없으니까 x에 1×1 conv를 stride=2로 적용해서 shape을 (14×14×128)로 맞춰준다.
이 방법은 학습해야하는 파라미터 수가 약간 추가된다. 본 구현과 ImageNet 실험에서는 b) 방법을 사용한다.
ResNet은 레이어의 깊이에 따라 ResNet-18, 34, 50, 101, 152 등 다양한 종류로 나뉘며, 그 구조는 아래 Table과 같다.
Bottleneck Block
위의 Table을 보면 ResNet 34-layer까지는 한 Residual Block에 3x3 Convolution 레이어가 2개 있는 구조였다면, 50-layer부터는 1x1, 3x3, 1x1 Convolution으로 총 3개의 레이어가 있는 구조인 것을 확인할 수 있다.
같은 구조의 block을 계속해서 쌓을 경우 파라미터의 수가 매우 많아지기 때문에, 연산량과 파라미터 수를 줄이면서도 층을 더 깊게 쌓기 위해 도입된 구조가 Bottleneck 구조이다.
1×1 Conv (차원 축소): 입력 레이어의 채널 수를 크게 줄임. (예: 256 → 64)
3×3 Conv (특징 추출): 채널 수가 줄어든 상태에서 공간적 특징을 추출.
1×1 Conv (차원 복원): 채널 수를 확장하여 Residual Connection과 크기를 맞춤 (예: 64 → 256)
4. PyTorch 구현 코드
PyTorch의 torchvision에 있는 ResNet 구현 코드를 기반으로 작성하였다.
원본 코드 링크(github)
Convolution layer
기본적인 convolution layer를 미리 정의한다.
def conv3x3(in_planes, out_planes, stride=1, groups=1, dilation=1):
"""3x3 convolution with padding"""
return nn.Conv2d(in_planes, out_planes, kernel_size=3, stride=stride,
padding=dilation, groups=groups, bias=False, dilation=dilation)
def conv1x1(in_planes, out_planes, stride=1):
"""1x1 convolution"""
return nn.Conv2d(in_planes, out_planes, kernel_size=1, stride=stride, bias=False)
BasicBlock
기본적인 Residual Block구조이다. forward()를 보면 F(x) + x가 마지막에 활성화 함수를 통과하는 ResNet v1의 방식이다.
\[H(x) = F(x) + x\]
\[y = \mathrm{ReLU}(H(x))\]
class BasicBlock(nn.Module):
expansion = 1
def __init__(self, inplanes, planes, stride=1, downsample=None, groups=1,
base_width=64, dilation=1, norm_layer=None):
super(BasicBlock, self).__init__()
if norm_layer is None:
norm_layer = nn.BatchNorm2d
self.conv1 = conv3x3(inplanes, planes, stride)
self.bn1 = norm_layer(planes)
self.relu = nn.ReLU(inplace=True)
self.conv2 = conv3x3(planes, planes)
self.bn2 = norm_layer(planes)
self.downsample = downsample
self.stride = stride
def forward(self, x):
identity = x
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
if self.downsample is not None:
identity = self.downsample(x)
out += identity
out = self.relu(out)
return out
Bottleneck
해당 원본 코드의 주석을 보면 stride=2의 conv연산을 3x3레이어에서 수행한다고 쓰여있는데, 이 말은 논문에서 첫 1x1 Conv에서 stride=2를 적용해 공간정보를 압축하던 것을 3x3으로 옮겼다는 의미이다.
이 구조가 더 정확성이 높아 개선된 구조이다.
class Bottleneck(nn.Module):
expansion = 4
def __init__(self, inplanes, planes, stride=1, downsample=None, groups=1,
base_width=64, dilation=1, norm_layer=None):
super(Bottleneck, self).__init__()
norm_layer = nn.BatchNorm2d
width = int(planes * (base_width / 64.)) * groups
self.conv1 = conv1x1(inplanes, width)
self.bn1 = norm_layer(width)
self.conv2 = conv3x3(width, width, stride, groups, dilation)
self.bn2 = norm_layer(width)
self.conv3 = conv1x1(width, planes * self.expansion)
self.bn3 = norm_layer(planes * self.expansion)
self.relu = nn.ReLU(inplace=True)
self.downsample = downsample
self.stride = stride
def forward(self, x):
identity = x
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
out = self.relu(out)
out = self.conv3(out)
out = self.bn3(out)
if self.downsample is not None:
identity = self.downsample(x)
out += identity
out = self.relu(out)
return out
ResNet
위에서 정의한 BasicBlock 또는 Bottleneck Block을 이용하여 전체 네트워크를 구성하는 클래스이다.
입력 이미지에 대해 초기 Convolution과 MaxPooling을 수행한 후, 4개의 Residual Layer를 통과시켜 특징을 추출한다. 이후 Average Pooling을 통해 공간 정보를 1×1 크기로 압축하고, FCN을 통과하여 최종 분류를 수행한다.
class ResNet(nn.Module):
def __init__(self, block, layers, num_classes=1000, zero_init_residual=False,
groups=1, width_per_group=64, replace_stride_with_dilation=None,
norm_layer=None):
super(ResNet, self).__init__()
norm_layer = nn.BatchNorm2d
self._norm_layer = norm_layer
self.inplanes = 64
self.dilation = 1
if replace_stride_with_dilation is None:
replace_stride_with_dilation = [False, False, False]
self.groups = groups
self.base_width = width_per_group
self.conv1 = nn.Conv2d(3, self.inplanes, kernel_size=7, stride=2, padding=3,
bias=False)
self.bn1 = norm_layer(self.inplanes)
self.relu = nn.ReLU(inplace=True)
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
self.layer1 = self._make_layer(block, 64, layers[0])
self.layer2 = self._make_layer(block, 128, layers[1], stride=2,
dilate=replace_stride_with_dilation[0])
self.layer3 = self._make_layer(block, 256, layers[2], stride=2,
dilate=replace_stride_with_dilation[1])
self.layer4 = self._make_layer(block, 512, layers[3], stride=2,
dilate=replace_stride_with_dilation[2])
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(512 * block.expansion, num_classes)
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
elif isinstance(m, (nn.BatchNorm2d, nn.GroupNorm)):
nn.init.constant_(m.weight, 1)
nn.init.constant_(m.bias, 0)
def _make_layer(self, block, planes, blocks, stride=1, dilate=False):
norm_layer = self._norm_layer
downsample = None
previous_dilation = self.dilation
if dilate:
self.dilation *= stride
stride = 1
if stride != 1 or self.inplanes != planes * block.expansion:
downsample = nn.Sequential(
conv1x1(self.inplanes, planes * block.expansion, stride),
norm_layer(planes * block.expansion),
)
layers = []
layers.append(block(self.inplanes, planes, stride, downsample, self.groups,
self.base_width, previous_dilation, norm_layer))
self.inplanes = planes * block.expansion
for _ in range(1, blocks):
layers.append(block(self.inplanes, planes, groups=self.groups,
base_width=self.base_width, dilation=self.dilation,
norm_layer=norm_layer))
return nn.Sequential(*layers)
def _forward_impl(self, x):
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
x = self.maxpool(x)
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
x = self.layer4(x)
x = self.avgpool(x)
x = torch.flatten(x, 1)
x = self.fc(x)
return x
self.inplanes
self.inplanes는 현재 Layer의 입력 채널 수를 의미한다. 첫 번째 Convolution의 출력 채널 수가 64이므로 초기값은 64로 설정된다.
이후 _make_layer()에서 첫 번째 Block이 생성된 후
self.inplanes = planes * block.expansion
을 통해 다음 Layer의 입력 채널 수로 갱신된다.
예를 들어 Bottleneck의 경우 expansion = 4 이므로 다음과 같이 채널 수가 증가한다.
64 → 256
128 → 512
256 → 1024
512 → 2048
_make_layer()
self.layer1 = self._make_layer(block, 64, layers[0])
self.layer2 = self._make_layer(block, 128, layers[1], stride=2)
self.layer3 = self._make_layer(block, 256, layers[2], stride=2)
self.layer4 = self._make_layer(block, 512, layers[3], stride=2)
이 함수는 여러 개의 Residual Block을 묶어 하나의 Layer를 생성한다. 예를 들어 ResNet50에서는
layers = [3, 4, 6, 3]
이 전달되므로 아래와 같이 레이어가 생성된다.
Layer1 : Bottleneck 3개
Layer2 : Bottleneck 4개
Layer3 : Bottleneck 6개
Layer4 : Bottleneck 3개
ResNet50의 특성맵 변화
Stage
Output Shape
Input
3 × 224 × 224
Conv1
64 × 112 × 112
MaxPool
64 × 56 × 56
Layer1
256 × 56 × 56
Layer2
512 × 28 × 28
Layer3
1024 × 14 × 14
Layer4
2048 × 7 × 7
AvgPool
2048 × 1 × 1
FC
1000
정리
ResNet은 Skip Connection을 통해 Residual Learning을 수행해 깊은 신경망에서도 안정적인 학습이 가능하도록 만든 모델이다. Identity Mapping을 직접 학습하는 대신 Residual을 학습하도록 설계하여 Degradation 문제를 해결했으며, 이후 다양한 후속 연구의 기반이 되었다.
-
1. PyTorch_Basics
파이토치의 기초 문법과 함수 사용법을 다룬 포스트이다. 계속해서 내용을 업데이트할 예정.
1. Tensor
1.1. Tensor가 무엇인가?
인공지능 분야에서 대부분의 데이터는 벡터(Vector) 로 표현된다. 단어, 이미지, 혹은 특정 측정값에 대한 정보를 나타낼 때, 여러 수치를 나열한 데이터 형태를 사용하는데, 예를 들어 [0.5, 1.2, 1.7]과 같이 1차원 배열 형태가 이에 해당한다.
이러한 벡터가 여러 개 모이면, 같은 형태가 새로운 방향으로 쌓이게 된다. 즉, 기존 벡터의 수치들이 나열된 방향(가로)에 수직한 방향(세로)으로 데이터가 늘어나면서 행렬(Matrix) 이 만들어진다.
그런데 항상 모든 데이터를 동일한 방식으로 표현할 수 있는 것은 아니다. 예를 들어 사진 한 장을 수치들의 집합으로 표현한다고 하면, 단순히 벡터로 펼쳐서 나타낼 수도 있지만, 보다 직관적으로는 가로 × 세로의 픽셀 격자로 이루어진 행렬이며, 각 픽셀은 RGB 세 가지 채널 값을 갖는다. 결국 하나의 이미지는 (높이) × (너비) × (채널 수) 형태의 3차원 구조, 즉 직육면체 모양의 수치 배열로 표현된다.
그렇다면 이런 이미지가 여러 장 있다면 어떻게 될까? 여러 개의 벡터를 쌓아 행렬을 만들었던 것처럼, 3차원 구조의 이미지들을 또 다른 방향으로 쌓으면 4차원 구조가 된다. 이처럼 차원이 높아질수록 데이터의 구조는 점점 복잡해진다.
이 지점에서 텐서(Tensor) 라는 개념이 등장한다. 텐서는 이처럼 임의의 차원(축, axis)을 가지는 수치 배열을 통칭하는 개념으로, 스칼라·벡터·행렬을 모두 텐서의 특수한 경우로 일반화할 수 있다.
개념
차원 (Rank)
예시
스칼라 (Scalar)
0차원
3.14
벡터 (Vector)
1차원
[0.5, 1.2, 1.7]
행렬 (Matrix)
2차원
[[1, 2], [3, 4]]
텐서 (Tensor)
3차원 이상
이미지 배치, 영상 데이터 등
이때, 반드시 3차원 이상의 데이터만을 텐서라고 가리키는 것은 아니다. 벡터는 1D Tensor, 행렬은 2D Tensor라고도 부를 수 있으며, 텐서는 이들을 모두 포괄하는 일반화된 개념이다.
- rank
선형대수학에서 Rank는 행렬의 열(또는 행) 중 선형 독립인 벡터의 수, 즉 기저 벡터의 개수를 의미했었다. 반면 텐서를 다룰 때의 Rank는 의미가 다소 달라지는데, 텐서가 가진 축(axis)의 수, 즉 몇 차원인지를 나타내는 개념으로 사용된다.
이처럼 텐서라는 개념을 통해, 스칼라부터 고차원 데이터까지 다양한 형태의 수치 데이터를 하나의 일관된 자료구조로 표현하고 다룰 수 있게 된다.
1.2 Tensor in PyTorch
- Tensor 생성
다음과 같이 tensor를 정의할 수 있다.
t1 = torch.tensor([0, 1, 2, 3])
t2 = torch.tensor([[1,2,3],
[4,5,6]])
이 외에도 특정 값으로 초기화된 텐서를 생성하는 다양한 방법이 있다.
torch.zeros(2, 3) # 0으로 채워진 2x3 텐서
torch.ones(2, 3) # 1로 채워진 2x3 텐서
torch.full((2, 3), 7) # 7으로 채워진 2x3 텐서
torch.eye(3) # 3x3 단위 행렬(대각선이 1)
torch.rand(2, 3) # 0~1 사이의 균일 분포 난수 텐서
torch.randn(2, 3) # 평균 0, 표준편차 1의 정규 분포 난수 텐서
- rank, 형태 확인
dim() : 텐서의 rank(차원 수)를 반환한다.
shape, size() : 텐서의 크기(shape)를 반환한다.
print(t1.dim()) # rank = 1
print(t1.shape) # torch.Size([4])
print(t2.dim()) # rank = 2
print(t2.shape) # torch.Size([2, 3])
print(t2.size(0)) # 2 (첫 번째 축의 크기)
print(t2.size(1)) # 3 (두 번째 축의 크기)
- Tensor의 자료형 (dtype)
텐서는 담고 있는 값의 자료형에 따라 여러 종류로 나뉜다.
자료형
PyTorch dtype
설명
torch.FloatTensor
torch.float32
32비트 부동 소수점 (가장 널리 사용)
torch.DoubleTensor
torch.float64
64비트 부동 소수점
torch.HalfTensor
torch.float16
16비트 부동 소수점 (경량화 모델에 주로 사용)
torch.IntTensor
torch.int32
32비트 정수
torch.LongTensor
torch.int64
64비트 정수 (인덱스, 레이블에 주로 사용)
torch.BoolTensor
torch.bool
True / False
자료형은 텐서 생성 시 dtype= 인자로 지정하거나, 이후 to() 혹은 type()으로 변환할 수 있다.
t = torch.tensor([1, 2, 3], dtype=torch.float32)
print(t.dtype) # torch.float32
t = t.to(torch.float64)
print(t.dtype) # torch.float64
혹은 텐서 뒤에 원하는 자료형을 붙여 타입 캐스팅이 가능하다.
print(t.float()) # tensor([1., 2., 3.]) → float32
print(t.double()) # tensor([1., 2., 3.]) → float64
print(t.half()) # tensor([1., 2., 3.]) → float16
print(t.long()) # tensor([1, 2, 3]) → int64
print(t.int()) # tensor([1, 2, 3]) → int32
print(t.bool()) # tensor([True, True, True])
딥러닝 모델에서는 대부분 float32를 기본 자료형으로 사용한다.
torch.tensor() => 타입이 인풋에 따라감, torch.Tensor() => 무조건 float
- Tensor와 NumPy 변환
PyTorch 텐서는 NumPy 배열과 상호 변환이 가능하다.
tensor(numpy배열) : numpy배열을 파이토치 tensor로 변환한다. 이때 새 메모리를 할당 받음.
from_numpy(numpy배열) : 똑같이 파이토치 tensor로 변환하다. 원본 NumPy 배열과 메모리를 공유한다. 한쪽을 수정하면 다른 쪽도 영향을 받으니 주의.
# NumPy → Tensor
arr = np.array([1.0, 2.0, 3.0])
t = torch.Tensor(arr)
t2 = torch.from_numpy(arr)
# Tensor → NumPy
arr2 = t.numpy()
print(t) # tensor([1., 2., 3.])
print(t.dtype) # torch.float32
print(t2) # tensor([1., 2., 3.], dtype=torch.float64)
print(arr2) # [1. 2. 3.]
1.3 Tensor 연산
텐서 간의 기본적인 사칙연산은 동일한 shape일 때 element-wise(원소별)로 수행된다.
a = torch.tensor([1.0, 2.0, 3.0])
b = torch.tensor([4.0, 5.0, 6.0])
print(a + b) # tensor([5., 7., 9.])
print(a - b) # tensor([-3., -3., -3.])
print(a * b) # tensor([ 4., 10., 18.])
print(a.mul(b)) # tensor([ 4., 10., 18.]) ==> * 연산자와 같은 역할
print(a / b) # tensor([0.25, 0.40, 0.50])
행렬 곱(Matrix Multiplication)은 @ 연산자 또는 torch.matmul()을 사용한다.
A = torch.tensor([[1.0, 2.0],
[3.0, 4.0]])
B = torch.tensor([[5.0, 6.0],
[7.0, 8.0]])
print(A @ B)
# tensor([[19., 22.],
# [43., 50.]])
1.4 Tensor 조작 메서드
:: sum, mean
A = torch.tensor([[1.0, 2.0],
[3.0, 4.0]])
print(A.sum()) # tensor(10.)
print(A.sum(dim=0)) # tensor([4., 6.])
print(A.mean()) # tensor(2.5000)
print(A.mean(dim=0)) # tensor([2., 3.])
:: max (argmax)
max()로 호출하면 텐서의 최댓값을 반환하고, dim을 파라미터로 같이 전달하면 argmax 정보가 담긴 텐서와 같이 리턴된다.
print(A.max()) # tensor(4.)
print(A.max(dim=0))
# torch.return_types.max(values=tensor([3., 4.]),indices=tensor([1, 1]))
print(A.max(dim=-1))
# torch.return_types.max(values=tensor([2., 4.]),indices=tensor([1, 1]))
print(A.max(dim=0)[1]) # tensor([1, 1])
:: view
view를 호출하는 텐서의 원소의 갯수를 유지하며 shape을 변경한다.
A = torch.tensor([[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[10, 11,12]])
print(A.shape) # torch.Size([4, 3])
print(A.view(3,-1).shape) # torch.Size([3, 4])
print(A.view(2,2,-1).shape) # torch.Size([2, 2, 3])
print(A.view(-1)) # tensor([ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
:: squeeze, unsqueeze
squeeze(): 크기가 1인 차원을 삭제
unsqueeze(dim): 크기가 1인 차원을 추가
A = torch.tensor([[[1, 2, 3],
[4, 5, 6]]])
print(A.shape) # torch.Size([1, 2, 3])
print(A.squeeze().shape) # torch.Size([2, 3])
A = torch.tensor([[[1, 2, 3, 4, 5, 6]]])
print(A.shape) # torch.Size([1, 1, 6])
print(A.squeeze().shape) # torch.Size([6])
print(A.squeeze(0).shape)# torch.Size([1, 6])
A = torch.tensor([1, 2, 3, 4, 5, 6])
print(A.shape) # torch.Size([6])
print(A.unsqueeze(0).shape) # torch.Size([1, 6])
print(A.unsqueeze(1).shape) # torch.Size([6, 1])
-
1. 행렬이론
행렬의 행벡터, 열벡터
행렬에서 행(Row)은 가로줄, 열(Column)은 세로줄을 의미한다.
행벡터(Row Vector = Row Matrix)는 하나의 행으로 이루어진 $1 \times n$ 행렬을 의미하고,
열벡터(Column Vector = Column Matrix)는 하나의 열로 이루어진 $m \times 1$ 행렬을 의미한다.
행렬의 곱셈
곱셈 조건: 앞 행렬($A$)의 열의 수와 뒤 행렬($B$)의 행의 수가 일치해야 곱 $AB$가 정의된다.
교환법칙 불성립: 일반적으로 $AB \neq BA$이다. 단, $A = pB + qE$ 또는 $B = pA + qE$ 꼴(한 행렬이 다른 행렬과 단위행렬의 선형결합으로 표현되는 경우)이면 $AB = BA$가 성립한다.
결합법칙: 연산 순서가 동일하면 성립한다.
\((AB)C = A(BC)\)
분배법칙:
\(A(B+C) = AB + AC, \qquad (A+B)C = AC + BC\)
2×2 케일리-해밀턴 정리
\[A^2 - (\operatorname{tr}A)\,A + (\det A)\,E = O\]
(단, $\operatorname{tr}A$는 대각합, $\det A$는 행렬식, $E$는 단위행렬, $O$는 영행렬)
대각행렬, 삼각행렬
$A$, $B$가 둘 다 상삼각행렬이면 → $AB$도 상삼각행렬
$A$, $B$가 둘 다 하삼각행렬이면 → $AB$도 하삼각행렬
대각행렬끼리는 항상 교환법칙 성립: $D_1 D_2 = D_2 D_1$
전치행렬, 대칭행렬
전치행렬(Transpose): 행렬 $A$의 행과 열을 서로 바꾼 행렬을 $A^T$라 한다.
$(A^T)^T = A$
$(A+B)^T = A^T + B^T $
$(kA)^T = kA^T \quad (k\text{는 스칼라})$
$(AB)^T = B^T A^T$ , $(ABC)^T = C^T B^T A^T $
$\det(A^T) = \det(A)$
$(A^{-1})^T = (A^T)^{-1} \quad (A\text{가 가역행렬일 때})$
대칭행렬(Symmetric Matrix): $A^T = A$를 만족하는 정사각행렬.
반대칭행렬(Skew-symmetric Matrix): $A^T = -A$를 만족하는 정사각행렬.
$A,\,B$가 대칭행렬이면 $A+B,\ A-B,\ kA$도 대칭행렬
$A$가 대칭행렬이면 $A^n\ (n=1,2,3,\dots)$도 대칭행렬
$A$가 가역인 대칭행렬이면 $A^{-1}$도 대칭행렬
$A,\,B$가 대칭행렬일 때, $AB$가 대칭행렬 $\iff AB = BA$
임의의 정사각행렬 $A$에 대해 $A+A^T$는 대칭행렬, $A-A^T$는 반대칭행렬
모든 정사각행렬은 대칭행렬과 반대칭행렬의 합으로 유일하게 분해됨: $A = \tfrac{1}{2}(A+A^T) + \tfrac{1}{2}(A-A^T)$
정사각행렬이 아니어도, 임의의 행렬 $A$에 대해 $A^TA,\ AA^T$는 항상 대칭행렬