결론부터 말하자면, Gradient Accumulation 방법은 GPU memory issue를 보완하기 위한 방법입니다.

배치 크기는 성능에 영향을 주는 중요한 하이퍼파라미터 중 하나인데요.
이 때문에 배치 크기(Batch Size)와 성능(Performance) 관계는 이전부터 많은 방법(다양한 조건에서의 실험, 수학적 풀이, 최적화 관점 등)으로 연구되어 왔습니다.

대표적인 논문만 간단히 보자면,

위 논문뿐만 아니라 관련 논문을 보면 아래와 같이 결론을 얻을 수 있습니다.

    ① LB(Large Batch)는 학습 속도도 빠르고, 성능도 좋을 수 있다
    ② LB 일수록 더 큰 학습률(Learning Rate)를 사용하면 좋다 (하지만 큰 학습률을 사용하면 수렴이 안되는 문제가?)
    ③ 하지만 LB보다 SB(Small Batch)가 안정적으로 학습할 수 있어 일반화(Generalization)에 강하다

Gradient Accumulation 방법이 배치 크기와 관련이 있다보니 간단하게 배치 크기 관련 이야기를 해보았는데, 이번 글은 어떤 이유에서든 "비록 가지고 있는 GPU memory가 제한적이지만 일단 LB를 사용해서 학습시키고 싶다."라는 생각입니다.

Gradient Accumulation?

큰 배치 크기를 활용할때 가장 큰 문제는 '성능이 좋아질 수 있다', '안좋아질 수 있다'가 아닐겁니다. 아마도 가장 중요하게 고려하는 것은 지금 보유하고 있는 GPU를 가지고 '1,024, 2,048, ... 처럼 큰 배치 크기를 돌려볼 수 있느냐'입니다.

누구나 다 겪는 문제인데요. 만약 이와 같은 문제에 직면했다면, 이를 해결하기 위한 방법 중 하나로 Gradient Accumulation 방법을 생각해볼 수 있습니다. 가장 좋은 방법은 돈을 들이는 것...

일반적인 학습 방법과 Gradient Accumulation 방법의 차이는 아래 그림에서 바로 확인할 수 있습니다.

General Training vs Training with GA

일반적인 학습 방법이 미니 배치를 통해 gradient를 구한 후, Update를 즉시 진행하는 방법이라면,

Gradient Accumulation 방법을 포함한 학습은 미니 배치를 통해 구해진 gradient를 n-step동안 Global Gradients에 누적시킨 후, 한번에 업데이트하는 방법입니다.

매우 간단합니다. 즉, 핵심 아이디어는 256 mini-batch를 통해 gradient를 업데이트하는 것과 64 mini-batch를 4번(64 * 4 = 256) 누적하여 업데이트하는 것이 비슷한 결과를 가져다 줄 것이라는 생각입니다.

이 예와 같다면, 우리가 보유하고 있는 GPU memory 한계상 64 mini-batch 밖에 사용할 수 없다면, 이 학습 방법을 통해 4번 누적하여 업데이트할 경우 256 mini-batch를 사용하여 학습하는 것과 다름없게 되는 셈이죠.

하지만 이 방법은 memory issue를 중점적으로 해결하고자 하는 방법이지 속도, 성능과는 아직 explicit하게 증명된 바가 없습니다.

아래의 간단한 구현을 통해 위에서 글로서 살펴본 것보다 좀 더 쉽게 이해할 수 있을 거에요.


전체 코드는 깃허브_저장소에 있습니다.

Setup

from tensorflow.keras.datasets import mnist
from tensorflow.keras.layers import Input, Dense, Conv2D, MaxPooling2D, Flatten
from tensorflow.keras.models import Model
import tensorflow as tf

print(tf.__version__)

MNIST Dataset Laod

(x_train, y_train), (x_test, y_test) = mnist.load_data()

Make TF Dataset

def make_datasets(x, y):
    # (28, 28) -> (28, 28, 1)
    def _new_axis(x, y):
        y = tf.one_hot(y, depth = 10)
        
        return x[..., tf.newaxis], y
            
    ds = tf.data.Dataset.from_tensor_slices((x, y))
    ds = ds.map(_new_axis, num_parallel_calls = tf.data.experimental.AUTOTUNE)
    ds = ds.shuffle(100).batch(32) # 배치 크기 조절하세요
    ds = ds.prefetch(tf.data.experimental.AUTOTUNE)
    
    return ds
    
ds = make_datasets(x_train, y_train)

Make Models

