Training van een model met 125 miljoen parameters voor het automatisch aanvullen van piano
TL;DR: Ik heb een transformer met 125 miljoen parameters getraind om pianoprestaties in realtime aan te vullen (~108 noten per seconde op een iPhone 15). De grootste verbeteringen kwamen voort uit het vinden van de juiste MIDI-representatie, het agressief opschonen van de trainingsdata en het toevoegen van DPO (Direct Preference Optimization) na de training.
Bijna een jaar geleden begon ik met een idee: mijn MIDI-piano verbinden met mijn telefoon, iets spelen en AI de rest van het nummer voor me laten aanvullen. Denk aan GitHub Copilot, maar dan voor piano.
Het bleek een complexer project te zijn dan verwacht. Na veertien experimenten is het eindelijk op een punt gekomen waar ik erover kan schrijven. De app, RollTab, is gratis beschikbaar voor bezitters van een MIDI-keyboard en een iPhone/iPad.
Wat zit er in een MIDI-bestand?
Een MIDI-bestand verschilt wezenlijk van een MP3 of andere audioformaten. In plaats van opgenomen geluid op te slaan, slaat het muziek op als een reeks gebeurtenissen: een toets wordt ingedrukt op een bepaalde toonhoogte (pitch) en snelheid (velocity), een toets wordt losgelaten, het sustainpedaal verandert van status, enzovoort. Andere gebeurtenissen omvatten het wisselen van instrument of het aanpassen van het volume.
Deze gebeurtenissen zijn vaak georganiseerd in meerdere tracks. Een pop- of game-MIDI kan melodie, akkoorden, bas, drums, strijkers en verschillende synth-onderdelen bevatten. Omdat dit project gericht is op het aanvullen van piano, heb ik voornamelijk piano-achtig materiaal behouden en de rest verwijderd of verminderd.
Hoe tokeniseer je muziek?
Om een transformer te trainen op deze uitvoeringen, moest ik MIDI-gebeurtenissen eerst omzetten in een discrete reeks die het model kan lezen en voorspellen.
De meest voor de hand liggende mapping is om een token te maken voor elke MIDI-gebeurtenis:
NOTEON6080# {toonhoogte}{velocity}NOTEOFF60# {toonhoogte}TIMESHIFT12# {tijdstap}
Als je toonhoogte en velocity direct in een NOTE_ON-token opneemt, kan de vocabulaire snel groeien. Er zijn 128 MIDI-toonhoogtes en 128 velocity-waarden, waardoor de naïeve gecombineerde note-on vocabulaire tot 16.512 tokens kan oplopen (128 * 128 + 128). In de praktijk zou je velocity waarschijnlijk in groepen (buckets) verdelen, maar het basisprobleem blijft: veel combinaties zijn zeldzaam, waardoor het model veel structuur moet leren van schaarse tokens.
Een gebruikelijke verbetering is om de representatie te factoriseren met een grammatica: [NOTEON, PITCH, VELOCITY] | [NOTEOFF, PITCH] | [TIME_SHIFT, DURATION]
Nu zijn de output-ruimtes kleiner:
NOTEON/NOTEOFF/TIME_SHIFTPITCH: 128 waardenVELOCITY: ~16DURATION: ~100
Je kunt de grammatica tijdens de generatie afdwingen door ongeldige volgende tokens te maskeren. Na NOTE_ON zijn alleen pitch-tokens geldig; na de pitch zijn alleen velocity-tokens geldig. Dit garandeert een syntactisch correcte output.
Ik heb representaties in de stijl van note-on/note-off geprobeerd, maar mijn modellen hadden de neiging om te "driften". Ze vergaten note-off te verzenden, lieten noten hangen of verloren het overzicht van de actieve status. Dit was vooral problematisch voor mijn doel: een klein model dat bijna in realtime op een laptop of telefoon draait.
Een andere representatie die ik probeerde was: [NOTE, PITCH, VELOCITY, DURATION] | [TIME_SHIFT, DURATION]
Dit voorkomt note-off drift omdat de nootduur expliciet is. De time shift-token verschuift de afspeelkop wanneer er geen noot wordt gespeeld. Dit werkte muzikaal beter, maar het was traag. Eén muzikale noot kostte ongeveer vier autoregressieve transformer-stappen en verbruikte snel het contextvenster.
De uiteindelijke representatie
De representatie waar ik uiteindelijk voor koos was: NOTE(pitch, delta_onset, duration, velocity)
In de uiteindelijke versie is er geen aparte TIMESHIFT-gebeurtenis. Stilte wordt gerepresenteerd door de deltaonset van de volgende noot: de tijd sinds het begin van de vorige noot.
Voorbeeld:
NOTE(C4, delta=0, duration=12, velocity=80)NOTE(D4, delta=24, duration=12, velocity=80)
Betekenis: speel C4, wacht 24 tijdstappen voordat de volgende noot begint, speel dan D4.
Akkoorden worden gerepresenteerd als meerdere noten met delta_onset = 0, gesorteerd op toonhoogte.
Het is geen platte tokenstroom zoals: NOTE, PITCH, DELTA, DURATION, VELOCITY
In plaats van vier transformer-passes te besteden aan het genereren van de attributen van een noot, schuift de transformer de muziek één volledige noot per keer vooruit. In de praktijk bereikt dit met het grote model ongeveer 108 noten per seconde op een iPhone, wat ruim boven ligt aan wat een mens nodig heeft voor live spel.
Intern heeft elke noot vijf categorische velden, elk met een eigen vocabulaire, waarbij de timing is gekwantiseerd naar vaste stappen: [eventtype, pitchid, deltaid, durationid, velocity_id]
Elk veld krijgt zijn eigen embedding. De noot-token is de som van alle embeddings: note = eventtypeembedding[NOTE] + pitchembedding[C4] + deltaembedding[12] + durationembedding[24] + velocityembedding[80]
Het model heeft vervolgens aparte output-heads voor pitch, delta, duration, enzovoort. Er zit een kleine geneste decoder tussen de velden, zodat latere velden kunnen conditioneren op eerder voorspelde velden. De dure transformer-backbone draait echter slechts één keer per noot, niet per veld.
Sustainpedaal
Het indrukken van het sustainpedaal op een piano zorgt ervoor dat noten blijven klinken, zelfs nadat je de toets loslaat. Ik wilde de implementatie niet vertroebelen door sustainpedaal-gebeurtenissen toe te voegen. In plaats daarvan is sustain tijdens de preprocessing verwerkt in de nootduur.
Als de toets wordt losgelaten terwijl het sustainpedaal is ingedrukt, wordt de noot verlengd tot het moment dat het pedaal wordt losgelaten. Als dezelfde toonhoogte eerst opnieuw wordt gespeeld, wordt de eerdere noot afgekapt bij de hertriggering. Het resultaat is een nootduur die de werkelijke klinkende duur benadert.
Hiermee gaat het expliciete pedaalgebaar verloren, maar het maakt het modelleringsprobleem veel eenvoudiger: het model hoeft alleen pitch, onset, duration en velocity te voorspellen.
Dataset
Ik heb gezocht in veel publiek beschikbare datasets en collecties, waarbij ik me vooral richtte op oudere klassieke muziek in het publieke domein. De kwaliteit varieerde enorm, dus ik heb veel opschoonscripts geschreven.
De uiteindelijke dataset bevatte enkele honderdduizenden MIDI-bestanden, wat overeenkomt met ongeveer 300 miljoen noot-events.
De uiteindelijke pipeline:
- Selectie van op piano gericht materiaal.
- Verwijderen of verminderen van pathologische multi-track mengsels.
- Filteren op dichtheid en pitch/tijd-dekking.
- Deduplicatie via fingerprints die globale transpositie en uniforme tempowijzigingen negeren.
- Alternatieve versies van dezelfde compositie in dezelfde split groeperen.
Ik probeerde de dataset te schalen naar ongeveer 5x de grootte, in de hoop dat dit de prestaties zou verbeteren, maar de resulterende modellen waren slechter. Het opschonen en selecteren van de data was belangrijker dan simpelweg méér data toevoegen.
Training
Aanvankelijk bestond de training uit cross-entropy over de vijf output-heads, bij elkaar opgeteld: typeloss + pitchloss + deltaloss + durationloss + velocity_loss
Dit maakt het eenvoudig om de nauwkeurigheid van pitch, duration en velocity afzonderlijk te volgen, in plaats van te vertrouwen op één geaggregeerd next-token loss.
Toch heeft dit trainingsdoel een belangrijke beperking: muziekcontinuatie heeft geen enkel correct antwoord. Een testnummer geeft het model slechts één "correcte" volgende noot, terwijl er vaak veel continuaties zijn die muzikaal zouden werken. Cross-entropy is nuttig voor het leren van de mechanica van muziek, maar is geen goede proxy voor hoe goed een volledige continuatie klinkt.
Augmentatie
Augmentatie was belangrijk omdat de live-input geen perfect MIDI-bestand is. Het is een mens die piano speelt, waarbij noten soms iets te vroeg, te laat of te hard kunnen zijn.
Uiteindelijk heb ik de volgende augmentaties gebruikt:
- Globale transpositie.
- Uniforme tempowschaal.
- Duration/velocity jitter.
- Weggelaten prompt-noten.
Model
De architectuur is in essentie een standaard decoder-only transformer: RMSNorm, rotary positional embeddings (RoPE), causal self-attention, SwiGLU/MLP blocks en autoregressieve generatie.
Ik heb voornamelijk drie modelgroottes getraind:
- Small: ongeveer 33 miljoen parameters.
- Medium: ongeveer 64 miljoen parameters.
- Large: ongeveer 125 miljoen parameters.
Het kleine model was geweldig voor snelle experimenten, maar het medium model versloeg het bijna altijd. Het grote model presteerde beter, hoewel niet met een enorme marge. Momenteel probeer ik de kwaliteit van het medium model dichter bij die van het grote model te brengen om de voetafdruk en latentie in de iOS-app te verminderen.
Scheduled Sampling
Mijn beste basismodel maakte gebruik van scheduled sampling tussen de velden van elke noot. Normaal gesproken ziet het model tijdens de training de correcte pitch voordat duration en velocity worden voorspeld. Tijdens de inferentie moeten ze echter werken met whatever pitch het model daadwerkelijk heeft voorspeld.
Daarom voedde ik het model tijdens de training soms met zijn eigen voorspelde pitch. Ik begon bij 0% voor de eerste paar epochs en verhoogde dit geleidelijk tot 50% in het beste model. Opmerkelijk genoeg verhoogde dit de validation loss, maar verbeterde het de continuaties.
| Methode | Validation loss ↓ | Gemini preference ↑ |
|---|---|---|
| Scheduled 50% | 2.9998 | 64.3% |
| Zonder scheduled | 2.9495 | 35.7% |
Pairwise preference gescoord door Gemini.
Evaluatie
In het begin was de evaluatie puur gebaseerd op mijn eigen gehoor. Ik genereerde continuaties van testnummers met prompts van 4-32 noten en vergeleek de outputs handmatig. Dit was traag en na een tijdje klonk alles als ruis.
Prompts van vier noten waren het moeilijkst: er was simpelweg niet genoeg muzikale context. Acht noten werkten beter, terwijl prompts van 16–32 noten aanzienlijk betrouwbaarder waren omdat het model genoeg structuur had om af te leiden wat er gebeurde.
Ik schreef ook een reeks automatische metrieken:
- Herhaalde pitch n-grams.
- Pitch entropie.
- Pitch-class entropie.
- Pitch bereik.
- Nootdichtheid.
- Lange pauzes.
- Akkoorddichtheid.
Deze metrieken waren nuttig om overduidelijke fouten op te sporen, maar onvoldoende om het beste model te selecteren. Uiteindelijk gebruikte ik Gemini 3.5 Flash voor pairwise evaluation. In plaats van een absolute score te vragen, vroeg ik: "gegeven A en B, welke continuatie is beter?". Dit werkte veel beter, zeker omdat ik elke vergelijking spiegelde om positionele bias te verminderen. Hiermee kon ik een redelijk grote voorkeursdataset opbouwen voor DPO.
Aanvankelijk overindexeerde Gemini op hoe goed een continuatie op zichzelf klonk, in plaats van hoe goed deze volgde uit de prompt. Betere prompting hielp, maar uiteindelijk splitste ik de evaluatie in twee criteria: een continuatiescore (hoe goed volgt de output uit de prompt) en een sounds-good-score (muzikaal kwaliteit op zichzelf). De continuatiescore diende als primair signaal voor DPO.
DPO: Direct Preference Optimization
DPO zorgde voor het grootste verschil na de pretraining. Het tilde het model van "soms een goede continuatie produceren" naar "het veel betrouwbaarder doen".
Voor elke prompt genereerde ik meerdere continuaties en gebruikte ik pairwise evaluatie om een betere en een slechtere te kiezen: prompt -> gekozen continuatie prompt -> afgewezen continuatie
DPO traint het model om de gekozen continuatie waarschijnlijker te maken dan de afgewezen, terwijl het redelijk dicht bij het oorspronkelijke model blijft. Na DPO werden meer dan 69% van de continuaties verkozen boven die van het basismodel.
De $\beta$-waarde bepaalt hoe sterk DPO bestraft als het model te ver afwijkt van het basismodel. In mijn tests verbeterden $\beta=0.01$ en $\beta=0.03$ het model, terwijl $\beta=0.10$ te agressief was en het resultaat verslechterde. Ik probeerde ook een "consensus"-dataset: ik behield alleen paren waar de evaluator consistent akkoord ging. Dit leverde het beste resultaat op.
| Model | Pairwise preference (Gemini) |
|---|---|
| Pretrained base | 24.55% |
| $\beta = 0.01$ | 61.08% |
| $\beta = 0.03$ | 57.14% |
| $\beta = 0.10$ | 38.10% |
| Consensus ($\beta = 0.03$) | 69.05% |
Mijn gevoel is dat het basismodel al een redelijk mentaal model van muziek had geleerd, maar nog niet wat een goede continuatie maakt.
Wat niet werkte
Veel dingen werkten niet:
- Note-on/note-off driftte te veel voor kleine realtime modellen.
- Grammar-masked token streams waren valide maar traag.
- Bredere data maakte de resultaten slechter wanneer de data ruisig was.
- Grotere modellen hielpen, maar losten loops niet magisch op.
- Mirostat verminderde herhalingen, maar maakte de output vaak incoherent.
- Extra lokale hulp-losses maakten de training trager zonder duidelijke winst in kwaliteit.
- Absolute scalaire Gemini-beoordelingen waren slechter dan pairwise judging.
- Validation loss alleen miste belangrijke verschillen in de kwaliteit van de resultaten.
- Born-again networks (het model hertrainen op zijn eigen soft predictions) verbeterden de kwaliteit hier niet.
Implementatie en verpakking
Ik exporteerde het PyTorch-model naar Core ML en kwantiseerde de gewichten naar INT8. De eerste lancering is traag omdat de Apple-runtime het model optimaliseert voor de beschikbare hardware.
Het model is getraind met contexten tot 512 noten, maar ik wilde langere sessies ondersteunen. Wanneer de context de limiet nadert, behoud ik de meest recente 384 noten, herbouw ik de context vanuit die noten en ga ik verder. Dit betekent dat de KV-cache opnieuw moet worden opgebouwd, maar het model is snel genoeg dat dit geen groot probleem is.
Ik gebruikte RoPE voor positionele encoding, dus in theorie zou ik iets eleganters kunnen doen met verschoven posities en een ringbuffer. Helaas exposeert Core ML de Q, K en V niet direct. Op dat punt was ik echter gewoon blij dat het werkte.
Conclusie
Dit is een zeer leuk project geweest. Er zijn veel interessante papers over muziekgeneratie, maar ik heb er bewust voor gekozen om die in het begin niet te diep te lezen. Ik wilde het plezier ervaren om het probleem zelf op te lossen. Pas daarna ben ik teruggegaan om mijn aanpak te vergelijken met de bestaande literatuur.
Het is nog lang niet perfect. Het model loopt af en toe, korte prompts zijn lastig en er is veel te verbeteren. Denk aan GPT-2, maar dan voor piano. Maar ik ben eindelijk op het punt gekomen waar ik echt geniet van het zitten achter de piano, een paar noten spelen en kijken wat we samen bedenken.
***
Voetnoten:
- De eerste versie duurde 11 dagen om goedgekeurd te worden. Er is een nieuwe versie in behandeling die keuze biedt tussen top-k, top-p, min-p, XTC, top-h en Mirostat v2 sampling.
- We sorteren op toonhoogte zodat we tijdens de training niet worden gestraft wanneer het ene nummer een C-majeur akkoord codeert als CEG en een ander als EGC.
- De exacte vocabularies zijn:
event_type: PAD, BOS, EOS, NOTE, MASKpitch: 0 unused/pad + 128 MIDI pitchesdelta: 0..48 stappen, plus 72, 96, 144, 192duration: 1..96 stappen, plus 144, 192, 288, 384velocity: 4, 12, 20, ..., 124
- Timing gebruikt 24 stappen per kwartnoot. Dit geeft voldoende resolutie voor veelvoorkomende rechte en triool-onderverdelingen, inclusief de "bijna maar niet helemaal op de tel" timing die ik vaak produceer bij live spel. Dit is gekozen na analyse van de timingdistributie in de trainingsdataset.
- In een test over 200 nummers gaf Gemini in 70% van de gevallen dezelfde voorkeur nadat A en B waren omgewisseld.
- Enkele recente transformer-gebaseerde modellen voor symbolische MIDI-generatie zijn onder andere Aria, Moonbeam, MIDI-GPT, Anticipatory Music Transformer, PianoBART en MIDI-LLM.
Groetjes,