JAX란, 머신러닝의 연산을 가속화하기 위해, Google에서 발표한 컴파일러를 최적화하는 기술이다.
JAX는 머신러닝에서 필수적인 Autograd와 XLA(integrated with Accelerated Linear Algebra, 가속 선형 대수) 컴파일러를 통해, 머신러닝의 연산을 빠르게 실행해 준다.
JAX는 설치가 매우 쉽고, 기존 Python에서 구현된 Numpy를 쉽게 변환할 수 있어서, 많이 활용되고 있다.
다만 JAX는 구글의 공식 제품이 아닌, 연구 프로젝트 기 때문에, 아직은 이런저런 버그가 있을 수 있다고 한다.
JAX 설치 방법
JAX는 우선 기본적으로 Linux나 Mac 운영 체제에서 동작한다.
Window도 동작하기는 하지만, 실험버전으로 CPU를 활용한 jax만 지원된다. (WSL을 사용하면 GPU를 사용할 수 있긴 하다.)
[CPU 설치]
pip install --upgrade "jax[cpu]"
[GPU & TPU 설치]
GPU에서도 pypi를 통해 쉽게 설치가 가능하다. 하지만, GPU는 Linux 환경에서만 설치되는 것을 명심하자. (나의 경우에는 WSL로 진행했다.)
# CUDA 12
pip install --upgrade "jax[cuda12_pip]" -f https://storage.googleapis.com/jax-releases/jax_cuda_releases.html
# CUDA 11
pip install --upgrade "jax[cuda11_pip]" -f https://storage.googleapis.com/jax-releases/jax_cuda_releases.html
JAX 기본 기능
jax.numpy
jax는 기본적으로 jax.numpy를 통해, numpy의 API를 그대로 호환해 준다.
jax.numpy와 numpy는 거의 비슷하지만, 차이가 있는데, jax는 함수형 프로그래밍으로 설계되어 있다는 점이다.
즉, numpy는 배열에 직접 접근해서, 값을 바꾸는 것이 허용되지만, jax.numpy는 데이터를 직접 조작하는 것이 허용되지 않는다. → 거의 모든 Python 가속기들의 특징인 것 같다.
다만, 값을 직접 바꾸는 것은 불가능하지만, 해당 요소를 반영한 새로운 배열을 생성할 수 있다.
import jax.numpy as jnp
if __name__ == '__main__':
data = jnp.array([1,2,3,4])
data[0] = 5
# ERROR
data = data.at[0].set(5)
# data = [5,2,3,4]
[grad]
JAX는 native Python 및 numpy 코드를 자동으로 미분할 수 있는 기능을 제공한다.
JAX의 grad 함수는 함수의 입력에 대한 gradient를 자동으로 계산해 주는 함수이다.
JAX의 grad 함수는 loss의 기울기를 구할 때, 매우 빠르고 쉽게 활용될 수 있다.
JAX의 grad는 N차 미분값까지 쉽게 구할 수 있다.
import jax
import jax.numpy as jnp
def square(x):
return x ** 2
if __name__ == '__main__':
grad_square = jax.grad(square)
# Calculate Gradient
x = jnp.array(2.0)
grad_value = grad_square(x)
print("Input:", x)
print("Gradient:", grad_value)
[jit]
jax.jit 함수는 JAX에서 제공하는 함수를 최적화해 주는 메커니즘이다.
jit 함수를 통해, 정의한 함수를 컴파일하여, 최적화된 코드로 변환하고, 이를 Cache에 저장해 둔 뒤, 호출 시, 최적화된 코드를 통해 빠르게 실행된다.
최적화된 Code를 Cache에 저장해 두기 때문에, 반복 변환이나, 불필요한 변환은 피하는 것이 좋다.
다만, jit은 아래와 같은 경우에는 속도 향상이 없거나, 오히려 늦어질 수 있다.
변환하려는 함수 내에 제어문이 포함된 경우
재귀함수
데이터를 조작하는 함수
크고 복잡한 함수 → 변환을 위한 cost가 더 많이 들 수 있음
다른 모듈처럼, jit 사용을 위해, 단순 decorator만 사용해 주면 된다. 하지만, 변환을 위한 cost가 더 많이 들 수 있기 때문에, 꼭 비교해 보고 사용하는 것이 좋다.
import jax
import jax.numpy as jnp
@jax.jit
def square(x):
return x ** 2
if __name__ == '__main__':
grad_square = jax.grad(square)
# Calculate Gradient
x = jnp.array(2.0)
grad_value = grad_square(x)
print("Input:", x)
print("Gradient:", grad_value)
[vmap]
jax.vmap 함수는 함수를 Vector 화하여 mapping 하는 함수이다.
vmap 함수를 통해, 배열의 각 요소에 함수를 병렬로 실행할 수 있다. (pandas의 apply와 비슷한 개념이다.)
jit과 vmap은 같이 사용될 수 있다. (jit을 먼저 래핑 한 후, vmap을 하거나, vmap을 래핑한 후, jit을 하거나 둘 다 가능하다.)
import jax
import jax.numpy as jnp
def dot_product(x, y):
return jnp.dot(x, y)
if __name__ == '__main__':
grad_square = jax.grad(dot_product)
vectorized_dot_product = jax.vmap(dot_product)
x = jnp.array([i for i in range(10000)])
y = jnp.array([i for i in range(10000)])
grad_value = dot_product(x, y)
JAX 사용후기
JAX는 기본적으로 multi GPU 환경이나, TPU 환경에서 유리하다. 나의 경우에는 single GPU 환경이기 때문에, JAX를 쓰면 오히려 변환에 더 오랜 시간이 걸렸다. (JAX가 분산에 최적화되었기 때문이다.)
JAX가 numpy를 호환한다고 하지만, 아직 torch 등의 딥러닝 프레임워크와 호환이 부족하다. 따라서, 단순 기존 코드의 최적화가 아닌, 분산 환경에서 속도를 향상시키기 위한 대대적 Refactoring이나 개발에 사용하는 것이 좋을 것 같다.
JAX는 현재 기준(2023.08.07)으로 CUDA 11버전까지만 지원한다. 이것도 환경을 제한하는 요소인 것 같다.
그럼에도 불구하고, JAX는 딥러닝 코드를 Python 언어 내에서 최적화할 수 있는 선택지를 제공한다는 점에서 매우 유용한 것 같다.
Pandas는 파이썬에서 데이터 처리와 분석을 위한 라이브러리로, numpy를 기반으로 개발되었다.
Pandas는 DataFrame과 Series라는 데이터 구조를 사용하여, 데이터를 쉽게 처리할 수 있도록 한다.
Pandas는 C 언어 등, low level의 언어로 개발된 numpy를 기반으로 하고, 수년간의 버전 업그레이드로 그 자체에 최적화를 해놓았기 때문에, 일반적으로 Pandas에서 제공하는 함수들을 사용하는 것이 성능 면에서 가장 좋다.
하지만, 데이터 크기와 연산의 복잡성에 따라, 특정한 상황에서는 Pandas의 성능을 최적화하기 위한 방법이 필요하다.
Pandas의 데이터 처리 최적화 원리
Pandas는 기본적으로 low level 언어로 최적화가 많이 되었기 때문에, Pandas 데이터 처리를 위한 연산 과정에 Python 언어로 처리하는 과정이 생략되는 것이 좋다.
Pandas는 메모리 위에서 동작한다. 이에 따라, 메모리의 가용 용량을 벗어나는 데이터 처리 및 연산은 한 번에 처리할 수 없다. 따라서, 메모리를 효율적으로 사용할 수 있도록 변경하는 것이 좋다.
Pandas 데이터 로드
사실, Pandas를 많이 활용하는 이유 중 하나는 Pandas의 Dataframe이 SQL 테이블 형태와 거의 유사하기 때문이다.
Pandas에 들어갈 데이터를 Code 내부에서 주입하는 경우도 있지만, 대부분의 경우, Database나, CSV File 등에서 Import 해오는 경우가 많다.
Pandas는 앞서 말한대로, 메모리에 DataFrame을 올려놓고, 연산하는 형태이기 때문에, 메모리가 연산을 효율적으로 처리할 수 있도록, 작은 단위의 필요한 데이터만 사용하는 것이 연산 측면에서 유리하다.
1. Query 및 파일 최적화
Pandas에서 SQL이나 CSV 등의 Raw 형태의 데이터를 읽고, 이를 Filtering하여Filtering 하여 사용하는 경우가 많은데, 이는 Raw 데이터 전체를 메모리 올려, 메모리 & I/O 부담을 증가시킨다. 따라서, 필요한 데이터만 미리 Filtering 하여 가져오는 것이 좋다.
이렇게 Pandas에서 필요한 데이터만 가져오면, 전체 Series의 갯수(Row 수)가 줄기 때문에, Index 활용 측면에서도 유리하다.
예시로, 한국어 형태소 분석을 위해 SQL 테이블에서 1주일치 데이터를 읽어서, 처리하였는데, 하루씩 읽어서 7번 처리하는 게 속도 면에서 더 효율적이었다.
chuncksize를 지정해줘서, 한 번에 읽을(메모리에 올릴) Series(row) 수를 지정할 수 있다.
하지만, 전체 데이터가 같이 필요한 것들(특정 칼럼 sort 등)은 처리가 까다롭기 때문에, 다른 행들 간의 연산이 비교적 적은 경우에 활용하는 것이 좋다.
for chunk in pd.read_csv('raw_data.csv', chunksize=10000):
processing(chunk)
4. Dask 사용
만약, 메모리가 감당하기 어려운 정도의 어려운 정도의 데이터 양과 연산이 포함된다면, 대용량 데이터를 분산 처리하기 위해 개발된, Dask를 사용할 수 있다.
Dask는 Pandas와 달리, Disk에 저장된 데이터를 처리하기 때문에, 여러 머신에서 분산처리가 가능하고, 지연 연산을 사용하기 때문에, 실제 연산을 최적화하는 과정이 포함된다.(SQL의 실행 Plan을 생각하면 된다.) 따라서, 초 대용량 데이터 처리에는 Dask의 강점이 있다.
하지만, 메모리가 감당 가능한 수준의 연산에서는 메모리와 디스크의 속도 차이 등 때문에, Pandas가 유리하다.
import dask.dataframe as dd
if __name__ == "__main__":
df = dd.read_csv('raw_data.csv')
Pandas 연산 & 조회
[실험 데이터셋]
Pandas 연산 테스트를 위해, 예시 데이터로 Kaggle 데이터셋을 사용했다. (https://www.kaggle.com/datasets/jordankrishnayah/45m-headlines-from-2007-2022-10-largest-sites?resource=download)
사용할 데이터는, 4.5M 분량의 2007년부터 2022년 주요 언론사의 기사 제목 headline 데이터이다. 데이터는 총, 4405392개 row로 구성되어 있고, [Date, Publication, Headline, URL]의 4개의 칼럼으로 구성되어 있다.
1. 반복문 최적화
Pandas 연산에서 가장 큰 성능 개선 포인트는 반복문 연산이다.
Pandas가 Python 언어로 동작하기 때문에, Python의 list의 개념으로 Dataframe을 다루기 때문에, 이런 문제가 많이 발생한다.
Pandas를 For문을 통해, 각 row에 접근하는 경우에는, 각 row마다 연산을 각각 실행한다. 이에 따라, 데이터 크기가 커질수록 연산의 Overhead는 가중화된다.
가장 쉬운 방법은 Pandas의 apply를 사용하는 것이다.
또한, Numpy Vectorize를 사용하는 방법, iterrows, itertuples를 사용하는 방법들이 있는데, 일반적으로 itertuples와 numpy vectorize는 Pandas Apply보다 좋은 성능을 보인다고 알려져 있다.
추가적으로 멀티스레드를 이용하여, Pandas 연산을 병렬화 하고, 효율적으로 처리하는 swifter가 있다.
(테스트 상황)
headline 데이터셋에서 URL의 "http://" or "https://" 부분을 제거하는 작업을 테스트
headline 데이터셋에서 Date의 연도를 제거하는 작업을 테스트
(1) 단순 For문
for i in range(data.shape[0]):
data["URL"][i] = data["URL"][i].replace('http://', '').replace('https://', '')
→ 실행 시간 : 94848.09 s(1000 row 실행시간: 21.53으로 추정)
for i in range(data.shape[0]):
data["Date"][i] = data["Date"][i]%10000
data["Date"] = data["Date"].swifter.apply(lambda x: x % 10000)
→ 실행 시간 : 0.37s
[실험 결과]
Python 영역의 연산을 활용하는 경우(Python 라이브러리 or String 사용 등)에는 apply나 itertuples를 사용한 순회가 가장 좋은 성능을 보인다. → 첫 번째 실험
Python 영역의 연산을 활용하지 않는 경우, 즉, Cython으로 변환이 가능한 연산등은 np.vectorize가 가장 좋은 성능을 보인다.
데이터의 타입, 크기, 연산에 따라, 가장 적합한 연산은 다르겠지만,
일반적으로 apply나 itertuples를 사용한 순회가 가장 좋다.(일반적으로 대용량에선 itertuples가 apply보다 낫다고 함.)
연산이 간단한 경우(Cython으로 변환이 될만한 간단한 연산)에는 np.vectorize를 통해 최적화가 가능하다.
단순 for문은 사용하지 않는 것이 좋다.
Swifter는 데이터의 크기가 매우 크고, 연산이 복잡하지 않은 연산에서 효과적이다.
2. 특정 조건 데이터 조회
특정 조건 데이터 조회는 Pandas에서 자주 사용된다.
연산에 비해, 긴 시간이 걸리지는 않지만, 데이터가 많고, 연산이 복잡해질수록 조건에 맞는 데이터를 찾는 시간이 오래 걸린다.
(테스트 상황)
headline 데이터셋에서 2022년부터 데이터 중, New York Times의 데이터를 찾으려고 한다.
(1) Boolean Type으로 indexing
가장 일반적인 방법이다. 여러 개의 칼럼들의 조건의 boolean 형태로 각각 연산하여 구할 True인 값만 가져올 수 있다.
filtered_data = data[(data["Date"]>20220000) & (data["Publication"]=='New York Times')]
→ 실행 시간 :0.14s
(2) loc를 이용한 indexing
Boolean Type으로 indexing과 동일하다.
filtered_data = data.loc[(data["Date"]>20220000) & (data["Publication"]=='New York Times')]
→ 실행 시간 :0.14s
(3) query를 사용한 조회
Dataframe은 query를 지원한다. (하지만, like 등의 조건은 지원하지 않는다.)
참조하는 칼럼이 많고, 데이터가 클수록, query를 내부적으로 최적화하는 단계가 있어 더 좋은 성능을 보인다.
filtered_data = data.query("Date >20220000 and Publication == 'New York Times'")
→ 실행 시간 :0.07s
(4) isin을 사용한 indexing
큰 범위애서 보면, Boolean Type으로 indexing에 속하는데, 특정 문자열과 일치하는 조건을 찾을 때는, boolean type에 isin을 넣어주면 더 빨리 찾을 수 있다.
filtered_data = data[(data["Publication"].isin(['New York Times'])) & (data["Date"] > 20220000)]
→ 실행 시간 :0.05s
(5) itertuples를 사용한 순회
순회를 이용한 데이터 indexing은 별로 좋은 방법은 아니다.
하지만, 연산과 조회를 같이하는 경우에는 한 번의 순회에 조회 조건을 넣어, 데이터를 찾는 것도 고려해 볼 수 있다.
find_index = []
for i, row in enumerate(data.itertuples(index=False), start=0):
if row.Date > 20220000 and row.Publication == 'New York Times':
find_index.append(i)
filtered_data = data.iloc[find_index]
→ 실행 시간 :2.01s
[실험 결과]
일반적으로 사용되는 boolean을 사용하는 것이 좋다고 알려져 있지만, 참조하는 칼럼이 많고, 데이터가 많을 경우에는 query를 사용하는 것이 효과적일 수 있다.
Boolean Type도 단순히 조건을 넣어서 indexing 하는 것보다, isin 같은 pandas 연산자를 함께 사용해서 데이터를 찾는 것이 효율적이다.
3. 문자열 포함 검색
SQL에서는 LIKE라는 특정 문자열을 포함했는지 여부를 찾는 방법이 있지만, Pandas에서는 LIKE를 지원하지 않는다.
생각보다, 특정 문자를 포함하는지 여부를 점검하는 경우가 많은데, 이런 경우 어느 방법이 효과적일까?
Transformer는 사실, NLP분야뿐만아니라, 다양한 분야에서 많이 사용되기 때문에, 그만큼 구현 소스를 쉽게 찾을 수 있다. 나도, Transformer를 자주 사용하지만, 라이브러리에서 읽어오는 형태로 사용하기 때문에, 그 상세 구조에 대해서는 대략적으로만 알고 있다. 이번 기회에 Transformer를 pytorch로 직접 짜보면서 그 구조를 정확히 이해하고자 한다.
Multi-Head Attention은 scaled Dot-Product Attention을 query에 해당하는 value 값들을 참조하기 위해 사용하는데, query, key, value를 그대로 사용하는 것이 아니라, 여러 개의 head로 나누고, query, key, value를 linear projection 한 후, 사용한다.
Scaled Dot-Product Attention 이후, 각 head의 value 값을 concat하고, linear layer을 거쳐 output을 낸다.
주의할 점은, sequence의 순서가 중요하기 때문에, contiguous를 사용해서, 순서를 유지한다는 점이다.
실제 NLP 단어 예측 등, 데이터셋을 넣어보기 위해, dataloader와 학습 등을 연결해 봐야겠다.
특정 task를 풀기 위해, 데이터셋을 처리하기 위한 model을 짜는 것도 좋지만, 가끔은 논문을 그대로 구현해 보는 것도 좋을 것 같다. 특히, 그림과 글만 보고 구현을 하려고 하니, 내가 정확하게 알지 못했던 부분, 특히 머리로 이해하고 넘어간 부분을 완전히 알게 된 것 같아 좋다.
NLP 분야에서 광범위하게 사용되고 있던, Transformer를 computer vision 분야에 적용해 좋은 성능을 보여주었다.
Abstact
Transformer가 NLP 분야에서는 standard로 자리 잡았지만, computer vision 분야에서 활용은 아직 한계가 있다.
vision 분야에서는 attention은 attention은 CNN과 함께 적용되거나, 그 요소를 바꾸는 데 사용하는 등, 전체적인 구조는 그대로이다.
이 논문에서는 CNN구조의 중심이 불필요하고, image patches를 sequence 형태로 pure transformer에 바로 적용하는 것이 image classification에서 잘 working한다는 것을 보여준다.
많은 양의 pre-trained 데이터로 학습하고, 적은 양의 image recognition benchmark들로 실험했을 때, Vision Transformer(ViT)는 SOTA CNN 구조보다 학습에 적은 연산 cost를 사용하면서 좋은 성능을 낸다.
Introduction
[배경]
NLP 분야에서는 Transformer를 선두로 한, Self-attention-based 구조를 채택해서, 매우 좋은 성능을 보이고 있다.
Computer vision에서는 CNN 구조가 아직 대세이다. CNN 구조와 self-attention 구조를 연결하려고 노력하거나, conviolution을 대체하는 등 여러 연구들도 있었다.
특히, convolution을 대체하는 연구는 이론적으로는 효율적이지만, 현재(그 당시) hardware accelerator 구조로는 적용이 어려웠다. 따라서, large-scale image recognition에서는 classic resnet 구조가 아직 SOTA였다.
[소개]
이 논문에서는 standard Transformer를 거의 변경없이, 이미지에 적용한다. 이를 위해, image를 patch로 나누고, 이 patch들을 linear embeddings sequence 형태로 transformer에 넣는다. (image patche들은 NLP의 token처럼 다뤄진다.)
모델은 image classification을 supervised learning으로 학습한다.
ImageNet 같은 mid-sized 데이터셋에서 이 모델은 비슷한 크기의 resnet보다 몇 % 정도 낮은 수준의 정확도를 보인다.
이 결과는 기대에 밑돌아, Transformer가 inductive biases(추상적 일반화)가 CNN에 비해 떨어진다고 생각할 수 있다.
하지만, 대용량 dataset에서 실험했을 때, 상황은 바뀐다.
ViT는 충분한 pre-trained 데이터가 있을 때, 매우 좋은 성능을 보인다.
Method
모델 디자인은 original Transformer를 최대한 비슷하게 따랐다. (구현이 되어 있기에 바로 적용할 수 있어서)
[ViT]
Transformer는 1D 데이터를 처리하는데, 이미지는 2D이다. 이미지(HXWXC)를 다루기 위해, Image를 2D patches(PXPXC) 개로 나누었다.
Transformer는 각 layer에서 constant latent vector size D를 유지하는데, 이를 위해, patch들을 학습 가능한 linear projection을 통해, D dimension 화하였다. 그리고, 이 결과를 patch embeddings라고 부른다.
BERT의 class token 처럼, patch embedding의 앞에 learnable embedding을 붙인다. 이것은 Transformer encoder의 output이 이미지 representation(y)이 될 수 있도록 사용된다.
pre-training과 fine-tuning 단계에서 모두, Transformer encoder의 ouput이 classification의 head로 이용된다.
classification head는 pre-training 단에서는 one hidden layer의 MLP로, fine-trurning 단계에서는 하나의 linear layer로 구성된다.
Position embeddings는 patch embeddings에 더해져, 공간 정보를 제공한다.
ViT에서는 학습 가능한 1D position embeddings를 사용했는데, 2D의 position embedding이 성능에 딱히 영향이 없는 것 같아서 그랬다고 한다.
Transformer encoder는 multiheaded self-attention의 대체 layer들과 MLP block으로 구성되어있다.
Layernorm은 각 block 전에 적용되어 있고, 모든 block 끝에는 residual connection이 존재한다.
MLP는 GELU 함수를 사용한 2개의 latyer로 구성되어 있다.
[Inductive bias]
ViT는 image specific inductive bias가 CNN에 비해 덜하다.
CNN에서는 지역 정보, 2D neighborhood 정보, translation 등분산성(골고루 보고 처리한다.)이 각 layer에 담긴다.
ViT에서는 MLP layer에서만 translation 등분산성과 지역 정보를 보고, self-attention layer들에서는 전체적으로 본다.
2D neighborhood 정보는 드물게 사용된다. model의 시작에 이미지를 cutting 하고, fine-tuning 때는 position emeddings를 다른 resolution으로 처리하기 때문이다. 또한, initinalization 시에 embedding에는 정보가 없기 때문에, patch의 2D 위치와 patch 간의 공간 관계에 대해 처음부터 스스로 학습해야 한다.
[Hybrid Architecture]
raw image patches의 대안으로, CNN의 feature 형태로 input sequence를 구성할 수 있다. hybrid model에서는 patch embedding을 CNN feature map으로부터 뽑는다.
어떤 케이스에서는 1X1이 될 수 있는데, 이 것은 input sequence가 spatial dimension 정보를 flatten 한 케이스이다.
기본적으로 ViT를 large dataset에서 pre-train 하였고, 적은 데이터셋에서 fine-tune 하였다.
이를 위해, pre-trained prediction head를 지우고, zero-initialized D X K layer를 넣었다. (K는 classification class 개수)
pre-trained 보디, fine-tune 때 높은 해상도의 이미지를 사용하는 것이 유리하다.
고해상도 이미지를 넣을 때, patch size는 동일하게 유지한다. (sequence length만 늘어난다.)
ViT는 임의의 sequence length를 다룰 수 있지만, 그러면 pre-training 된 position embedding은 더 이상 의미가 없다.
이때는 pre-trained position embedding에 original image에서의 위치에 따라, 2D interpolation을 통해 처리했다.
Experiments
Resnet, ViT, hybrid model을 실험했다.
각 모델의 데이터 필요도를 확인하기 위해, 다양한 사이즈에서 pre-train을 하였고, 다양한 benchmark에서 실험했다.
pre-training의 연산 cost를 생각했을 때, ViT는 매우 순조롭게, 적은 양의 연산 cost 만으로 SOTA recognition benchmark에 도달했다.
[Setup]
Dataset : ILSVRC-2012 ImageNet 데이터셋(1000 classes, 1.3M images), superset ImageNet-21k(21k classes, 14M images), JFT(18k classes, 303M high resolution images)을 사용했다. benchmark로는 ImageNet의 original validation labels와 cleaned-up Real labels, CIFAR-10/100, Oxford-IIIT Pets, Oxford Flowers-102를 사용했다.
Model Variants : ViT의 configuration는 BERT를 기반으로 했다. patch size가 작아질 수 록 연산은 expensive 해진다. (token이 많다고 생각하면 됨) CNN의 baseline으로는 ResNet을 사용했지만, Batch Normalizaation layer를 Group Normalization으로 대체했다.
[SOTA와 비교]
특정 모델에서 ImageNet의 SOTA인 NoisyStudent보다 좋은 성능을 보여준다.
Conclusion
image recognition에 Transformer를 사용해 보았다.
과거 computer vision에서의 self-attention 구조와 다르게, image-specific inductive biases를 구조에 넣지 않았다.
대신에, 이미지를 patch들의 sequence로 다뤄, NLP처럼 처리했다.
scalable 하고, large dataset에서 pre-training 했을 때, 잘 working 하여, ViT는 pre-trained에 많은 cost를 쓰지 않고도, SOTA image classification에 버금가는 성능을 보여주었다.
아직, 나아갈 길이 많다. (detection이나 segmentation 적용 등)
Reference
Dosovitskiy, Alexey, et al. "140 Thomas Unterthiner, Mostafa Dehghani, Matthias Minderer, Georg Heigold, Sylvain Gelly, 141 Jakob Uszkoreit, and Neil Houlsby. An image is worth 16x16 words: Transformers for image 142 recognition at scale." ICLR 3 (2021): 143.
총평
NLP 분야에서 혁신을 이뤄낸, transformer를 vision 분야에 도입하여 성능을 낸 게, 지금 시점에서는 당연해 보이지만, 이 도입을 위해 얼마나 고민하고, 실험했을지 싶다.
NLP처럼 transformer를 시작으로, GPT, BERT 등으로 이어지는 거대 모델의 흐름이 vision에도 적용될 것인지 살펴봐야겠다.
이때, profiling의 결과는 테이블 구조의 텍스트 형태로 터미널에 출력 or 파일에 저장 가능하다.
이 결과로도 Insight를 충분히 추출할 수 있지만, 텍스트 형태로 분석하다 보면, 가시성이 떨어진다는 점과, profiling 이력 간 비교가 어렵다는 점이 아쉽다.
이를 해결할 수 있는 딥러닝의 시각화 툴인 Tensorboard에 profiling 결과를 올리는 방법이 있어서, 이 방법을 알아보고자 한다.
원리
torch profiler의 옵션에 "on_trace_ready"라는 옵션이 존재한다. 이것은 profiling 결과가 준비되었을 때, 호출할 callback 함수를 지정하는 것인데, 이 callback 함수로 tensorboard에서 제공하는 trace handler를 연결하여, tensorboard에서 읽을 수 있는 log 형태로 떨궈준다.
Setup
tensorboard에서 torch profiler의 결과를 읽어서 표현할 수 있는 plugin을 추가로 설치해야한다. (당연히, tensorboard가 필요하기 때문에, 아래 plugin을 설치하면, tensorboard도 자동으로 설치된다.)
pip install torch_tb_profiler
사용법
torch profiler를 이용하기 위한, 콘텍스트 관리자(with 절)에 on_trace_ready 옵션에, "tensorboard_trace_handler" 함수를 지정해 준다.
이때, tensorboard_trace_handler 함수의 인자로, tensorboard에서 읽을 수 있도록 log 디렉터리를 지정해 준다.
...
with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], record_shapes=True,
profile_memory=True, on_trace_ready=torch.profiler.tensorboard_trace_handler('./log/resnet18')) as prof:
for epoch in range(TRAIN_EPOCH):
running_loss = 0.0
for i, data in enumerate(trainloader, 0):
inputs, labels = data[0].to(device), data[1].to(device)
optimizer.zero_grad()
with record_function("model_inference"):
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item()
if i % TRAIN_PRINT_FREQUENCY == TRAIN_PRINT_FREQUENCY - 1:
print(f'Epoch: {epoch + 1}, Batch: {i + 1}, Loss: {running_loss / 200:.3f}')
running_loss = 0.0
...
그 후에, tensorboard 실행을 해주면 끝난다.
tensorboard --logdir=./log/resnet18
결과
[NORMAL]
Overview : Overview는 전체적인 프로파일링의 결과에 대해 보여주는 화면이다.
Configuration : Profiling 시 사용된 설정 정보를 표시한다.
Execution Summary : 전체 수행 시간과, 각 단계에 소요된 수행시간을 보여준다.
Spen Time Breakdown : 코드 또는 연산의 실행 시간을 단계별(Kernel, Memcpy, Memset, Runtime, DataLoader, CPU Exec, Other)로 분해하여 보여준다.
Performance Recommentation : profiling 결과를 기반으로 한 성능 개선 권장 사항을 자동으로 생성해 준다. (실제로 어떤 원리로 동작하는지는 잘 모른다.)
Operator : 연산에 대한 profiling 결과를 보여준다. torch profiling 결과를 터미널에서 출력하였을 때, 보여주던 결과를 그대로 보여준다고 생각하면 된다. 추가적으로 Tensor Cores Eligible 옵션이 있는데, 해당 연산이 GPU를 사용할 수 있는지에 대한 가능 여부를 표시한 것이다. Group By 조건을 바꾸면, input shape도 볼 수 있다.
Trace : 함수 및 연산의 실행 시간을 시간 경과에 따라 그래프 형태로 표시한다. 해당 코드의 실행에 사용된 Process와 그 안의 Thread의 동작을 확인 할 수 있다. (사실 이 UI는 torch profiler의 chrome tracing 기능으로도 볼 수 있다.)
Memory : 실행 시간에 따른 Memory 사용 추이를 보여준다. 각 연산마다 할당한 메모리와, Allocation Time과 Release Time, Duration을 보여준다. 코드 실행에 사용한 H/W 별로 볼 수 있다. (다만, 해당 UI에서 Memory를 많이 사용하는지, Chrome이 계속 죽는다.)
[DIFF]
Tensorboard를 통한 torch profiler 시각화의 가장 큰 장점이라고 할 수 있는 이력 간 비교 기능이다. Baseline의 log를 정한 뒤, 비교하고자 하는 log를 대입하면, 그 둘 간의 profiling 결과의 delta 값을 보여준다.
이를 통해, Profiling 결과를 비교하면서, H/W 효율화를 위한 구조 개선을 진행할 수 있다.
Torch 모델에서 torch profiling을 시각화하여 비교하는 방법을 알아보았다. 개인 프로젝트에서는 그 효용이 덜하겠지만, 많은 사람들이 같은 모델을 연구할 때, 성능과 profiling 결과를 모두 tensorboard를 통해 시각화하여, 성능을 유지하면서 모델의 연산 효율성을 향상하거나, 모델의 연산 효율성을 유지하면서 모델의 성능을 향상하는 데, 사용하면 매우 유용할 것이다.
딥러닝 학습을 잘(?)한다는 것을 정의하기는 어렵지만, 더 빠른 시간 안에 많은 양을 학습하는 것은 매우 중요하다.
딥러닝의 모델은 다수의 layer로 구성되어 있기 때문에, 각 layer의 결과가 데이터가 어느 형태로 존재하는지, 어느 layer가 병목 현상인지를 파악하는 것이 까다롭다.
특히, 최근에는 모델을 직접 코드로 구현하기보다는, pre-trained model을 사용하는 경우가 많은데, 사전에 사용하는 모델의 구조를 알지 못하면, 내부 동작을 제대로 파악할 수 없다.
Pytorch에서는 이러한 모델의 구조와 각 layer에서의 cost를 profiling 할 수 있는 torch profiler를 지원한다.
Code Sample
torch profiler 테스트를 위한 resnet18을 이용한 CIFAR-10 classification code이다.
모델을 직접 코드로 구현한 것이 아닌, torchvision에서 load 하였다.
사용 상황을 가정하자면, load 한 모델의 구조를 모르거나, 모델에 부하가 존재하는 부분을 tuning 해야 하는데, 어느 layer를 바꿔야 할지 모르는 상황이다.
import torch
import torchvision
import torchvision.transforms as transforms
import torch.nn as nn
import torch.optim as optim
TRAIN_EPOCH = 10
TRAIN_PRINT_FREQUENCY = 200
if __name__ == '__main__':
transform = transforms.Compose([
transforms.RandomHorizontalFlip(),
transforms.RandomCrop(32, padding=4),
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
])
trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=128, shuffle=True, num_workers=2)
testset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform)
testloader = torch.utils.data.DataLoader(testset, batch_size=100, shuffle=False, num_workers=2)
model = torchvision.models.resnet18(pretrained=False)
num_features = model.fc.in_features
model.fc = nn.Linear(num_features, 10)
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)
for epoch in range(TRAIN_EPOCH):
running_loss = 0.0
for i, data in enumerate(trainloader, 0):
inputs, labels = data[0].to(device), data[1].to(device)
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item()
if i % TRAIN_PRINT_FREQUENCY == TRAIN_PRINT_FREQUENCY - 1:
print(f'Epoch: {epoch + 1}, Batch: {i + 1}, Loss: {running_loss / 200:.3f}')
running_loss = 0.0
print("Training finished.")
correct = 0
total = 0
with torch.no_grad():
for data in testloader:
images, labels = data[0].to(device), data[1].to(device)
outputs = model(images)
_, predicted = torch.max(outputs.data, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
accuracy = correct / total
print(f'Test Accuracy: {accuracy:.2%}')
Torch Profiler
[Setup]
Pytorch에서는 1.8 버전 이상부터 torch의 profiling을 위한 torch.profiler를 제공한다. 따라서, Torch 버전이 1.8 이상인 경우에는 별도의 설치가 필요 없다.
현재 설치된 Torch 버전을 잘 모른다면, Python에서 아래 명령어를 통해 확인해 보자.
import torch
print(torch.__version__)
만약, torch 버전이 1.8 미만에 torch 버전을 바꿔도 문제가 없는 상황이라면, 아래 명령어를 통해 torch 버전을 업그레이드해 준다.
pip install --upgrade torch torchvision
[사용법]
사용방법은 매우 간단하다. 우선 torch.profiler를 import 하고, 콘텍스트 관리자(with 절)를 이용하여, profiling을 위한 부분을 감싸주면 된다. (함수 전체에 대한 profiling은 profile with 절을 @profile(acitivities~)와 같은 decorator로 처리할 수 있다.)
아래는 sample code 중, train에 대한 profiling을 위한 소스이다. 주의할 점은, profiling에 memory가 많이 소모되기 때문에, train epoch을 1로 낮춰놓고 profiling을 하는 것이 좋다. (어차피, 같은 동작이 반복되기 때문에, input 하나만을 측정해도 별 문제는 없다.)
모델을 GPU에서 돌려서 "ProfilerActivity.CUDA "를 포함했지만, CPU로 돌리는 환경에서는 해당 인자를 생략해도 된다. (다만, GPU 환경에서는 CPU, CUDA 인자 모두 필요함)
...
from torch.profiler import profile, record_function, ProfilerActivity
...
with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], record_shapes=True) as prof:
for epoch in range(TRAIN_EPOCH):
running_loss = 0.0
for i, data in enumerate(trainloader, 0):
inputs, labels = data[0].to(device), data[1].to(device)
optimizer.zero_grad()
with record_function("model_train"):
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item()
if i % TRAIN_PRINT_FREQUENCY == TRAIN_PRINT_FREQUENCY - 1:
print(f'Epoch: {epoch + 1}, Batch: {i + 1}, Loss: {running_loss / 200:.3f}')
running_loss = 0.0
print(prof.key_averages().table(sort_by="self_cpu_time_total"))
print("Training finished.")
[인자 설명]
activities : list 형태로 입력받는다. 어떤 활동을 profiling 할 것인지를 지정한다. 가능한 활동은 다음과 같다.
ProfilerActivity.CPU : CPU 작업(연산, 함수 호출)에 대한 프로파일링, CPU 시간, 메모리 사용량등을 제공
ProfilerActivity.CUDA : CUDA 작업(GPU 연산, 호출)에 대한 프로파일링, GPU 시간, 메모리 사용량등을 제공
record_shapes : bool 형태, 각 layer의 입력(input)을 기록할지 여부
profile_memory : bool 형태, memory를 profiling 할지 여부, False로 설정하면 time에 대한 profiling만 진행한다.
on_trace_ready : Profiling 결과가 준비되었을 때, 호출될 callback 함수를 지정할 수 있음. on_trace_ready 옵션을 통해, 함수를 사전 정의해, profiling 결과 등을 file 형태로 떨굴 수 있다.
...
with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], record_shapes=True,
profile_memory=True, on_trace_ready=finish_profiler) as prof:
...
with_stack : bool 형태, 함수 호출 stack 정보를 표기할지에 대한 여
with_flops : bool 형태, 실제로 계산 비용을 FLOPs로 측정한 결과
with_modules : bool 형태, profiling 결과에 연산의 호출 stack에 대한 module의 계층 구조를 기록해 줌. (어떤 연산이 어떤 연산의 내부에서 호출되었는지를 나타내줌)
결과는 table 형태로 보이는데, 아래와 같이 다양한 옵션들을 사용할 수 있다. (key_averages() 내의 인자 형태로 들어감)
group_by_input_shape : True로 설정하면, 동일한 입력 모양을 가진 연산 또는, 함수 호출을 grouping 할 수 있다. (모델의 input 사이즈를 보려면, 해당 옵션을 True 설정해야 한다.)
group_by_stack_n : 연산 또는 함수의 stack의 상위 n 단계만을 기준으로 grouping 할지 지정하는 인자
table의 인자도 지정할 수 있는데, table의 출력에 대한 옵션을 지정한다.
sort_by : table을 어떤 기준으로 order by 할지 (default : None)
row_limit : 몇 개까지 표시할지
header : header를 표시할지 (default : None)
top_level_events_only : 해당 옵션을 True 설정하면, 최상위 호출 단계까지만 표시
아래는 sample code에 대해 profiling을 수행한 결과이다. (CPU 시간이 큰 10개만 추출)
결과에서 보이는 각 칼럼은 다음과 같다.
CPU Time 관련
Self CPU % : 연산 or 함수 호출이 소비한 CPU 시간의 백분율 (전체 실행 시간에서 해당 연산이 소요한 CPU 시간)
Self CPU : 해당 연산 or 함수 호출이 소비한 총 CPU 시간
CPU total % : 해당 연산 or 함수 호출과 그 하위 호출에서 소요된 총 CPU 시간의 백분율
CPU total : 해당 연산 or 함수 호출과 그 하위 호출에 의해 사용된 총 CPU 시간
CPU time avg : 해당 연산 or 함수 호출의 평균 CPU 시간 (평균적으로 해당 연산이 소요되는 시간)
CUDA Time 관련
Self CUDA : 해당 연산 or 함수 호출이 소비한 총 CUDA 시간
Self CUDA % : 해당 연산 or 함수 호출이 소비한 총 CUDA 시간의 백분율
CUDA total : 해당 연산 or 함수호출과 그 하위 호출에서 소요된 총 CUDA 시간
CUDA time avg : 해당 연산 or 함수호출과 그 하위 호출에서 소요된 평균 CUDA 시간
# of Calls : 해당 연산 또는 함수 호출의 호출 횟수
Model Input 관련
Input Shapes : record shapes를 True로 하고, key_averages에 group_by_input_shape를 true로 지정한 경우에만 보인다. 각 연산의 input shape이 보인다.
CPU memory 관련 (snapshot 형태기 때문에 사용 전과 후의 memory 사용 delta값이 나온다. 즉, 음수가 될 수 있다.)
CPU Mem : 연산 or 함수 호출이 소비한 CPU의 메모리 총 용량
Self CPU Mem :연산 or 함수 호출이 직접적으로 사용한 CPU 메모리 용량
CUDA memory 관련 (snapshot 형태기 때문에 사용전과 후의 memory 사용 delta값이 나온다. 즉, 음수가 될 수 있다.)
CUDAMem : 연산 or 함수 호출이 소비한 CUDA의 메모리 총 용량
Self CUDAMem :연산 or 함수 호출이 직접적으로 사용한 CPU 메모리 용량
연산량 관련
Total MFLOPs : 연산 or 함수 호출이 실행될 때, 총 수행된 MFLOPs 수
Torch 모델에서 torch Profiling을 통해, 부하가 되는 부분이나, Layer의 input size 등을 확인할 수 있다. 해당 profiling은 모델에서 부하가 되는 부분을 개선하거나, 하드웨어 확장에 대한 의사결정, Batch size 조절 등 다양한 model 개선에 사용될 수 있다.
Python으로 짜인 Code를 서비스하다 보면, CPU 100%나 Memory Fault, 실행시간이 길어지는 등 다양한 문제를 만나게 된다.
자신이 개발한 코드에서는 직감적으로 어느 부분이 문제가 될지를 간파할 수 있지만, 다른 사람이 짠 코드에서 문제에 원인이 되는 부분을 찾아내기는 매우 어렵다.
일반적으로 가장 쉽게 떠올릴수 있는 방법은 실행시간은 time 모듈을 이용한 print 디버깅이나 unittest, CPU나 memory는 작업 관리자를 통해 확인하는 방법이다. 하지만, 이 방법들은 대략적인 정도만 알아낼 수 있고, 어느 부분이 문제가 있는지 진단하기 매우 어렵다.
Python에서는 Profiling을 위한 다양한 도구들을 가지고 있어, code 분석이 매우 용이하다. 어떤 것들이 있는지 확인해보자!
Code Sample
각 도구들을 Test 하기 위한 sample code이다.
code는 각각 validation_check, data_preprocessing, outlier_remove, data_sort, data_cal_half_avg 함수를 거쳐 최종 결과를 내도록 되어있다.
import numpy as np
def data_validation_check(sensor_value):
try:
for i in sensor_value.split("|"):
float(i)
return True
except:
print("Error")
return False
def data_preprocessing(sensor_value):
sensor_value = sensor_value.split("|")
sensor_value = list(map(float, sensor_value))
return sensor_value
def outlier_remove(sensor_value):
data_mean = np.mean(sensor_value)
data_std = np.std(sensor_value)
lower_bound = data_mean - 3 * data_std
upper_bound = data_mean + 3 * data_std
sensor_value = [i for i in sensor_value if lower_bound < i and upper_bound > i]
return sensor_value
def data_sort(sensor_value):
return np.sort(sensor_value)
def data_cal_half_avg(sensor_value):
return np.mean(sensor_value[int(len(sensor_value) * 0.5):])
def run(sensor_value):
if data_validation_check(sensor_value):
sensor_value = data_preprocessing(sensor_value)
sensor_value = outlier_remove(sensor_value)
sensor_value = data_sort(sensor_value)
sol = data_cal_half_avg(sensor_value)
return sol
else:
return "Error!"
if __name__ == '__main__':
sensor_value = "|".join([str(i) for i in range(10000000)])
print(run(sensor_value))
memory_profiler : Memory Profiling
Python은 머신러닝 같은 데이터 처리를 위한 언어로 자주 사용되기 때문에, 메모리 관련된 이슈에 자주 직면하게 된다.
따라서, 어느 부분이 memory를 많이 소모하는지 확인이 필요한 경우가 많다.
Python에서는 "memory_profiler"를 통해 memory 사용량을 측정할 수 있다.
[설치 방법]
설치 방법은 매우 간단하다. pip을 이용하여 설치한다.
pip install memory_profiler
[사용 방법]
사용 방법도 매우 간단하다. memory_profiler의 profiler을 import 하고, memory profiling을 하고자 하는 함수에 "@profiler" 데코레이터를 사용하고, 프로그램을 실행하면 끝난다.
[사용 예시]
from memory_profiler import profile
import numpy as np
@profile
def data_validation_check(sensor_value):
try:
for i in sensor_value.split("|"):
float(i)
return True
except:
print("Error")
return False
...
[결과]
결과는 다음과 같이, 테이블 형태로 터미널에 출력된다.
각 칼럼은 다음을 의미한다.
Line # : code 내 몇 번째 줄인 지
Mem Usage : 해당 라인이 실행되기 전의 메모리 사용량
Increment : 해당 라인의 실행으로 추가적으로 사용된 메모리의 양
Occurrences : 각 라인이 실행된 횟수
Line Contents : 라인 코드 내용
즉, memory profiler는 각 라인이 수행되기 전과 후를 스냅숏으로 메모리의 증분값을 보여주어, memory 사용량을 나타낸다. (따라서, memory를 해제하는 경우 등에는 음수값이 나올 수 있다.)
memory_profiler의 결과를 file 형태로 저장하기 위해서는, logger를 사용하거나, 아래와 같이 프로그램 수행 결과를 txt 형태로 내리도록 하면 된다.
python -m memory_profiler main.py > log.txt
memory_profiler를 run 한 후, 아래 명령어로 그래프를 그릴 수 있는데, 사실 이 그래프로 뭘 알 수 있는지는 의문이다. (그냥 시간에 따른 메모리 사용량만 표시된다.)
Line # Mem usage Increment Occurrences Line Contents ============================================================= 32 503.5 MiB 503.5 MiB 1 @profile 33 def data_sort(sensor_value): 34 579.9 MiB 76.3 MiB 1 return np.sort(sensor_value)
Filename: main.py
Line # Mem usage Increment Occurrences Line Contents ============================================================= 36 197.5 MiB 197.5 MiB 1 @profile 37 def data_cal_half_avg(sensor_value): 38 197.5 MiB 0.0 MiB 1 return np.mean(sensor_value[int(len(sensor_value) * 0.5):])
7499999.5 mprof: Sampling memory every 0.1s running new process running as a Python program...
사실, 언뜻 생각하기엔 sort에서 가장 많은 memory가 사용될 것이라고 생각했지만, 의외로 outlier 제거를 위한 순회나, split등에서 많이 사용된다는 것을 알 수 있다.
[주의점]
memory의 profile은 memory의 snapshot과 기록에 많은 추가 시간이 소요되기 때문에, memory profile과 실행시간 측정은 동시에 진행하면 안 된다.
memory가 snapshot 형태로 기록되기 때문에, memory 소요값이 절대적이지 않고, 실행 환경 등에 따라 다르다는 점을 꼭 기억하자!
Execution Time Profiling : line_profiler
Execution Time은 Python에서 가장 민감한 부분이기도 하다.
보통 time 모듈을 이용하여 디버깅을 진행하기도 하는데, 매구 간마다 디버깅을 위해 시간을 찍는 것도 매우 비효율적이다.
이런 비효율을 덜어줄 수 있는 Execution Time profiling 도구 line_profiler이다.
[설치 방법]
설치 방법은 memory_profiler처럼 pip을 이용하여 설치한다.
pip install line_profiler
[사용 방법]
사용 방법은 더 간단하다. 실행 시간을 측정하고 싶은 함수에 "@profile" 데코레이터를 넣어주고, 터미널에서 아래 명령어를 실행해 주면 된다.
kernprof -l -v main.py
[사용 예시]
# memory_profiler가 import 안되도록 한번 더 확인!
import numpy as np
@profile
def data_validation_check(sensor_value):
try:
for i in sensor_value.split("|"):
float(i)
return True
except:
print("Error")
return False
...
[결과]
결과는 다음과 같이, 테이블 형태로 터미널에 출력된다.
우선 맨 위에, 시간 unit과 각 함수 total 수행 시간이 표시된다. (전체 total 수행 시간이 아니다.)
아래 각 칼럼은 다음을 의미한다.
Line # : code 내 몇 번째 줄인 지
Hits: 각 라인이 실행된 횟수
Time : 수행 시간
Per Hit: 각 실행당 걸린 시간
% Time : 함수 내 실행 시간에서 차지하는 퍼센트
Line Contents : 라인 코드 내용
line_profiler의 결과를 file 형태로 저장하기 위해서는, 아래 명령어를 사용하면 된다. line_profiler를 실행하면, 실행 파일에 대한 lprof의 파일 결과가 떨어지는데, 이를 text 파일로 떨구면 된다.
python -m line_profiler main.py.lprof > log.txt
[Sample 수행 결과]
첫 생각과는 다르게, validation check가 가장 많은 시간이 소요되는 것을 확인할 수 있다.
Timer unit: 1e-06 s
Total time: 4.83922 s File: main.py Function: data_validation_check at line 3
Line # Hits Time Per Hit % Time Line Contents ============================================================== 3 @profile 4 def data_validation_check(sensor_value): 5 1 0.5 0.5 0.0 try: 6 10000000 1970775.1 0.2 40.7 for i in sensor_value.split("|"): 7 10000000 2868439.0 0.3 59.3 float(i) 8 1 1.5 1.5 0.0 return True 9 except: 10 print("Error") 11 return False
Total time: 1.48381 s File: main.py Function: data_preprocessing at line 13
Line # Hits Time Per Hit % Time Line Contents ============================================================== 13 @profile 14 def data_preprocessing(sensor_value): 15 1 369882.5 369882.5 24.9 sensor_value = sensor_value.split("|") 16 1 1113930.3 1113930.3 75.1 sensor_value = list(map(float, sensor_value)) 17 18 1 1.3 1.3 0.0 return sensor_value
Total time: 2.66128 s File: main.py Function: outlier_remove at line 20
Line # Hits Time Per Hit % Time Line Contents ============================================================== 20 @profile 21 def outlier_remove(sensor_value): 22 1 337871.3 337871.3 12.7 data_mean = np.mean(sensor_value) 23 1 366792.1 366792.1 13.8 data_std = np.std(sensor_value) 24 25 1 5.7 5.7 0.0 lower_bound = data_mean - 3 * data_std 26 1 10.6 10.6 0.0 upper_bound = data_mean + 3 * data_std 27 28 1 1956595.8 1956595.8 73.5 sensor_value = [i for i in sensor_value if lower_bound < i and upper_bound > i] 29 1 0.7 0.7 0.0 return sensor_value
Total time: 0.415683 s File: main.py Function: data_sort at line 31
Line # Hits Time Per Hit % Time Line Contents ============================================================== 31 @profile 32 def data_sort(sensor_value): 33 1 415683.2 415683.2 100.0 return np.sort(sensor_value)
Total time: 0.003097 s File: main.py Function: data_cal_half_avg at line 35
Line # Hits Time Per Hit % Time Line Contents ============================================================== 35 @profile 36 def data_cal_half_avg(sensor_value): 37 1 3097.0 3097.0 100.0 return np.mean(sensor_value[int(len(sensor_value) * 0.5):])
[주의점]
memory profiler와 마찬가지로, line_profiler로 수행시간을 차분하여, 라인 별 수행시간을 구하는 것이다. 따라서, 절대적이지 않고 수행 환경에 따라 달라진다.
Process Profiling : py-spy
CPU는 운영체제의 스케줄링이나 프로세스 등에 따라 동적으로 변하기 때문에, 함수마다의 수행시간을 정확히 측정하는 것은 매우 어렵다.
따라서, CPU는 리눅스 명령어나 윈도 작업관리자를 통해, 프로그램 수행 후 observation 형태로 간접적으로 파악하는 방법 밖에 없다.
또한, CPU는 사용량이 많더라도, 조치하기가 매우 어렵다. 따라서, 너무 CPU 사용량이 많은 부분만 확인하는 정도의 이상감지용 지표로 활용하는 것이 좋다.
CPU 사용률을 직접 측정하기는 어렵지만, 각 Process에 걸리는 부하를 간접적으로 알 수 있는 도구가 있는데, 바로 py-spy이다.
[설치 방법]
설치 방법은 앞선 profiler들처럼 pip을 이용하여 설치한다.
pip install py-spi
[사용 방법]
사용 방법은 단순히 아래 명령어를 터미널에 입력해 주면 된다.
py-spy record -o profile.svg -- python main.py
[결과]
결과는 위에서 지정한 profile.svg(이름은 바꿔도 된다.) 파일의 스택 플레임 그래프 형태로 떨어진다.
결과는 다음의 형태를 가진다.
함수 호출 스택이 위에서부터 바깥쪽의 함수를 의미한다. 예를 들어, 예제의 run 함수 → data_preprocessing 함수 → split 함수 형태로 위부터 아래로 표시된다.
Box 표시 : 각 함수가 Box로 표시된다. Box의 크기가 해당 함수의 소비 시간을 나타낸다. 따라서, 상위 함수는 하위 여러 함수들의 박스들로 구성된다.
색상 : 어두운 색상에 있는 함수일수록 깊은 호출 스택을 의미한다.
일반적으로, 다음과 같은 결과 해석이 가능하다.
우선 Box가 큰 함수의 부분이 부하의 원인이 되는 경우가 많기 때문에 주목해서 봐야 한다.
Box가 큰 함수들 중, 호출 스택이 깊은 함수들은 여러 번 중첩되는 경우가 많은데, 이 부분의 중첩을 줄여야 개선이 가능하다.
다른 profiler들과 다르게, 내부의 import 된 함수 단위까지 표시가 되기 때문에, 어떤 구조로 함수가 호출되는지 이해가 쉽다.
[주의점]
사실, 수행 시간을 통해, 간접적으로 프로세스의 중첩이나, 부하를 확인하는 것이기 때문에, CPU 사용률과 직접적인 연관이 없다. (참고용으로만 사용하는 것이 좋다.)
이 밖에, Python 내장 profiler인 CProfile 같은 Profiler와, Palanteer, Pyinstrument 등의 Profiler 들도 존재한다. 하지만, 프로그램의 수행결과로 논문을 쓸 것이 아니라면, 다음과 같은 툴로도 충분하다고 생각한다.
주로, 모델은 labeler들이 testset에 대한 model의 ouput을 평가한다.이때, 평가자는 학습과정에 참가하지 않았던 사람이다.
다양한 NLP task에 대한 evaluation도 진행한다.
[결과]
Labeler들은 InstructGPT의 ouput을 GPT-3의 것보다 선호한다.
InstructGPT의 output은 GPT-3의 것보다 신뢰도가 높다.
InstructGPT는 toxicity 면에서 GPT-3보다 조금 개선되었지만, bias 측면에서는 차이가 없다.
인간의 feedback에 대한 강화학습 부분을 수정하여, NLP dataset에서의 성능하락을 최소화했다.
Evaluation에서 training set 생성에 참여하지 않은 사람들에게서도 InstructGPT가 좋은 평가를 받은 것은 선호도의 일반화를 의미한다.
Public NLP dataset은 InstructGPT가 사용되는 방식을 정확히 반영하지 못한다.
InstructGPT가 인간의 feedback에 의한 강화학습에 포함되지 않는 지시에도 일반적으로 잘 따른다. 즉, 훈련에 사용된 분포에만 국한하는 게 아니라, 그 밖에 존재하는 분포에서도 지시에 잘 따른다.
하지만, InstructGPT는 아직 완벽하지 않다.
Methods and Experimental details
[Method]
크게 3개의 step을 통해 학습했다. (2, 3번은 계속 반복된다.)
demonstration에 대한 데이터를 모으고, supervised 방식으로 학습한다. (기존 LM 방식)
comparision 데이터를 모으고, reward model을 학습한다.
PPO 알고리즘을 사용하여, LM을 fine-tuning 한다.
[Dataset]
Dataset을 수집한 내용이 나온다. 기본적으로 OpenAI API를 통해 text prompts를 구성했고, user ID당 200개까지만 prompts를 제한하였다. userID 기준으로 testset을 분리했다. (혹시 몰라서, 개인정보로 filtering을 한번 더 했다. 1인이 다계정을 사용하는 것을 막으려고 한 것 같다.)
데이터셋은 다음과 같이 구성되어 있다.
Plain : labeler들에게 무작위 task에 대한 질문을 한 결과
Few-Shot : labeler들이 만든 지시문과 지시문에 대한 여러 개의 질문과 대답 쌍
User-based : User들이 사용 사례를 정하고, 그에 대한 prompts를 생성
[Task]
데이터셋에 포함된 prompt들은 generation, question answering, dialog, summarization, extractions 등 다양하다.
[Model]
기본 모델은 GPT-3 pretrained model을 사용한다. GPT-3을 기반으로 아래의 3가지 다른 technique을 사용하여 model을 학습한다.
동일 input에 대한 2개의 model ouput들 사이의 comparision을 학습한다. (rewards는 다른 응답에 비해 선호될 log odds로 정함)
몇 개의 ouput에 대해서 비교할 것인지를 K라고 하면, 비교는 k*(k-1)/2번만큼 이뤄져야 한다.
이때, 단순히 전체 prompt 간의 비교 값을 shuffle 하여 학습하면, overfitting이 발생한다. 이를 해결하기 위해, 한 batch에 하나의 prompt에 대한 모든 comparison이 담기도록 학습했다.
학습을 위한 loss function은 다음과 같다.
r(x, y) : scalar output of the reward model from x,y
y_w: 더 선호되는 ouput
y_l : 덜 선호되는 output
3. Reinforcement Learning (RL)
SFT model을 PPO(Proximal Policy Optimization) 알고리즘을 통해 학습한다.
아래의 objective가 최대화되는 방향으로 RL 학습을 진행한다.
π^RL : RL policy로 학습된 모델
π^SFT : SFT로 학습된 모델
해당 논문에서는 γ=0으로 한다.
[Evaluation]
모델이 얼마나 "align" 되었는지 확인하기 위해, align의 의미를 먼저 정의한다.
InstructGPT의 목적이 user의 의도를 반영과 일치하는 언어 모델을 만드는 것이기 때문에, model의 align을 helpful, honest, harmless로 정의한다.
모델이 helpful을 평가하기 위해서는 labeler의 선호도 평가에 의존한다.
모델의 honest를 평가하기 위해서, 모델이 진실을 답하는지 거짓을 답하는지 평가한다. 이를 위해, closed doamin task에 대해 모델이 거짓말하는 정도를 평가하고, TruthfulQA 데이터셋을 이용하여 평가한다.
모델의 harm을 평가하기 위해, labeler가 ouput이 context에 적절한지 여부, 특정 집단에도 ㅐ한 비하를 포함하였는지, 성적이거나 차별적인 콘텐츠를 포함하였는지의 여부를 평가한다. 또한, RealToxicityPrompts와 CrowS-Pairs 데이터셋을 이용하여 평가한다.
InstructGPT의 의도에 맞게 fine-tuning 하는 것은 GPT-3 모델의 성능을 떨어뜨릴 수 있다. 이를 평가하기 위해, GPT-3에 제출된 prompt를 이용하여 성능을 평가한다. 또한, public NLP dataset으로도 평가한다.
Results
Labeler들이 InstructGPT의 output을 GPT-3의 것보다 선호한다.
InstructGPT 모델은 신뢰도 측면에서 GPT-3에 비해 향상이 있었다.
InstructGPT 모델은 GPT-3보다 toxicity를 줄일 수 있었지만, bias는 줄이지 못했다.
InstructGPT는 GPT-3에 비해, align-tax(align을 위해 NLP의 성능감소가 생기는 것)가 있지만, 그렇게 크지 않다.
그 외
논의해 볼 사항과 미래 영향, 해결해야 할 과제들이 적혀있다. (철학적인 내용인 것 같아 별도로 적지는 않는다.)
Reference
Ouyang, Long, et al. "Training language models to follow instructions with human feedback."Advances in Neural Information Processing Systems35 (2022): 27730-27744.
총평
Chat-GPT의 가장 큰 단점으로 지적받는 '거짓말'에 대한 해결 방법을 제안한 논문이다. 이 논문을 읽기 전에는 단순히 학습 데이터를 filtering 하는 것이 방법 아닐까 하는 생각이 있었는데, 데이터를 filtering 하면 cost가 매우 크고, 데이터가 편향될 수 있어서 이 문제를 어떻게 풀까 고민했었다. 근데 이 논문은 매우 간단한 방법으로 (물론 prompt 등 데이터 생성은 간단하지 않지만) 이 문제를 완화하였다. 물론, 아직 갈 길이 멀지만, GPT-1부터 논문의 마지막 부분에 항상 존재하던, 발생할 수 있는 문제들에 대해 해결을 방법을 제안한 의미 있는 논문이라고 생각한다.