# rescaling, 1 / 255
preprocessing_layer = tf.keras.models.Sequential([
        tf.keras.layers.experimental.preprocessing.Rescaling(1./255),
    ])

# simple CNN model
def get_model():
    inputs = Input(shape = (28, 28, 1))
    preprocessing_inputs = preprocessing_layer(inputs)
    
    x = Conv2D(filters = 32, kernel_size = (3, 3), activation='relu')(preprocessing_inputs)
    x = MaxPooling2D((2, 2))(x)
    x = Conv2D(filters = 64, kernel_size = (3, 3), activation='relu')(x)
    x = MaxPooling2D((2, 2))(x)
    x = Conv2D(filters = 64, kernel_size =(3, 3), activation='relu')(x)
    
    x = Flatten()(x)
    x = Dense(64, activation = 'relu')(x)
    outputs = Dense(10, activation = 'softmax')(x)
    
    model = Model(inputs = inputs, outputs = outputs)
    
    return model

model = get_model()
model.summary()

Training with Gradient Accumulation

epochs = 10
num_accum = 4 # 누적 횟수

loss_fn = tf.keras.losses.CategoricalCrossentropy(from_logits = True)
optimizer = tf.keras.optimizers.Adam()
train_acc_metric = tf.keras.metrics.CategoricalAccuracy()
@tf.function
def train_step(x, y):
    with tf.GradientTape() as tape:
        logits = model(x)
        loss_value = loss_fn(y, logits)
    gradients = tape.gradient(loss_value, model.trainable_weights)
    
    # update metrics    
    train_acc_metric.update_state(y, logits)
    
    return gradients, loss_value

def train():
    for epoch in range(epochs):
        print(f'################ Start of epoch: {epoch} ################')
        # 누적 gradient를 담기 위한 zeros_like 선언
        accumulation_gradients = [tf.zeros_like(ele) for ele in model.trainable_weights]
        
        for step, (batch_x_train, batch_y_train) in enumerate(ds):
            gradients, loss_value = train_step(batch_x_train, batch_y_train)
            
            if step % num_accum == 0:
                accumulation_gradients = [grad / num_accum for grad in accumulation_gradients]
                optimizer.apply_gradients(zip(gradients, model.trainable_weights))

                # zero-like init
                accumulation_gradients = [tf.zeros_like(ele) for ele in model.trainable_weights]
            else:
                accumulation_gradients = [(accum_grad + grad) for accum_grad, grad in zip(accumulation_gradients, gradients)]

            if step % 100 == 0:
                print(f"Loss at Step: {step} : {loss_value:.4f}")
            
        train_acc = train_acc_metric.result()
        print(f'Accuracy : {(train_acc * 100):.4f}%')
        train_acc_metric.reset_states()
        
# start training
train()

mecab 설치 관련 내용을 찾아본 결과, .tar 확장자를 다운받아 여러 가지를 직접 설치해주는 방법도 있지만 가장 편한 것은 아래 명령어를 쓰는 것입니다. 쉘에 그대로 복붙해주세요

