Hydra - 딥러닝 학습 및 config 관리
딥러닝 학습 과정은 어떻게 우리를 짜증나게 하는가?
딥러닝 모델을 개발하다보면 우리는 굉장히 짜증나는 문제들을 직면하게 된다.
- 많은 하이퍼 파라미터들의 관리: 모델의 설계가 복잡해지면 복잡해질수록 관리해야하는 하이퍼 파라미터의 수가 굉장히 늘어난다. 수작업으로 하나씩 관리하다보면, 실수로 잘못된 하이퍼 파라미터 셋으로 학습하는 경우들이 발생하기도 한다.
- 최적의 하이퍼 파라미터 조합 탐색: 하이퍼 파라미터 자체가 많은 것도 문제지만, 일부를 바꿔가면서 최적의 조합을 탐색하는 것 자체는 더 고역이다. 매번 코드를 수정하고 다시 돌리고 하기에는 확인해봐야 할 조합은 너무 많다.
이런 작업은, 언어모델 연구가 아닌 다른 계열의 딥러닝 연구를 하는 사람에게는 굉장한 고통이다. (hugging face에서 제공하는 시스템을 활용할 수 없는 경우가 많기 때문이다.)
나의 초기 대응: argparse 사용
처음에는 학습파일(training_model.py)의 main문 외부에 argparser를 활용해서 configuration을 위한 객체를 만들어 사용했다.
import argparse
parser = argparse.ArgumentParser(description='Training Configuration')
# dataset
parser.add_argument('--vessel_name', type=str, choices=[ 'vesel1', 'vessel2'], default='vessel1', help='Vessel name selection')
parser.add_argument('--data-file', type=str, choices=['vessel_1_preprocessed.parquet', 'vessel_2_preprocessed.parquet'], default='vessel_1_preprocessed.parquet', help='Data file to use for training')
parser.add_argument('--seed', type=int, default=None, help='Setting Random Seed for numpy, torch, cuda.')
# model structure
parser.add_argument('--model-structure', type=str, default='whole2', choices=['lastOutput', 'seq2seq', 'timeDistributed', 'multitask', 'simpleDNN'], help='Selection of model')
parser.add_argument('--hidden-size', type=int, default=128, help='Hidden size of LSTM')
parser.add_argument('--num-layers', type=int, default=1, help='Number of LSTM layers')
parser.add_argument('--bi-directional', action='store_true', default=True, help='Use bidirectional LSTM')
# training
parser.add_argument('--checkpoint', type=str, default=None, help='Checkpoint file path before training')
parser.add_argument('--loss-functions', nargs='+', type=str, default=['mse', 'other_loss'], help='Selection of loss functions')
parser.add_argument('--loss-weights', nargs='+', type=float, default=[1.0, 1000.0], help='Weight of each loss-functions')
parser.add_argument('--learning-rate', type=float, default=1e-3, help='Learning rate')
parser.add_argument('--batch-size', type=int, default=128, help='Batch size')
parser.add_argument('--epochs', type=int, default=1000, help='Number of epochs')
parser.add_argument('--patience', type=int, default=100, help='Early stopping patience')
parser.add_argument('--drop-ratio', type=float, default=0.0, help='Dropout ratio of fc layer')
parser.add_argument('--cv', type=bool, default=True, help='Applying cross validation')
# .... 일부 생략 ....
args = parser.parse_args()이 방식을 쓰면 어느정도 고통을 덜 수 있다.
- 터미널에서 이들 하이퍼 파라미터들을 변경하면서 학습을 시킬 수 있다. (파라미터들을 조합하고 학습하는 쉘 스크립트가 필요하다.)
- 학습에 사용한 args 인스턴스를 모델파일과 함께 파일로 저장해두고 불러올 수 있다.
그러나, 이 방식은 또 다른 문제를 내포하고 있었다.
- (예시에서와 같이) 코드가 아름답지 못하다. 여러 파일로 나눠서 config을 구분하고 조합할 수 있으면 좋을텐데 그런 방법이 없다.
- 모델에 추가로 하이퍼 파라미터가 추가되야 한다면, 굉장히 짜증나는 상황이 발생한다. (기존에 저장한 모델이 사용한 args에는 새로운 하이퍼 파라미터가 없으니, 또 하나씩 추가해주는 작업이 필요하다.)
- 조합이 많아지면, 학습을 위한 쉘 스크립트가 굉장히 지저분해진다.
휴…
그래서 다른 방법을 찾을 수 밖에 없었다.
누군가 동일한 고민을 했을 것이라 생각했고, 역시나 해결책은 있었다.
바로 오늘의 주인공인 Hydra다.
Argparse 대신 Hydra
Hydra는 meta에서 관리하는 오픈소스 프로젝트로, 공식 문서는 이렇게 설명하고 있다.
Hydra is an open-source Python framework that simplifies the development of research and other complex applications. The key feature is the ability to dynamically create a hierarchical configuration by composition and override it through config files and the command line. The name Hydra comes from its ability to run multiple similar jobs - much like a Hydra with multiple heads.
우리가 겪는 고충을 meta도 너무나 잘 알고 있는 것 같다.
- 복잡한 research와 application을 간단히 개발하도록 지원한다.
- 동적으로, 계층을 가진 configuration 파일을 생성할 수 있다.
- Command line에서 override할 수 있다.
- Multiple job을 지원한다. (한 번의 명령어로, config을 조합하면서 수 차례 실험을 반복하게 할 수 있다.)
Hydra의 기초적 사용법
Hydra의 공식 문서에 있는 tutorial이 너무나 잘 되어있어서, 그것보다 더 잘 설명할 자신이 없다. 기초적인 사용법은 공식 문서의 tutorial을 참고하자.
내가 Hydra를 사용하는 방식
나는 오히려, 내가 이걸 그래서 어떻게 쓰고 있는 지 설명해볼까 한다. 내가 활용하는 방식은 두 가지로 정리할 수 있다.
- 계층구조로 config 파일 관리하기
- 터미널에서 multirun 기능으로 여러 조합들 학습 수행하기
계층구조로 config 파일 관리하기
앞서 argparse의 코드를 보면 dataset, model structure, training라고 주석을 통해 config을 구분하고 있다. (굉장히 비효율적이다.) 이들은 별도의 파일로 분류하는 것이 더욱 관리측면에서 바람직해 보인다. 나는 config을 다음처럼 분리하여 사용한다.
- config.yaml
- wandb.yaml
-
- base.yaml
-
- base.yaml
- mse.yaml
- other_loss.yaml
-
- base.yaml
- lastHidden.yaml
- wholeOutput.yaml
- seq2seq.yaml
-
- base.yaml
- plateau.yaml
- steplr.yaml
-
- base.yaml
최상단에 config.yaml과 wadnb.yaml이 존재한다.
이들은 어떤 상황에서든 포함되야하는 공통 config을 담고있다.
그리고, 다섯 개의 폴더(data, loss, model, scheduler, training)로 config을 나눴다.
model 폴더를 예시로 살펴보자.
model 폴더에는 base.yaml이 존재하는데, 이는 해당 카테고리(model)의 default 값을 설정하는 파일이다.
# default로 wholeOutput 모델을 선택하고 있다.
structure: wholeOutput # [lastHidden, wholeOutput, seq2seq]같은 폴더에 몇 개의 yaml 파일들이 더 있다. (lasyHidden.yaml, wholeOutput.yaml, 그리고 seq2seq.yaml)
이들은 base.yaml에서 선택하는 선택지로 미리 만들어둔 것으로, 선택된 model의 config들이 model 하부에 load된다. (정확히는 base와 합쳐진다.)
즉, base.yaml에서는 structure: wholeOutput이라고 설정하고 load 했지만, 아래와 같이 실제론 wholeOutput.yaml의 설정들이 로드되게 된다.
_target_: models.LSTM_wholeOutput.LSTM_wholeOutput
structure: 'wholeOutput'
hidden_size: 128
num_layers: 1
bi_directional: True
expansion: 2
drop_ratio: 0.0
input: ${data.input}
output: ${data.output}
input_timestep: ${data.input_timestep}
output_timestep: ${data.output_timestep}_target_: models.LSTM_wholeOutput.LSTM_wholeOutput
# base.yaml에 있는 structure를 wholeOutput.yaml에 있는 structure로 덮어쓰기 됨.
structure: 'wholeOutput'
hidden_size: 128
num_layers: 1
bi_directional: True
expansion: 2
drop_ratio: 0.0
input: ${data.input}
output: ${data.output}
input_timestep: ${data.input_timestep}
output_timestep: ${data.output_timestep}load된 이후 각 항목들은 다음처럼 접근할 수 있다.
print(f'model: {cfg.model.structure}')
print(f'hidden size: {cfg.model.hidden_size}')
print(f'expansion: {cfg.model.expansion}')이렇게 각 모델별로 별도의 yaml 파일을 만들어두면, 모델마다 사용되는 파라미터셋이 다르더라도 동적으로 해당 모델에 맞는 config을 불러와서 사용할 수 있게 된다. 모델이 추가되더라도, 그에 해당하는 yaml 파일만 추가해주면 되서 매우 편리하다. 모델 이외의 loss, data, scheduler 등도 이런 식으로 구성하여 사용하고 있다.
_target_은 왜 설명 안하죠?
눈치챘을 지 모르지만, wholeOutput.yaml에 있는 _target_은 설명하지 않고 넘어갔다.
이는 hydra의 object instantiate를 사용하기 위한 항목이다.
자세한 기능과 사용법은 object instantiate에 대한 공식 가이드를 참고하자.
터미널에서 multirun 기능으로 여러 조합들 학습 수행하기
argparser를 쓸 때는, 여러 조합의 실험을 수행하기 위한 쉘 스크립트를 만들어 사용했었다. (다시봐도 혼돈 그 자체다. 아마 내 쉘 스크립팅 실력 문제일 것이다.)
#!/bin/bash
# 새로운 변수를 추가할 때 유의사항:
# 1. string을 받을 경우, key="$variable" 형태로 받을 것
# 2. int나 float는 key=$variable 형태로 받을 것
# 3. 다수의 combination을 위해 for 문을 사용할 경우, for 문의 list는 ""로 감싸줄 필요가 있음.
# 4. list내 항목은 띄어쓰기 없이 딱 붙여쓰고 []로 감싸둬야 함.
# model
structure='wholeOutput'
hidden_size_combinations=(128 256)
expansion=2
# data
vessel_name='vessel1'
input_list=[current_dir,current_strength,wind_dir,wind_strength]
output='label'
# train
checkpoint=null
freeze=false
loss_functions=[mse,other_loss]
loss_weights_combinations=(
[1.0,1000000000000.0,0.0,0.0]
[1.0,1000000000000.0,0.0,0.0]
[1.0,1000000000000.0,0.0,0.0]
[1.0,1000000000000.0,0.0,0.0]
[1.0,1000000000000.0,0.0,0.0]
)
learning_rate_combinations=("1e-4" "1e-3")
batch_size_combinations=(128 256)
epochs=2000
patience=30
cv=true
fold_combinations=(5 10)
# wandb
wandb_project_name='project1'
wandb_note='comment'
for loss_weights in "${loss_weights_combinations[@]}"; do
for fold in "${fold_combinations[@]}"; do
for batch_size in "${batch_size_combinations[@]}"; do
for input_timestep in "${input_timestep_combinations[@]}"; do
for learning_rate in "${learning_rate_combinations[@]}"; do
for hidden_size in "${hidden_size_combinations[@]}"; do
for plateau_lr_patience in "${plateau_lr_patience_combinations[@]}"; do
python train_model.py\
vessel_name="'$vessel_name'"\
voyage_based_cv=$voyage_based_cv\
folds=$fold\
label_summation=$label_summation\
batch_size=$batch_size\
input_timestep=$input_timestep\
output_timestep=$output_timestep\
label_shift=$label_shift\
learning_rate=$learning_rate\
hidden_size=$hidden_size\
expansion=$expansion\
structure="$structure"\
scheduler="$scheduler"\
plateau_lr_patience=$plateau_lr_patience\
patience=$patience
epochs=$epochs\
loss_functions=$loss_functions\
loss_weights=$loss_weights\
wandb_project_name="$wandb_project_name"\
wandb_note="'$wandb_note'"\
input=$input_list\
freeze=$freeze\
checkpoint=$checkpoint\
done
done
done
done
done
done
done하지만, 더는 이런 지저분한 스크립팅이 필요가 없다. 지금은 아주 ‘우아하게’ 한 줄로 끝낸다.
train_model.py -m model.hidden_size=128,256,512 training.learning_rate=1e-3,1e-4,1e-5 loss.weights.other_loss=1e+15 wandb_project_name=project1 +repeat=0,1,2,3,4이 명령어에는 multirun을 위한 세 개의 항목이 있다.
model.hidden_size=[128,256,512]
training.learning_rate=[1e-3,1e-4,1e-5]
repeat=[0,1,2,3,4]Hydra는 이들을 조합하여 총 회의 반복 실험을 알아서 수행한다. 그래서 multirun이다. 이 얼마나 우아한가?
정리하며
본 포스팅에서는 hydra의 필요성과 저자가 활용하는 사례를 소개했다. 아마 딥러닝 학습으로 고통받는 많은 연구자들이 찾던 내용이 아니었을까 싶다.
저자는 아직 hydra의 고급 사용자가 아니기 때문에, hydra의 가능성은 본 포스팅에서 설명한 내용에 국한되지 않는다. 더 유용하고 강력한 기능들이 많이 있다. 이후 새로운 기능을 익히게 되면, 후속 포스팅으로 다뤄볼까 한다.