이 글의 내용은 정다샘 교수님의 코드를 제가 분석한 내용입니다. 1편의 기본 rnn language model을 발전 시키는 내용입니다.
기존에는 하나의 token이 pitch 정보와 duration 정보를 모두 담은 형태로 만들어져 있었다. 가령 a와 a2는 같은 '라'이지만 길이는 2배차이가 난다. 하지만 이 방식은 크게 2가지 정도 문제가 있는듯 하다. 우선 token의 종류가 지나치게 많아져서 모델이 학습을 해야할 양이 늘어난다. 그리고 이 때문에 위치 정보인 duration을 제대로 학습 시키기 위해서는 필요한 데이터의 양이 늘어나게 된다.
따라서 각 token은 pitch와 duration의 값을 따로 가질 필요가 있다. dataset의 구조를 간단하게 생각해 보았을 때 dataset에서 꺼내올 수 있는 data는 하나의 token 당 (pitch, duration)과 같이 2개 혹은 그 이상(key, meter, length, rhythm)의 정보값을 알려주는 token들의 리스트가 되어야 할 것이다. 그리고 이 값들은 embedding -> rnn -> projection을 거친 뒤 softmax를 거치게 되어서 각 token별로 확률값을 말해주면 된다. 이 중에서 embedding layer의 단계에서 여러개의 값(pitch, duration, key, meter, length, rhythm)들을 따로 계산해야 하는 부분이 조금 까다롭지만 token이 여러개의 값을 가지더라도 각각의 정수값들(ex. pitch idx 46, duration idx 6)을 원하는 차원의 embedding 값으로 바꿔준 뒤(ex. pitch embedding 256, duration embedding 256)에 이를 concatenate 해버리면 결국 하나의 token 당 하나의 embedding(ex. summed_embedding 512)이 나오게 될 것이다.
모델을 학습 시키기 위해 필요한 요소인 Loss, Model, Trainer, Utils에서 utils를 먼저 생각해보자. 크게 생각해보았을 때 rnn을 학습 시키기위한 dataset이 갖춰야할 중요한 요소는 '모든 데이터 값들을 가지는 data'와 '그 데이터의 vocab들을 포함한 사전' 이 2개이다. 먼저 self.data는 리스트의 형태로 개별적인 요소들, 우리의 경우에는 노래의 tune들이 모두 들어가 있어야 하며, 각 tune들은 token들의 list로 표현이 되어 있어야 한다.
dataset_ABC.data[0]
# midi pitch // duration
['64//1.0',
'67//1.0',
'66//1.0',
'62//1.0',
' ',
'64//1.0',
'57//1.0',
' ', ...
위 데이터를 보면 저번 시간에 보았던 dataset과는 다르게 token이 midi_pitch와 duration으로 이루어진 것을 볼 수 있다. 교수님은 vocab의 숫자를 줄여주기 위해서 abc note 데이터를 parsing하는 도구인 pyabc를 뜯어 고쳤다. 그 내용은 너무 많아서 여기서 다룰 수가 없을듯 하다.
def convert_token(token):
if isinstance(token, pyabc.Note):
return f"{token.midi_pitch}//{token.duration}"
if isinstance(token, pyabc.Rest):
return f"{0}//{token.duration}"
text = token._text
if '"' in text:
text = text.replace('"', '')
if text == 'M':
return None
return text
결국 token에 midi_pitch와 duration이라는 method를 추가함으로 위의 형태와 같이 token들이 등장하게 된다.
이어서 data의 vocab들을 unique한 character들로 따로 모아주어서 이를 사전의 형태로 만들어 주는 작업이 필요하며, 기존의 dataset에서는 self.vocab이 이 사전에 해당한다. 또한 이 dataset을 get_item한 아웃풋은 정수로 encoding 된 값이어야 하기 때문에 self.tok(ken)2idx를 만들어주어서 vocab의 문자들이 숫자로 변환될 수 있도록 해야한다. 하지만 pitch_duration의 vocab을 구성하는 것은 기존에 set를 활용해서 중복된 character들을 제거하는 것 외에도 추가적인 작업이 필요하다. 아래는 기존의 vocab을 만드는 코드인데 이런 식으로 사전을 만들어주면 같은 pitch의 token들이 duration만 다른 별개의 token이 되어버린다.
def _get_vocab(self, vocab_path):
entire_char_list = [token for tune in self.data for token in tune]
self.vocab = ['<pad>', '<start>', '<end>'] + sorted(list(set(entire_char_list)))
self.tok2idx = {key: i for i, key in enumerate(self.vocab)}
dataset_ABC.vocab
['<pad>',
'<start>',
'<end>',
'\n',
' ',
' ',
'(',
'(3',
')',
'-',
'.',
'0//1.0',
'0//2.0',
'43//1.0',
'43//1.5',
'43//2.0', ...
우리가 원하는 것은 vocab의 수를 줄여서 모델이 학습할 양을 줄이고 pitch와 duration을 나누어 주어 학습해야할 부분을 좀더 세분화 하는 것이므로 이를 위해서 앞선 사전에서 '43//1.0','43//1.5','43//2.0'이 모두 포함된 것을 '43'이 포함된 pitch의 사전과 '1.0','1.5','2.0'이 포함된 duration의 사전으로 나누어주어야 한다.
사전의 구성은 크게 'main', 'dur', 'key', 'meter', 'unit_length', 'rhythm'의 6가지로 이루어져 있다. 각각은 pitch, duration, key, meter, 1박의 기준 길이, 장르라고 볼 수 있다. pitch와 duration만을 학습 시키는 것이 아니라 추가적인 정보들을 매 token마다 따로 적어줌으로써 모델이 이를 까먹지 않도록 한다. 따라서 사전은 dictionary의 형태로 만들어 주어야 할 것이며 이 작업을 위해서 새로운 TokenVocab class를 만들어주고 pitch_duration dataset의 vocab을 이 클래스의 object로 정의해 준다.
self.vocab = TokenVocab(vocab_path, unique_char_list)
def split_note(token):
if '//' in token:
pitch, duration = token.split('//')
return ['pitch'+pitch, 'dur'+duration]
else:
return [token, 'null']
class TokenVocab:
def __init__(self, json_path, unique_char_set=None) -> None:
self.key_types = ['main', 'dur', 'key', 'meter', 'unit_length', 'rhythm']
self.vocab = defaultdict(list)
...
for token in unique_char_set:
if 'dur' in token:
self.vocab['dur'].append(token)
elif 'K:' in token:
self.vocab['key'].append(token)
elif 'M:' in token:
self.vocab['meter'].append(token)
elif 'L:' in token:
self.vocab['unit_length'].append(token)
elif 'R:' in token:
self.vocab['rhythm'].append(token)
else:
self.vocab['main'].append(token)
코드를 보면 모든 토큰들에 dur, K:, M:, L:, R:의 string을 따로 미리 추가해줌으로써 vocab의 분류작업을 진행 하고 있다는 것을 알 수 있다.
# 만들어 놓은 tok2idx를 활용해서
# call 함수를 활용해서 object가 인풋을 받아 idx를 뱉어내게 하면 된다.
dataset_pidu.vocab('64//1.0'), dataset_pidu.vocab('|')
# ([47, 6], [60, 0])
# 추가로 특별한 header 값들도 매 token에 넣어준다.
header = dataset_pidu.header[0]
header, dataset_pidu.vocab.encode_header(header)
({'reference number': '518',
'tune title': 'Peggy on the settle',
'composer': 'anon.',
'origin': 'Ireland',
'book': 'Francis O\'Neill: "The Dance Music of Ireland" (1907) no. 518',
'rhythm': 'Reel',
'meter': 'C|',
'unit note length': '1/8',
'key': 'Ador'},
# [4, 9, 4, 10])
dataset_pidu[0][0]
tensor([[ 1, 0, 4, 9, 4, 10],
[47, 6, 4, 9, 4, 10],
[50, 6, 4, 9, 4, 10],
[49, 6, 4, 9, 4, 10],
[45, 6, 4, 9, 4, 10],
[ 4, 0, 4, 9, 4, 10],...
dataset은 packed_sequence로 묶여진 2개의 튜플의 형태(원 멜로디와 예측할 멜로디)이며 dataset의 vocab은 get_size()를 통해서 각각의 요소들의 사전의 크기를 알려준다.
# 5가지 input type으로 나누어 사전의 길이를 넣어준다.
# embedding layer는 사전의 크기를 필ㅇ로 하므로
dataset_pidu.vocab.get_size()
# [68, 12, 23, 10, 5, 16]
pitch_duration 모델이 기존의 ABC 모델의 class를 상속 받는 것을 가정했을 때, 이제는 init단에서 make_layer를 실행하고, forward단에서 get_layer를 실행하는 방식으로 모델을 짜야만 한다. 이렇게 구조를 만들어줘야 새로운 모델에서는 상속을 받으면서 필요한 layer의 make_layer와 get_layer만을 적어주면 되기 때문이다. LanguageModel에서 구조를 만들어두었기 때문에 이 모델을 상속받는 PitchDurModel에서는 make_embedding_layer, make_projection_layer 등을 수정해주면 된다.
class LanguageModel(nn.Module):
def __init__(self, vocab_size, hidden_size=128):
super().__init__()
self.vocab_size = vocab_size
self.hidden_size = hidden_size
self._make_embedding_layer()
self.rnn = nn.GRU(hidden_size, hidden_size, num_layers=3, batch_first=True)
self._make_projection_layer()
...
class PitchDurModel(LanguageModel):
def __init__(self, vocab_size, hidden_size=128):
super().__init__(vocab_size, hidden_size)
self.rnn = nn.GRU(hidden_size * 3, hidden_size, num_layers=3, batch_first=True)
def _make_embedding_layer(self):
self.emb = MultiEmbedding(self.vocab_size, [self.hidden_size,
self.hidden_size,
self.hidden_size//4,
self.hidden_size//4,
self.hidden_size//4,
self.hidden_size//4])
def _make_projection_layer(self):
self.proj = nn.Linear(self.hidden_size, self.vocab_size[0] + self.vocab_size[1])
...
우리는 인풋에서 각 토큰별로 Pitch, Duration, key, meter, length, ryhthm의 6가지 요소를 정수 값으로 받아오고 있다. 따라서 이 정수들을 우리가 원하는 차원의 embedding 벡터로 바꾸어줄 필요가 있다. 이를 위해서 ModulelList를 활용한다. ModuleList는 list와 구조가 같지만 layer들을 담아두기 위해서 torch에서 특별하게 제공하는 list라고 볼 수 있다고 한다. 여기에 layer를 담아두어야 backpropagation이 가능하다고 하는데 나도 그 이상의 내용은 모르겠다. 다만 어렵게 생각할 것 없이 list에서 for 문으로 embedding layer를 하나씩 빼와서 각각의 요소들을 지정한 크기의 차원의 벡터로 바꾸어준 뒤에 그 벡터들을 concatenate하는 구조라고 생각하면 된다.
class MultiEmbedding(nn.Module):
def __init__(self, vocab_sizes: Union[list, tuple], embedding_sizes: Union[int, list, tuple]) -> None:
super().__init__()
self.layers = []
if isinstance(embedding_sizes, int):
embedding_sizes = [embedding_sizes] * len(vocab_sizes)
for vocab_size, embedding_size in zip(vocab_sizes, embedding_sizes):
self.layers.append(nn.Embedding(vocab_size, embedding_size))
self.layers = nn.ModuleList(self.layers)
def forward(self, x):
return torch.cat([module(x[..., i]) for i, module in enumerate(self.layers)], dim=-1)
이 부분은 원하는 input_size와 output_size만을 맞추어서 layer를 구성하면 된다. 모델의 예측은 오직 pitch와 duration 값이기 때문에 logit의 차원은 이 두개의 사전의 크기를 합한 값이 된다.
self.rnn = nn.GRU(hidden_size * 3, hidden_size, num_layers=3, batch_first=True)
self.proj = nn.Linear(self.hidden_size, self.vocab_size[0] + self.vocab_size[1])
logit은 pitch + duration의 차원이기 때문에 softmax를 할때 이 둘을 나누어서 계산한다. 총합을 따로 더해주어야 적절한 확률값이 나올 것이다.
def _apply_softmax(self, logit):
# logit.shape = [num_total_notes, vocab_size[0] + vocab_size[1]]
prob = logit[:, :self.vocab_size[0]].softmax(dim=-1)
prob = torch.cat([prob, logit[:, self.vocab_size[0]:].softmax(dim=-1)], dim=1)
return prob
이제 모델의 전체적인 구조가 완성이 되었다. 후에는 dataloader를 통해 data를 불러온 뒤에 원 멜로디를 예측한 pred와 정답의 멜로디를 비교하여 loss값을 계산한 뒤 optimizer를 통해 모델을 학습시켜주면 된다. 사실 trainer 부분도 살펴볼게 굉장히 많지만 당장은 생략하도록 한다.
trainset, validset = torch.utils.data.random_split(dataset, [int(len(dataset)*0.9), len(dataset) - int(len(dataset)*0.9)], generator=torch.Generator().manual_seed(42))
train_loader = DataLoader(trainset, batch_size=args.batch_size, collate_fn=pack_collate, shuffle=True)
valid_loader = DataLoader(validset, batch_size=args.batch_size, collate_fn=pack_collate, shuffle=False)
loss_fn = get_nll_loss
def get_loss_pred_from_single_batch(self, batch):
melody, shifted_melody = batch
pred = self.model(melody.to(self.device))
loss = self.loss_fn(pred.data, shifted_melody.data)
return loss, pred