bash <(curl -s https://raw.githubusercontent.com/konlpy/konlpy/master/scripts/mecab.sh)

 

만약, 에러가 난다면

  1. brew upgrade zsh
    - 에러랑은 거의 무관하지만 zsh 쓴다는 가정하에 업그레이드를 한번 해주고 시작합니다
    - 셀을 실행시켰을 때 dydl library not loded ~ 와 같은 에러 로그가 발생한다면, zsh 업그레이드로 해결됩니다
  2. xcode-select --install
    - xcrun 등 로그가 발생한다면 2번에서 해결될 가능성이 높습니다. gcc를 먼저 설치하기 전에 맥 개발자 도구부터 설치 또는 업데이트 해줍니다
  3. brew install gcc
    - 2번을 진행하고 나서 gcc 설치 명령어를 한번 더 수행한 후에, 맨 위 명령어를 다시 실행해보세요
    - 보통 C compiler 관련 로그가 여기서 해결됩니다
  4. pip install mecab-python
    - 만약 mecab을 설치하는 마지막 과정에서 mecab-python 설치 에러 로그가 발생한다면, pip를 활용해서 직접 설치해줍니다. 여기까지 전부 성공하면 mecab 실행과 관련한 모든 패키지 설치가 완료된 겁니다

jupyter notebook으로 파이썬을 사용할 때, 자주 사용하는 라이브러리(NumPy, Pandas 등)를 항상 임포트(import)하는게 어려운 것은 아니지만 노트북을 생성할때마다 임포트하는 것은 여간 번거로운 일이 아닙니다.

startup 설정을 진행하면 jupyter notebook을 실행할 때 00-first_.py 파일이 실행되면서 여기에 입력해둔 import 구문을 별도로 다시 입력해주지 않아도 되는 편리함을 느낄 수 있습니다.


  1. 배쉬 or 명령 프롬프트(cmd)를 실행합니다.
  2. ipython profile create 명령어 입력
    1. 이 명령어를 입력하면 프로파일이 생깁니다.
    2. 만약, 프로파일을 처음 생성하는 것이면 아래와 같이 몇 가지 라인이 자동으로 입력되는데, 바로 밑에서 .ipython으로 들어갈 경로를 모른다면 이때 생기는 코멘트를 참고하면 됩니다
      [ProfileCreate] Generating default config file: ~~~~~​
  3. "startup"이 존재하는 경로로 진입합니다
    1. cd .ipython/profile_default\
    2. dir 명령어로 startup이 있는지 확인합니다
    3. startup 경로로 진입합니다 cd startup
  4. 00-first_.py 파일을 작성합니다
    1. 00-first_.py 파일은 jupyter notebook이 실행될 때 먼저 자동으로 실행됩니다.
      즉, import 구문을 넣어두면 우리가 선언해주지 않아도 선언된 상태로 노트북을 사용할 수 있습니다.
    2. startup 경로에 진입한 상태에서 jupyter notebook을 실행시킵니다.
    3. New -> Text File, 00-first_.py로 이름 변경
    4. 자신이 사용할 코드를 삽입하고 저장합니다.
      # 구문 예시
      import numpy as np
      import pandas as pd
      import matplotlib.pyplot as plt
      import seaborn as sns
  5. 이제 쥬피터 노트북을 재실행하고, 임포트를 직접 선언해주지 않아도 라이브러리 사용이 가능한지 확인하면 끝!

 

 

매번 찾아보기 번거로워 사용하는 것만 정리해서 올려두었습니다.


가장 먼저 정규표현식하면 필수적으로 나오는 이메일과 핸드폰 번호 예제입니다.

# 스타일이나 표현하고 싶은 방법에 따라 다름, 여기선 가장 기본적인 문법
# 경우에 따라 특수문자를 표현하기 위해 %, - 등을 집어넣을 수 있다
# ex) r'([\w.%-]+@ 등)'
re.findall(r'(\w+)@(\w+).(\w+)', 'test123@tistory.com')
# [('test123', 'tistory', 'com')]
re.findall(r'(\w+)-(\w+)-(\w+)', '010-1234-5678')
# [('010', '1234', '5678')]

 

아래 예제에서는 findall로 대부분 예제를 작성했지만, 주로 search, match, findall을 사용합니다.

  • []
    • [] 안에 정의한 문자들에 포함되는 것을 찾는다
    • 단, '^'가 []의 맨 앞에 올 경우, 해당 패턴이 아닌 것을 찾는다
re.findall('[#4]', '1#a2b3c4')
# ['#', '4']

re.findall('[A-Z]', '12a3B4DZ')
# ['B', 'D', 'Z']

re.findall('[^0-9]', '12a3B4DZ')
# ['a', 'B', 'D', 'Z']
  • . (점)
    • 한 개 chracter(모든 문자)와 일치하는 것을 찾는다, .. 이면 character 두 개가 이어진 것을 찾는다
    • 만약 '점' 자체를 찾고 싶다면, \.으로 표현해주면 된다
re.findall('[0-9].[a-z]', '1#a2b3c4')
# ['1#a']
  • \w
    • character를 의미, \w\w는 character 두 개가 이어진 것을 의미한다
    • \W는 character가 아닌 문자를 의미
re.findall('[0-9]\w', '1a2b3c4')
# ['1a', '2b', '3c']
  • \d
    • 숫자를 의미, \d\d는 숫자 두 개가 이어진 것을 의미한다
    • \D는 숫자가 아닌 문자를 의미
re.findall('\d[a-z]', '1a2b3c4')
# ['1a', '2b', '3c']
  • \s
    • 공백을 의미(space, tab 등)
    • \S는 공백이 아닌 문자를 의미
  • ^과 $의 의미
    • ^: 문자열의 시작부터 일치
    • $: 문자열의 끝이 일치
re.findall(r'^abc', 'abc') # 'abc' -> a로 시작해야함
re.findall(r'^abc', '@abc') # 못찾음

re.findall(r'abc$', 'abc') # 'abc' -> c로 끝나야함
re.findall(r'abc$', 'abc@') # 못찾음
  • 문자열 앞에 r을 붙임
    • \t, \n 등 escape 표현을 무시하고 그대로 표현
a = r'test\n'
print(a) # test\n
  • '+', '*', '?'
    • +: 패턴이 적어도 한번은 나와야 추출
    • *: 패턴이 0번 이상인 경우 추출, 즉 없어도 추출함(+와 차이점)
    • ?: 패턴이 아예 없거나(0) 한번만 나오거나(1)
re.findall(r'a1+dz+', 'a1dzzzc') # 'a1dzzz'
re.findall(r'a1+dz+', 'adzzzc') # 못찾음

re.findall(r'a1*dz*', 'a1dzzzc') # 'a1dzzz'
re.findall(r'a1*dz*', 'adzzzc') # adzzz 찾음

re.findall(r'a1?dz?', 'a1dzzzc') # 'a1dz' -> 1 한번, z 한번
re.findall(r'a1?dz?', 'adzzzc') # adz -> 1 없음, z 한번
  • {}
    • 패턴 횟수를 설정해줄 때 사용
re.findall(r'te+st', 'test,teest,teeest') # ['test', 'teest', 'teeest']
re.findall(r'te{3}st', 'test,teest,teeest') # ['teeest'] -> e가 3번 반복되는 것만 추출
re.findall(r'te{2,3}st', 'test,teest,teeest') # ['teest', 'teeest'] -> e가 2~3번 반복되는 것만 추출
  • {}?
    • 최소 기준으로 매칭
re.search(r't.{2,3}', 'test,teest,teeest') # test
re.search(r't.{2,3}?', 'test,teest,teeest') # tes
  • 예제) 특정 문자로 시작되는 패턴 찾기
    • 여기서 다루는 예는 매우 한정적일 수 있습니다~
    • 여기서는 w로 시작되면서 숫자로 끝나는 패턴을 찾아보겠습니다. 단, w와 숫자 사이에는 공백이 있을 수 있고, 없을 수 있습니다.
