모델 학습 속도가 지나치게 느릴 때가 있다. 그럴 때 효율적인 학습 방법들을 실행해서 해결해보도록 하자.
자주 사용하는 데이터가 (특히 용량이 큰 데이터) 있는데, 이를 매번 I/O를 진행하면 비효율적이고 느리다. 따라서 메모리에 일시적으로 저장해둠으로써 반복적으로 데이터 로드를 진행하지 않아도 되고, 전체적인 데이터 처리 속도를 향상시키는 것이다.
예를 들어, Dataset class 코드를 data caching하는 방식으로 수정해보자.
기존의 코드는 매 epoch마다 이미지를 불러오게 되어 있기 때문에, 그렇게 되지 않도록 전체 이미지를 미리 불러와서 vector화 한 후 npy형태로 저장하고 필요할 때 불러와서 사용하는 방식으로 수정할 수 있다. 이를 위한 코드는 아래와 같다.
def data_caching(root_dir: str, info_df: pd.DataFrame):
for idx, row in info_df.iterrows():
image_path = os.path.join(root_dir, row['image_path'])
image = cv2.imread(image_path, cv2.IMREAD_COLOR)
image = cv2.cvtColor(image, cv2.cOLOR_BGR2RGB)
npy_path = img_path.replace('.jpg', '.npy')
np.save(npy_path, image)
data_caching('base_directory', info_df)
그러면 기존의 Dataset class 코드에서 __init__ 부분에서 image_paths를 불러오는 부분을 수정하게 된다.
self.info_df = info_df
self.images = [None]*len(info_df) # 이미지 데이터를 저장할 리스트를 None으로 초기화
그리고 __getitem__에서 아래와 같이 이미지가 메모리에 로드되어 있지 않으면 새롭게 로드하고, 이미 로드되어있다면 그것을 불러오는 식으로 수정하면 된다. 이제 첫번째 epoch 이후로는 데이터 로딩 없이 직접 메모리에서 데이터를 가져오므로 데이터 로딩 시간을 절약할 수 있다.
if self.images[index] is None: # 이미지가 메모리에 로드되지 않았다면 새롭게 로드
img_path = os.path.join(self.root_dir, self.info_df.iloc[index]['image_path'])
image = cv2.imread(img_path, cv2.IMREAD_COLOR)
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
self.images[index] = image
else:
image = self.images[index]
그러나 전체 dataset의 크기가 메모리 용량을 초과할 경우 메모리 관리 문제가 발생할 수 있으니 주의해야 한다.
모델을 학습할 때 모든 데이터를 한 번에 처리할 수 없고 보통은 batch라는 단위로 나누어서 처리하게 된다. batch size가 크면 gradient 추정이 더 정확하고 학습 과정이 안정적이지만, 큰 메모리를 필요로 한다. 반대로 batch size가 작으면, gradient 추정 및 학습 과정이 불안정하지만 제한된 메모리 용량에서도 학습이 가능하다. 따라서 보통은 메모리 자원이 충분하지 않기 때문에 batch size를 작게 설정할 때가 있는데, gradient accumulation을 이용하면 작은 batch size로도 큰 batch size를 설정한 효과를 볼 수 있다.
gradient accumulation은 각 batch를 처리한 후에 gradient를 즉시 업데이트하지 않고 메모리에 누적했다가, 일정 수의 batch가 처리되고 나면 누적된 gradient를 사용해서 모델의 가중치를 업데이트 한다. 이를 train_epoch 코드에 적용하면 아래 코드와 같이 수정할 수 있다.
accumulation_steps = 10
self.optimizer.zero_grad() # gradient를 0으로 초기화하는 부분을 loop 바깥으로 빼낸다.
for i, (images, targets) in enumerate(progress_bar):
images, targets = images.to(self.device), targets.to(self.device)
outputs = self.model(images)
loss = self.loss_fn(outputs, targets)
loss = loss/accumulation_steps
loss.backward()
total_loss += loss.item()
# 10 step 동안 gradient를 축적하다가 10 step을 넘어가면 초기화
if (i + 1) % accumulation_steps == 0 or (i + 1) == len(self.train_loader):
self.optimizer.step()
self.optimizer.zero_grad()
self.scheduler.step() # 가중치를 업데이트할 때만 scheduler를 step
연산을 할 때, float32를 이용하면 정밀도가 높지만 메모리를 많이 차지하고 연산 속도가 느리다. 반면에 float16은 정밀도가 낮은 대신 효율적인 메모리 사용이 가능하고 연산 속도도 빠르다. 이 두 자료형의 장점을 합친 자료형이 bfloat16인데, 정밀도는 float16보다 높으면서도 메모리를 적게 차지해 연산을 빠르게 수행할 수 있다.
float16 = 1 bit 부호 + 5 bit 지수 + 10 bit 가수
bfloat16 = 1 bit 부호 + 8 bit 지수 + 7 bit 가수
따라서 효율적인 연산이 필요한 시점에서는 bfloat16으로 자료형을 변환하고, loss 계산이나 weight update처럼 모델의 성능과 직결되는 구간에서는 float32로 변환하는 것이 mixed precision training이다.
from torch.cuda.amp import autocast, GradScaler
scaler = GradScaler()
# train_epoch 함수 내에서
with autocast:
outputs = self.model(images)
loss = self.loss_fn(outputs, targets)
scaler.scale(loss).backward() # 스케일링된 loss로 backpropagation
scaler.step(self.optimizer) # scaler를 사용해서 가중치 업데이트
scaler.update()
대규모 이미지 data set에 label을 붙이는 작업은 비용과 시간이 많이 소요된다. 따라서 unlabeled data도 사용자가 labeling을 해서 학습을 시키는 방법도 있는데 이를 pseudo labeling이라고 한다.
이 때 validation set은 pseudo labeling 과정에 포함시키지 않도록 주의한다.
text-to-image 또는 image-to-image 생성 모델을 이용해서 학습에 활용할 추가 데이터를 확보하는 방법이다. 다만 생성 이미지가 신뢰할 만한 것인지 점검하는 단계가 필요하다. CLIP 등을 활용하여 생성된 이미지가 내가 의도한 표현(text)을 포함하고 있는지 측정하는 방법이 있다.