Show HN: 1억 2,500만 개 파라미터 모델을 훈련하여 온디바이스 피아노 자동 완성 구현
요약
1억 2,500만 개 파라미터 트랜스포머를 훈련시켜 MIDI 피아노 연주 자동 완성 기능을 구현했습니다. 이 시스템은 사용자가 짧게 연주한 프롬프트를 기반으로 AI가 연속적인 음악을 생성하며, 마치 '피아노용 GitHub Copilot'과 같습니다. 핵심 기술로는 적절한 MIDI 표현 방식 설계와 DPO(Direct Preference Optimization) 후 학습이 있습니다.
핵심 포인트
- 125M 파라미터 트랜스포머로 피아노 자동 완성 구현
- MIDI 이벤트를 토큰화하여 모델 입력으로 변환하는 과정 설명
- DPO를 활용한 음악 생성 품질 개선에 성공함
요약하자면(TL;DR): 저는 125M(1억 2,500만) 개의 파라미터를 가진 트랜스포머를 훈련시켜 실시간으로 피아노 연주를 자동 완성할 수 있게 했습니다(~아이폰 15에서 초당 약 108개 음표). 가장 큰 개선점은 적절한 MIDI 표현 방식을 찾고, 학습 데이터를 공격적으로 정제하며, DPO(Direct Preference Optimization) 후 학습을 추가한 덕분이었습니다.
거의 1년 전, 저는 하나의 아이디어를 가지고 만지작거리기 시작했습니다: 제 MIDI 피아노를 휴대폰에 연결하고, 무언가를 연주하면 AI가 노래를 자동 완성하도록 하는 것입니다. 마치 피아노용 GitHub Copilot 같다고 생각합니다.
이것은 제가 예상했던 것보다 훨씬 깊은 탐험(rabbit hole)이었습니다. 14번의 실험 끝에, 마침내 제가 글을 쓸 만큼 만족스러운 지점에 도달했습니다.
앱인 RollTab은 MIDI 키보드와 iPhone/iPad를 가지고 있다면 여기서 무료로 이용할 수 있습니다. 1
몇 가지 사운드 샘플
각 오디오는 짧은 프롬프트로 시작하며, 그 뒤에 모델의 연속 부분이 이어집니다.
포켓몬스터, 파레트 타운 (8개 음표 프롬프트)
파이널 판타지 VI, 테라의 테마 (16개 음표 프롬프트)
Für Elise (16개 음표 프롬프트)
MIDI 파일에는 무엇이 들어 있나요?
MIDI 파일은 MP3나 다른 오디오 형식과는 상당히 다릅니다. 녹음된 소리를 저장하기보다는, 특정 피치와 벨로시티(velocity)로 키가 눌리고, 키가 떼어지고, 서스테인 페달의 상태가 바뀌는 등 일련의 이벤트(sequence of events)로 음악을 저장합니다. 다른 이벤트로는 악기 전환이나 볼륨 변경 등이 포함됩니다.
이러한 이벤트들은 종종 여러 트랙으로 구성됩니다. 팝 또는 게임 MIDI 파일에는 멜로디, 코드, 베이스, 드럼, 현악기, 그리고 여러 신시사이저 파트가 있을 수 있습니다. 이 프로젝트는 피아노 연속성에 초점을 맞추었기 때문에, 저는 주로 피아노와 유사한 자료만 유지하고 나머지는 제거하거나 축소했습니다.
음악을 어떻게 토큰화(Tokenize)하나요?
이러한 연주에 트랜스포머를 훈련시키려면, 먼저 MIDI 이벤트를 모델이 읽고 예측할 수 있는 이산적인 시퀀스로 변환해야 했습니다. 가장 명확한 매핑은 모든 MIDI 이벤트마다 토큰을 만드는 것입니다:
NOTE_ON_60_80 # {pitch}_{velocity}
NOTE_OFF_60 # {pitch}
TIME_SHIFT_12 # {time step}
만약 NOTE_ON에 피치와 벨로시티를 직접 포함한다면...
토큰(token)의 경우 어휘집이 빠르게 커질 수 있습니다. 128개의 MIDI 피치와 128개의 벨로시티 값이 있으므로, 단순하게 결합한 노트 온(note-on) 어휘는 최대:
128 * 128 + 128 = 16,512
토큰을 가집니다. 노트 온과 노트 오프(note-off)에만 해당합니다. 실제로는 벨로시티를 버킷팅(bucket)할 가능성이 높지만, 기본적인 문제는 여전히 남아 있습니다: 많은 조합이 희귀하며, 모델은 희소한 토큰으로부터 많은 구조를 학습해야 합니다.
일반적인 개선 방법은 문법을 사용하여 표현을 분해하는 것입니다:
[NOTE_ON, PITCH, VELOCITY] | [NOTE_OFF, PITCH] | [TIME_SHIFT, DURATION]
이렇게 하면 출력 공간이 더 작아집니다:
NOTE_ON / NOTE_OFF / TIME_SHIFT
PITCH: 128개 값
VELOCITY: ~16
...
잘못된 다음 토큰을 마스킹(masking)함으로써 생성 중에 문법을 강제할 수 있습니다. NOTE_ON 이후에는 피치 토큰만 유효합니다. 피치 이후에는 벨로시티 토큰만 유효합니다. 이는 구문적으로 유효한 출력을 보장합니다.
저는 노트 온/노트 오프 스타일의 표현을 시도했지만, 제 모델들이 표류하는 경향이 있었습니다. 노트 오프를 출력하는 것을 잊거나, 매달린(hanging) 노트를 남기거나, 활성 상태 추적을 잃었습니다. 이는 특히 노트북이나 휴대폰에서 실시간에 가깝게 실행되는 작은 모델의 목표였던 저에게는 매우 나쁜 일이었습니다.
제가 시도한 또 다른 표현은 다음과와 더 가까웠습니다:
[NOTE, PITCH, VELOCITY, DURATION] | [TIME_SHIFT, DURATION]
이것은 노트 지속 시간이 명시적이기 때문에 노트 오프 표류를 방지합니다. 시간 이동(time shift) 토큰은 어떤 음표도 연주되지 않을 때 재생 헤드(playhead)를 전진시킵니다.
이것은 음악적으로 더 잘 작동했지만, 느렸습니다. 하나의 음악적인 노트는 대략 네 번의 자기회귀 트랜스포머 단계가 필요했습니다. 또한 컨텍스트 윈도우를 빠르게 소모합니다.
최종 표현 방식
제가 결국 결정한 표현 방식은 다음과 같았습니다:
NOTE(pitch, delta_onset, duration, velocity)
최종 버전에는 별도의 TIME_SHIFT 이벤트가 없습니다. 침묵은 다음 노트의 delta_onset에 의해 표현됩니다: 이전 노트 시작점으로부터 경과된 시간입니다.
예를 들어:
NOTE(C4, delta=0, duration=12, velocity=80)
NOTE(D4, delta=24, duration=12, velocity=80)
means: C4를 연주하고 24 타임 스텝 동안 기다린 후 다음 음표 시작 시 D4를 연주합니다.
코드(Chords)는 delta_onset = 0인 여러 음표로 표현됩니다.
, pitch2 순으로 정렬되어 있습니다:
NOTE(C4, delta=24, duration=24, velocity=80)
NOTE(E4, delta=0, duration=24, velocity=78)
NOTE(G4, delta=0, duration=24, velocity=82)
또한 다음과 같은 평탄한 토큰 스트림은 아닙니다:
NOTE, PITCH, DELTA, DURATION, VELOCITY
음표의 속성들을 생성하기 위해 네 번의 트랜스포머 패스를 사용하는 대신, 트랜스포머는 한 번에 하나의 완전한 음표로 음악을 진행합니다.
실제로는 이 방식을 통해 아이폰에서 초당 약 108개의 음표를 얻을 수 있으며, 이는 라이브 연주에 필요한 인간의 능력보다 훨씬 높은 수치입니다.
내부적으로 각 음표는 다섯 가지 범주형 필드를 가지며, 각각은 자체 어휘집(vocabulary)을 가지고 있고 타이밍은 고정된 스텝으로 양자화됩니다.⁴
[event_type, pitch_id, delta_id, duration_id, velocity_id]
각 필드는 자체 임베딩(embedding)을 받습니다. 음표 토큰은 모든 임베딩의 합입니다:
note =
event_type_embedding[NOTE] + pitch_embedding[C4] + ...
그런 다음 모델은 피치, 델타, 지속 시간 등 별도의 출력 헤드(output heads)를 가집니다.
필드들 사이에는 작은 중첩 디코더(nested decoder)가 있어 후반 필드가 앞서 예측된 필드를 조건화할 수 있습니다. 하지만 비용이 많이 드는 트랜스포머 백본(backbone)은 필드당 한 번이 아니라 음표당 한 번만 실행됩니다.
서스테인 페달 (Sustain Pedal)
아시다시피, 피아노에서 서스테인 페달을 밟으면 손을 떼고 난 후에도 음표가 연주됩니다. 저는 구현 과정에 서스테인 페달 이벤트를 추가하여 복잡하게 만들고 싶지 않았습니다. 대신, 서스테인은 전처리(preprocessing) 단계에서 음표 지속 시간(note duration)에 내장되어 있습니다.
만약 서스테인 페달이 내려진 상태에서 건반을 떼면, 해당 음표는 페달을 들어 올리는 시점까지 연장됩니다. 만약 같은 피치가 먼저 다시 연주되면, 이전 음표는 재트리거(retrigger) 지점에서 잘립니다. 그 결과는 실제 소리가 나는 지속 시간을 근사하는 음표 지속 시간이 됩니다.
이 방식은 명시적인 페달 제스처를 잃게 하지만, 모델링 문제를 훨씬 간단하게 만듭니다. 즉, 모델은 피치, 시작 시점(onset), 지속 시간, 속도만 예측하면 됩니다.
데이터셋
저는 공개적으로 사용 가능한 많은 데이터셋과 컬렉션을 검색했으며, 주로 퍼블릭 도메인의 오래된 클래식 음악에 초점을 맞췄습니다. 품질이 매우 들쭉날쭉했기 때문에, 저는 꽤 많은 클리닝 스크립트를 작성해야 했습니다.
최종 데이터셋에는 수십만 개의 MIDI 파일이 포함되었으며, 이는 대략 3억 개의 음표 이벤트를 나타냅니다.
최종 파이프라인:
- 피아노에 초점을 맞춘 자료를 선택함
- 병리학적인 멀티트랙 혼합(pathological multi-track mixtures)을 제거하거나 축소함
- 밀도 및 음높이/시간 커버리지로 필터링함
- 전역 변조(global transposition)와 균일한 템포 변화를 무시하는 지문(fingerprints)으로 중복 제거함
- 동일한 작곡의 대체 버전을 같은 분할(split) 그룹으로 묶음
저는 데이터셋 크기를 약 5배로 늘려 성능이 향상되기를 바랐지만, 결과 모델은 더 나빴습니다. 데이터를 단순히 많이 추가하는 것보다 클리닝하고 선택하는 것이 더 중요했습니다.
학습 (Training)
초기에는 학습이 다섯 개의 출력 헤드에 대한 크로스 엔트로피(cross-entropy)를 합산하여 이루어집니다:
type_loss
+ pitch_loss
+ delta_loss
...
이렇게 하면 단일 통합 다음 토큰 손실(next-token loss)에 의존하기보다 음높이, 지속 시간, 벨로시티 정확도를 개별적으로 추적하기 쉽습니다.
그럼에도 불구하고, 학습 목표에는 중요한 한계가 있습니다. 음악의 연속성은 단 하나의 정답을 가지고 있지 않습니다. 분리된 노래(held-out song)는 모델에게
아키텍처는 본질적으로 상당히 표준적인 디코더 전용 트랜스포머(decoder-only transformer)입니다. RMSNorm, 회전 위치 임베딩(rotary positional embeddings), 인과적 셀프 어텐션(causal self-attention), SwiGLU/MLP 블록, 그리고 자기회귀 생성(autoregressive generation)을 사용합니다.
저는 주로 세 가지 크기의 모델을 훈련했습니다:
small: 약 33M 파라미터
medium: 약 64M 파라미터
large: 약 125M 파라미터
small 모델은 빠른 실험에 좋았지만, medium 모델이 거의 항상 더 나았습니다. large 모델의 성능이 가장 좋았지만, 그 차이가 아주 크지는 않았습니다.
현재 저는 medium 모델을 large 모델 수준으로 끌어올리는 작업을 하고 있습니다. 주된 목적은 iOS 앱에서 사용되는 풋프린트(footprint)와 지연 시간(latency)을 줄이기 위함입니다.
스케줄링 샘플링 (Scheduled Sampling)
제가 가장 잘 작동하는 기본 모델은 각 음표 필드 사이에 스케줄링 샘플링을 사용했습니다. 일반적으로 훈련 중에는 지속 시간(duration)과 벨로시티(velocity) 예측이 정확한 피치(pitch)를 볼 수 있습니다. 하지만 추론(inference) 시점에서는 모델이 실제로 예측한 어떤 피치를 가지고 작업해야 합니다.
그래서 저는 훈련 중에 때때로 모델에게 스스로 예측한 피치를 입력으로 주었습니다. 처음 몇 에포크는 0%부터 시작하여, 훈련 과정에서 점진적으로 늘렸고, 최고 성능의 모델에서는 최대 50%까지 적용했습니다.
재미있게도, 이것은 검증 손실(validation loss)을 증가시켰지만 연속성(continuations)은 개선되었습니다.
평가 (Evaluation)
처음에는 제가 직접 듣고 평가하는 것이 전부였습니다.
저는 4~32개의 음표 프롬프트(prompts)를 사용하여 분리된 노래(held-out songs)에서 연속성을 생성한 다음, 모델 출력을 수동으로 비교했습니다. 이것은 느리고 성가신 작업이었고, 시간이 지나자 모든 것이 노이즈처럼 들렸습니다.
4개 음표 프롬프트가 가장 어려웠습니다. 작업할 음악적 맥락(musical context) 자체가 많지 않았기 때문입니다. 8개 음표는 더 잘 작동했고, 16~32개 음표 프롬프트는 모델이 무슨 일이 일어나고 있는지 추론하기에 충분한 구조를 가지고 있어서 훨씬 더 신뢰성이 높았습니다.
프롬프트 없이 생성하는 것은 매우 들쭉날쭉하지만(hit or miss), 제가 목표로 하는 사용 사례(use-case)는 아닙니다.
또한 여러 자동화된 지표도 작성했습니다:
- 반복되는 피치 n-그램 (repeated pitch n-grams)
- 피치 엔트로피 (pitch entropy)
- 피치 클래스 엔트로피 (pitch-class entropy)
- 피치 범위 (pitch range)
- 음표 밀도 (note density)
- 긴 간격 (long pauses)
- 코드 밀도 (chord density)
이러한 지표들은 명백한 실패를 포착하는 데 유용했지만, 최고의 모델을 선택하기에는 충분하지 않았습니다.
결국 저는 Gemini 3.5 Flash를 사용하여 쌍별 평가(pairwise evaluation)를 진행했습니다. 이 모델에게 단일 절대 점수를 매기도록 요청하는 것은 일관성이 없었습니다. 대신, 'A와 B가 주어졌을 때, 어떤 이어가기가 더 좋은가?'라고 질문하는 것이 훨씬 효과적이었고, 특히 위치 편향(position bias)을 줄이기 위해 모든 비교를 미러링(mirrored)했을 때 그러했습니다. 5 이를 통해 저는 상당히 큰 선호도 데이터셋(preference dataset)을 구축할 수 있었고, 이를 DPO에 사용했습니다.
처음에는 Gemini가 프롬프트로부터 얼마나 잘 이어지는지보다는, 그 자체로 어떤 좋은 이어가기처럼 들리는지에 과도하게 초점을 맞췄습니다. 출력물들은 종종 그 자체로는 더 좋게 들렸지만, 제가 방금 연주한 내용과 단절된 느낌을 주었습니다.
프롬프팅 개선으로 어느 정도 도움이 되었지만, 결국 저는 평가를 두 가지 기준으로 분리했습니다. 첫째는 프롬프트로부터 얼마나 잘 이어지는지를 측정하는 '이어가기 점수(continuation score)'였고, 둘째는 고립된 상태에서의 음악적 품질을 측정하는 '듣기 좋은 점수(sounds-good score)'였습니다. 저는 DPO의 주요 신호로 이어가기 점수를 사용했습니다.
DPO: Direct Preference Optimization
DPO는 사전 학습(pretraining) 이후 가장 큰 변화를 가져왔습니다. 이 모델을 가끔 좋은 이어가기를 생성하는 수준에서 훨씬 더 안정적으로 수행하도록 만들었습니다.
각 프롬프트에 대해 여러 개의 이어가기를 생성하고, 쌍별 평가를 사용하여 더 좋고 나쁜 것을 선택했습니다:
prompt -> chosen continuation
prompt -> rejected continuation
DPO는 모델이 선택된 이어가기를 거부된 이어가기보다 더 가능성이 높게 만들도록 훈련시키면서도, 원래 모델과 합리적으로 가깝게 유지하도록 합니다.
DPO 이후, 저의 쌍별 평가에서 69% 이상의 이어가기가 기본 모델(base model)보다 선호되었습니다.
$eta$ 값은 DPO가 기본 모델로부터 멀어지는 것을 얼마나 강하게 페널티화하는지를 제어합니다. 제가 수행한 탐색(sweep)에서 $eta=0.01$과 $eta=0.03$은 모델을 개선했지만, $eta=0.10$은 너무 과도하여 오히려 성능을 저하시켰습니다.
또한 '합의(consensus)' 데이터셋도 시도해 보았습니다. 모든 노이즈가 포함된 선호 판단을 신뢰하는 대신, 평가자가 일관되게 동의한 선호 쌍만 남겼습니다. 이것이 이번 테스트에서 가장 좋은 결과를 냈습니다.
제 직감으로는 기본 모델(base model)이 이미 음악에 대한 합리적인 정신 모델(mental model)을 학습했지만, 무엇이 좋은 연속성(continuation)을 만드는지는 알지 못했던 것 같습니다.
작동하지 않은 부분들
많은 부분이 제대로 작동하지 않았습니다:
AI 자동 생성 콘텐츠
본 콘텐츠는 HN OpenAI Codex의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기