re.search(r'[^A-Za-z0-9]w[\s]?\d+\d$', 'benz sclass w212') # w212
re.search(r'[^A-Za-z0-9]w[\s]?\d+\d$', 'benz sclass w 212') # w 212

 

1. @staticmethod

함수 객체를 선언하지 않고, 바로 method에 접근해서 사용할 수 있습니다.

class Test:
    def __init__(self):
        pass

    @staticmethod
    def print_x(x):
    	return print(x)
        
Test.print_x(10) # 객체 선언하지않고 바로 사용        

 

2. list의 extend

주로 list 뒤에 값을 붙인다고 하면, append 함수만 생각나는 경우가 많습니다.
extend 함수는 익숙하지 않으면 잘 생각이...

[1, 2, 3]과 [10, 20]에 각각 함수를 사용했을 때 반환 결과는 다음과 같습니다.

  • extend 함수: [1, 2, 3, 10, 20]
  • append 함수: [1, 2, 3, [10, 20]]
a = [1, 2, 3]
b = [10, 20]

a.append(b)
print(a) # [1, 2, 3, [10, 20]]

a.extend(b)
print(a) # [1, 2, 3, 10, 20]

 

3. Pandas -> Numpy

Pandas 라이브러리는 데이터 분석과 관련한 다양한 함수를 제공해줌과 동시에 NumPy 라이브러리와 같이 빠른 성능을 보여줍니다.

하지만 만약 하고 있는 작업을 NumPy Array를 활용한 연산으로 수행할 수 있다면, 되도록이면 NumPy 연산으로 바꿔보는 것을 추천합니다.

생각치 못한 곳에서 속도 향상이 일어날 수 있고, 이후 처리 작업으로 전환하기에도 매우 편리합니다.

 

4. 속도가 정말 느린 것 같다면 배치 연산을 활용

전체 데이터에 전처리(preprocessing) 작업을 수행한 후, 처리된 데이터를 stack, concat과 같은 함수를 사용하여
새로운 저장 공간(ex: list or DataFrame etc.)에 쌓는 경우 속도 저하 현상이 발생할 수 있습니다.

이는 전체 데이터를 한번에 쌓고자하는 작업 때문에 병목 현상이 발생한 경우인데
특히, 우리가 자주쓰는 loc, iloc 함수와 같이 인덱싱 기능이 포함된 함수를 사용할 경우 자주 만나볼 수 있는 문제입니다.

속도 향상을 위해 전체를 한번에 쌓는 방법보다 분할하여 쌓은 뒤, 합쳐(merge)주는 순서로 변경해보세요.
신경망 모델 학습 시에 배치 연산을 하는 것처럼 바꿔보면 큰 속도 향상을 기대해볼 수 있습니다.
(ex: 1,000개 데이터를 100개씩 나누어 10번 처리한 뒤, 한번에 합쳐주는 방법을 의미)

매우 쉬운 방법이지만 한번에 떠오르기 쉽지 않습니다.

 

5. Class Config 설정

클래스를 정의할 때 여러 가지 하이퍼파라미터 정의가 필요할 수 있습니다.

__init__함수내 변수로 선언하여 관리할 수 있지만, 다루는 함수가 많아지거나 복잡해질수록 무언가 실수로 하나씩 변경하지 못하는 실수가 발생하기 마련입니다.

이를 방지하고자 관리하는 스타일에 따라 (1) Config 클래스 (2) 외부 파일 저장 (3) Config dict를 인자로 넘기기 등 방법을 사용할 수 있는데 여기서 볼 것은 (3)번 방법입니다.

**kwargs(dict type)를 사용해서 정의된 하이퍼파라미터를 넘겨준 후, 클래스 변수로 등록하는 과정입니다.
(+ *args는 tuple type)

class Test:
    def __init__(self):
        pass
    
    def set_config(self, **kwargs):
        self.add_entries(**kwargs)
    
    def add_entries(self, **kwargs):
        for key, value in kwargs.items():
            self.__dict__[key] = value
            
config = {
    'num_epochs': 10,
    'batch_size': 1024,
    'hidden_nums': 16,
}

t = Test()
t.set_config(**config)

# 결과는 __dict__로 확인할 수 있다
# {'num_epochs': 10, 'batch_size': 1024, 'hidden_nums': 16}
print(t.__dict__)

 

6. Jupyter Notebook으로 수식 쓰기

수학 공식을 표현하고 싶을 때, LaTeX 방법으로 수식을 만들 수 있지만 복잡하고 어렵습니다.

# RMSE 예시
# $$RMSE=\sqrt {\sum_{i} (Y_{i} - \hat Y_{i})^2}$$

handcalcs 패키지를 활용하면 매우 쉽게 수식을 렌더링할 수 있습니다.

pip install handcalcs로 쉽게 설치할 수 있습니다. 설치 후, 모듈을 임포트합니다.

import handcalcs.render

%%render magic 명령어로 아래와 같이 쉽게 사용할 수 있습니다.

%%render

r = sqrt(a**2 +b**2)
x = (-b + sqrt(b**2 -4*a*c))/(2*a)

실제 Jupyter Notebook Output

언더바(_)를 통해 밑 이름도 지정할 수 있습니다.

%%render

# under -> '_'
a_x = 1
b_under_underunder = 2

7. 파이썬 크롤링하기

파이썬으로 크롤링을 해야할 때, 스크래피(Scrapy) 등 다양한 방법이 있지만 기본적으로 우리가 사용하는 방법은 BeautifulSoupSelenium 라이브러리입니다.

특히, Selenium은 ChromeDriver을 활용해서 뷰(View)를 확인할 수 있는 장점이 있어 명확하고, 몇몇 함수를 사용해 더욱 편리하게 크롤링을 진행할 수 있습니다. 뿐만 아니라 버튼 클릭부터 페이지 변경까지 매우 편리합니다.

그런데 만약 버튼 클릭, 페이지네이션(Pagination), 링크 들어가서 또 링크를 들어가야하는 등 여러 가지 복잡한 액션이 포함된 크롤링을 진행해야할 때는 어떨까요? 뿐만 아니라 파싱해야할 정보량도 많다면?
이때, Selenium만 활용하면 속도가 매우 느리다는 단점을 느낄 수 있습니다. 음... 굳이 별다른 이유를 설명하지 않아도 일단 뷰를 우리에게 제공한다는 점을 생각해보면 일반적으로 납득이 가는 상황입니다.

이에 대한 해결방법으로 BeautifulSoup만 활용해보는 것입니다. BeautifulSoup은 HTTP 통신으로 시각적인 정보를 제공하지 않고 바로 파싱을 진행할 수 있기 때문에 크롤링 속도가 매우 빠르다는 장점이 있습니다.

만약, 여기서 페이지네이션이나 링크를 타고 들어가는 크롤링 과정을 뷰로 확인하고 싶다면, Selenium을 활용하면 되겠죠. 그런데 단점은 속도잖아요. 속도가 걱정된다면?

두 방법을 섞어서 활용해보세요. 여러 가지 액션은 Selenium, 파싱은 BeautifulSoup를 활용하면 빠른 속도로 크롤링을 진행할 수 있습니다.