[譯] 如何在 keras 中使用迴圈神經網路創作音樂

hearfish發表於2019-03-28

[譯] 如何在 keras 中使用迴圈神經網路創作音樂

介紹

神經網路正在被使用去提升我們生活的方方面面。它們為我們提供購物建議,創作一篇基於某作者風格的文件甚至可以被使用去改變圖片的藝術風格。近幾年來,大量的教程集中於如何使用神經網路去創作文字但卻鮮有教程告訴你如何創作音樂。在這篇文章中我們將介紹如何通過迴圈神經網路,使用 Python 和 Keras 庫去創作音樂。

對於那些沒耐心的人,在結尾為你們提供了本教程的 Github 倉庫的連結。

背景

在進入具體的實現之前必須先弄清一些專業術語。

迴圈神經網路(RNN)

迴圈神經網路是一類讓我們使用時序資訊的人工神經網路。之所以稱之為迴圈是因為他們對資料序列中的每一個元素都執行相同的函式。每次的結果依賴於之前的運算。傳統的神經網路則與之相反,輸出不依賴於之前的計算。

在這篇教程中,我們使用一個長短期記憶(LSTM)神經網路。這類迴圈神經網路可以通過梯度下降法高效的學習。使用閘門機制,LSTM 可以識別和編碼長期模式。LSTM 對於解決那些長期記憶資訊的案例如創作音樂和文字特別有用。

Music21

Music21 是一個被使用在計算機輔助音樂學的 Python 工具包。它使我們可以去教授音樂的基本原理,創作音樂範例並且學音樂。這個工具包提供了一個簡單的介面去獲得 MIDI 檔案中的音樂譜號。除此之外,我們還能使用它去創作音符與和絃來輕鬆製作屬於自己的 MIDI 檔案。

在這篇教程中我們將使用 Music21 來提取我們資料集的內容,獲取神經網路的輸出,再將之轉換成音符。

Keras

Keras 是一個 high-level 神經網路介面,它簡化了和 Tensorflow 的互動。它的開發重點是實現快速實驗。

在本教程中我們將使用 Keras 庫去建立和訓練 LSTM 模型。一旦這個模型被訓練出來,我們將使用它去給我們的音樂創作音符。

訓練

在本節中我們將講解如何為我們的模型收集資料,如何整理資料使它能夠在 LSTM 模型中被使用,以及我們模型的結構是什麼。

資料

Github 倉庫中,我們使用鋼琴曲(展示),音樂主要由《最終幻想》中的音軌組成。選擇《最終幻想》系列音樂,是因為它有很多部分,而且大部分的旋律都是清晰而優美的。而任何一組由單個樂器組成的 MIDI 檔案都可以為我們服務。

實現神經網路的第一步是檢查我們要處理的資料。

下面我們看到的是來自於一個被 Music21 讀取後的 midi 檔案的摘錄:

...
<music21.note.Note F>
<music21.chord.Chord A2 E3>
<music21.chord.Chord A2 E3>
<music21.note.Note E>
<music21.chord.Chord B-2 F3>
<music21.note.Note F>
<music21.note.Note G>
<music21.note.Note D>
<music21.chord.Chord B-2 F3>
<music21.note.Note F>
<music21.chord.Chord B-2 F3>
<music21.note.Note E>
<music21.chord.Chord B-2 F3>
<music21.note.Note D>
<music21.chord.Chord B-2 F3>
<music21.note.Note E>
<music21.chord.Chord A2 E3>
...
複製程式碼

這個資料被拆分成兩種型別:Note(譯者注:音符集)和 Chord(譯者注:和絃集)。音符物件包括音高音階和音符的偏移量

  • 音高是指聲音的頻率,或者用 [A, B, C, D, E, F, G] 來表示它是高還是低。其中 A 是最高,G 是最低。

  • 音階 是指你將選擇在鋼琴上使用哪些音高。

  • 偏移量是指音符在作品的位置。

而和絃物件的本質是一個同時播放一組音符的容器。

現在我們可以看到要想精確創作音樂,我們的神經網路將必須有能力去預測哪個音符或和絃將被使用。這意味著我們的預測集將必須包含每一個我們訓練集中遇到的的音符與和絃物件。在 Github 頁面的訓練集上,不同的音符與和絃的數量總計達 352 個。這似乎交給了網路許多種可能的預測去輸出,但是一個 LSTM 網路可以輕鬆處理它。

接下來我得考慮把這些音符放到哪裡了。正如大部分人聽音樂時注意到的,音符的間隔通常不同。你可以聽到一連串快速的音符,然後接下來又是一段空白,這時沒有任何音符演奏。

接下來我們從另外一個被 Music21 讀取過的 midi 檔案裡找一個摘錄,這次我們僅僅在它後面新增了偏移量。這使我們可以看到每個音符與和絃之間的間隔。

...
<music21.note.Note B> 72.0
<music21.chord.Chord E3 A3> 72.0
<music21.note.Note A> 72.5
<music21.chord.Chord E3 A3> 72.5
<music21.note.Note E> 73.0
<music21.chord.Chord E3 A3> 73.0
<music21.chord.Chord E3 A3> 73.5
<music21.note.Note E-> 74.0
<music21.chord.Chord F3 A3> 74.0
<music21.chord.Chord F3 A3> 74.5
<music21.chord.Chord F3 A3> 75.0
<music21.chord.Chord F3 A3> 75.5
<music21.chord.Chord E3 A3> 76.0
<music21.chord.Chord E3 A3> 76.5
<music21.chord.Chord E3 A3> 77.0
<music21.chord.Chord E3 A3> 77.5
<music21.chord.Chord F3 A3> 78.0
<music21.chord.Chord F3 A3> 78.5
<music21.chord.Chord F3 A3> 79.0
...
複製程式碼

如這段摘錄裡所示,midi 檔案裡大部分資料集的音符的間隔都是 0.5。因此,我們可以通過忽略不同輸出的偏移量來簡化資料和模型。這不會太劇烈的影響神經網路創作的音樂旋律。因此我們將忽視教程中的偏移量並且把我們的可能輸出列表保持在 352。

準備資料

既然我們已經檢查了資料並且決定了我們要使用音符與和絃作為網路輸出與輸出的特徵,那麼現在就要為網路準備資料了。

首先,我們把資料載入到一個陣列中,就像下面的程式碼這樣:

from music21 import converter, instrument, note, chord

notes = []

for file in glob.glob("midi_songs/*.mid"):
    midi = converter.parse(file)
    notes_to_parse = None

    parts = instrument.partitionByInstrument(midi)

    if parts: # 檔案包含樂器
        notes_to_parse = parts.parts[0].recurse()
    else: # 檔案有扁平結構的音符
        notes_to_parse = midi.flat.notes

    for element in notes_to_parse:
        if isinstance(element, note.Note):
            notes.append(str(element.pitch))
        elif isinstance(element, chord.Chord):
            notes.append('.'.join(str(n) for n in element.normalOrder))
複製程式碼

使用 converter.parse(file) 函式,我們開始把每一個檔案載入到一個 Music21 流物件中。使用這個流物件,我們在檔案中得到一個包含所有的音符與和絃的列表。把陣列符號貼到到每個音符物件的音高上,因為使用陣列符號可以重新創造音符中最重要的部分。將每個和絃的 ID 編碼成一個單獨的字串,每個音符用一個點分隔。這些程式碼使我們可以輕鬆的把由網路生成的輸出解碼為正確的音符與和絃。

既然我們已經把所有的音符與和絃放入一個序列表中,我們就可以創造一個序列,作為網路的輸入。

圖 1: 當一個資料由分類資料轉換成數值資料時,此資料被轉換成了一個整數索引來表示某一類在一組不同值中的位置。例如,蘋果是第一個明確的值,因此它被對映成 0。桔子在第二個因此被對映成 1,菠蘿就是 3,等等

圖 1:當一個資料由分類資料轉換成數值資料時,此資料被轉換成了一個整數索引來表示某一類在一組不同值中的位置。例如,蘋果是第一個明確的值,因此它被對映成 0。桔子在第二個因此被對映成 1,菠蘿就是 3,等等。

首先,我們將寫一個對映函式去把字元型分類資料對映成整型數值資料。這麼做是因為神經網路處理整型數值資料(的效能)遠比處理字元型分類資料好的多。圖 1 就是一個把分類轉換成數值的例子。

接下來,我們必須為網路及其輸出分別建立輸入序列。每一個輸入序列對應的輸出序列將是第一個音符或者和絃,它在音符列表的輸入序列中,位於音符列表之後。

sequence_length = 100

# 得到所有的音高名稱
pitchnames = sorted(set(item for item in notes))

# 建立一個音高到音符的對映字典
note_to_int = dict((note, number) for number, note in enumerate(pitchnames))

network_input = []
network_output = []

# 建立輸入序列和與之對應的輸出
for i in range(0, len(notes) - sequence_length, 1):
    sequence_in = notes[i:i + sequence_length]
    sequence_out = notes[i + sequence_length]
    network_input.append([note_to_int[char] for char in sequence_in])
    network_output.append(note_to_int[sequence_out])

n_patterns = len(network_input)

# 整理輸入格式使之與 LSTM 相容
network_input = numpy.reshape(network_input, (n_patterns, sequence_length, 1))
# 歸一化輸入
network_input = network_input / float(n_vocab)

network_output = np_utils.to_categorical(network_output)
複製程式碼

在這段示例程式碼彙總,我們把每一個序列的長度都設為 100 個音符或者和絃。這意味著要想去在序列中去預測下一個音符,網路已經有 100 個音符來幫助預測了。我極其推薦使用不同長度的序列去訓練網路然後觀察這些不同長度的序列對由網路產生的音樂的影響。

為網路準備資料的最後一步是將輸入歸一化處理並且 one-hot 編碼輸出

模型

最後我們來設計這個模型的架構。在模型中我們使用到了四種不同型別的層:

LSTM 層是一個迴圈的神經網路層,它把一個序列作為輸入然後返回另一個序列(返回序列的值為真)或者一個矩陣。

Dropout 層是一個正則化規則,這其中包含了在訓練期間每次更新時將輸入單位的一小部分置於 0,以防止過擬合。它由和層一起使用的引數決定。

Dense 層fully connected 層是一個完全連線神經網路的層,這裡的每一個輸入節點都連線著輸出節點。

The Activation 層決定使用神經網路中的哪個啟用函式去計算輸出節點。

model = Sequential()
    model.add(LSTM(
        256,
        input_shape=(network_input.shape[1], network_input.shape[2]),
        return_sequences=True
    ))
    model.add(Dropout(0.3))
    model.add(LSTM(512, return_sequences=True))
    model.add(Dropout(0.3))
    model.add(LSTM(256))
    model.add(Dense(256))
    model.add(Dropout(0.3))
    model.add(Dense(n_vocab))
    model.add(Activation('softmax'))
    model.compile(loss='categorical_crossentropy', optimizer='rmsprop')
複製程式碼

既然我們有關於不同層的一些資訊,那就把它們加到神經網路的模型中。

對於每一個 LSTM,Dense 和 Activation 層,第一個引數是層裡應該有多少節點。對於 Dropout 層,第一個引數是輸入單元中應該在訓練中被捨棄的輸入單元的片段。

對於第一層我們必須提供一個唯一的,名字是 input_shape 的引數。這個引數決定了網路中將要訓練的資料的格式。

最後一層應該始終包含和我們輸出不同結果數量相同的節點。這確保網路的輸出將直接對映到我們的類裡。

在這裡我們將使用一個簡單的,包含三個 LSTM 層、三個 Dropout 層、兩個 Dense 層和一個 activation 層的網路。我推薦調整網路的結構,觀察你是否可以提高預測的質量。

為了計算每次迭代的損失,我們將使用 [分類交叉熵],(rdipietro.github.io/friendly-in…)因為我們每次輸出屬於一個簡單類並且我們有不止兩個以上的類在為此工作。為了優化網路我們將使用 RMSprop 優化器。通常對於迴圈神經網路,使用它算是一個好的選擇。

filepath = "weights-improvement-{epoch:02d}-{loss:.4f}-bigger.hdf5"    

checkpoint = ModelCheckpoint(
    filepath, monitor='loss', 
    verbose=0,        
    save_best_only=True,        
    mode='min'
)    
callbacks_list = [checkpoint]     

model.fit(network_input, network_output, epochs=200, batch_size=64, callbacks=callbacks_list)
複製程式碼

一旦我們決定了網路的結構,就應該開始訓練了。使用 Kearas 裡的 model.fit() 函式來訓練網路。第一個引數是我們早前準備的輸入序列表,而第二個引數是它們各自輸出的列表。在本教程中我們將訓練網路進行 200 次迭代,每一個批次都是通過包含了 60 個分支的網路增殖的。

為了確保我們可以在任何時間點停止訓練而不會將之前的努力付之東流,我們將使用 model checkpionts(模型檢查點)。它為我們提供了一種方法,把每次迭代之後的網路節點的權重儲存到一個檔案中。這使我們一旦對損失值滿意了就可以停掉神經網路而不必擔心失去權重值。否則我們必須一直等待直到網路完成所有的 200 次迭代次數才能把權重儲存到檔案中。

創作音樂

既然我們已經完成了訓練網路,是時候享受一下我們花了幾個小時訓練的網路了。

為了能用神經網路去創作音樂,你得把它恢復到原來的狀態。簡言之我們將再次使用訓練部分中的程式碼,用之前的方式去準備資料和建立網路模型。這並不是重新訓練網路,而是把之前網路中的權重載入到模型中。

model = Sequential()
model.add(LSTM(
    512,
    input_shape=(network_input.shape[1], network_input.shape[2]),
    return_sequences=True
))
model.add(Dropout(0.3))
model.add(LSTM(512, return_sequences=True))
model.add(Dropout(0.3))
model.add(LSTM(512))
model.add(Dense(256))
model.add(Dropout(0.3))
model.add(Dense(n_vocab))
model.add(Activation('softmax'))
model.compile(loss='categorical_crossentropy', optimizer='rmsprop')

# 給每一個音符賦予權重
model.load_weights('weights.hdf5')
複製程式碼

現在我們可以使用訓練好的模型去開始創作音符了。

因為我們有一個完整的音符序列表,我們將在列表中選擇任意一個索引作為起始點,這允許我們不需要做任何修改就能重新執行程式碼並且每次都能返回不同的結果。但是,如果希望控制起始點,只需用命令列引數替換隨機函式即可。

這裡我也需要寫一個對映函式去編碼網路的輸出。這個函式將數值資料對映成分類資料(把整數變成音符)。

start = numpy.random.randint(0, len(network_input)-1)

int_to_note = dict((number, note) for number, note in enumerate(pitchnames))

pattern = network_input[start]
prediction_output = []

# 生成 500 個音符
for note_index in range(500):
    prediction_input = numpy.reshape(pattern, (1, len(pattern), 1))
    prediction_input = prediction_input / float(n_vocab)

    prediction = model.predict(prediction_input, verbose=0)

    index = numpy.argmax(prediction)
    result = int_to_note[index]
    prediction_output.append(result)

    pattern.append(index)
    pattern = pattern[1:len(pattern)]
複製程式碼

我們選擇使用網路去創作 500 個音符是因為這大約是兩分鐘的音樂,而且給了網路充足的空間去創造旋律。想要製作任何一個音符我們都必須給網路提交一個序列。我們提交的第一個序列是開始位置的音符序列。對於我們用作輸入的每個後續序列,我們將刪除序列的第一個音符,並在序列末尾插入上一個迭代的輸出,如圖 2 所示。

圖 2: 第一個輸入列是 ABCDE。把它入網路得到的輸出是 F。對於下一次的迭代,我們把 A 從列表裡移除,並把 F 追加進去。然後重複這步驟。

圖 2:第一個輸入列是 ABCDE。我們依靠網路從流裡得到的輸出是 F。對於下一次的迭代,我們把 A 從列表裡移除,並把 F 追加進去。然後重複這步驟。

為了從網路的輸出中確定出最準確的預測,我們抽取了值最大的索引。輸出匯陣列中,索引為 X 的列可能對應於下一個音符的 X。圖三幫助解釋這個。

圖 3: 我們看到在一個從網路到類的輸出預測的對映。正如我們看到的,下一個值最可能是 D,因此我們選擇 D 最為最可能的類。

圖 3:我們看到在一個從網路到類的輸出預測的對映。正如我們看到的,下一個值最可能是 D,因此我們選擇 D 為最可能的音高集合。

之後我們把網路的所有輸出蒐集,放到一個單一陣列中。

既然我們有了陣列中所有的音符與和絃的編碼,我們可以開始解碼它們並且創造一個音符與和絃物件的陣列。

首先必須確定我們解碼後的輸出是音符還是和絃。

如果模式是和絃,我們必須將音符串拆分成一組音符。然後我們迴圈遍歷每個音符的字串表示,併為每個音符建立一個音符物件。然後我們可以建立一個包含每個音符的和絃物件。

如果輸出是一個音符,我們使用模式中包含的音高字串表示建立一個音符物件。

在每次迭代的結尾我們增加 0.5 的偏移時間並且把音符/和絃物件追加到一個列表中。

offset = 0
output_notes = []

# 基於模型生成的值來建立音符與和絃

for pattern in prediction_output:
    # 輸出是和絃
    if ('.' in pattern) or pattern.isdigit():
        notes_in_chord = pattern.split('.')
        notes = []
        for current_note in notes_in_chord:
            new_note = note.Note(int(current_note))
            new_note.storedInstrument = instrument.Piano()
            notes.append(new_note)
        new_chord = chord.Chord(notes)
        new_chord.offset = offset
        output_notes.append(new_chord)
    # 輸出是音符
    else:
        new_note = note.Note(pattern)
        new_note.offset = offset
        new_note.storedInstrument = instrument.Piano()
        output_notes.append(new_note)

    # 增加每次迭代的偏移量使音符不會堆疊
    offset += 0.5
複製程式碼

在用網路創造音符與和絃的列表之後,我們可以使用這個列表創造一個 Music21 流物件,它使用此列表作為一個引數。最後,為了建立包含網路生成的音樂的 MIDI 檔案,我們使用 Music21 工具包中的 write 函式將流寫入檔案中。

midi_stream = stream.Stream(output_notes)

midi_stream.write('midi', fp='test_output.mid')
複製程式碼

結果

現在是見證奇蹟的時刻。圖 4 包含了一頁通過 LSTM 神經網路創作的音樂樂譜。瞅一眼就能看到它的結構,這在第二頁的第三行到最後一行尤為明顯。

有音樂常識,能閱讀樂譜的人呢可以看到在這一頁裡有一些奇怪的音符。這就是網路不能創作完美的旋律的結果。在我們目前的成果裡將總會有一些錯誤的音符。如果想獲得更好的結果我們得有更大的網路才行。

圖 4:通過 LSTM 網路生成的樂譜

圖 4:通過 LSTM 網路生成的樂譜

這個相對較淺的網路的結果仍然令人印象深刻,從示例音樂中可以聽到。對於那些感興趣的人來說,圖4中的樂譜代表了神經網路創作音樂邁出了一大步。

w.soundcloud.com/player/?ref…

未來的工作

我們用一個簡單的 LSTM 網路和 352 個音高實現了這個非凡的成果。不過,有一些地方還有待提高。

首先,目前實現的結果不支援音符的多種音長和音符間的偏移。我們要為新增為不同音長服務的音高和代表音符停頓時間的音調。

為了通過增加音調來獲得滿意的結果我們也必須增加 LSTM 網路的深度,這需要效能更高的計算機去完成。我自用的膝上型電腦大約需要兩個小時去訓練網路。

第二,為樂章增加前奏和結尾。現在網路在兩個樂章之間沒有間隔,網路不知道一個章節的結尾和另一個的開始在哪裡。這允許網路從前奏到結束地創作一個章節而不是像現在這樣突然的結束創作。

第三,增加一個方法去處理未知的音符。目前的情況是如果網路遇到一個它不認識的音符,它就會返回狀態失敗。解決這個方法的可能方案是去尋找一個和未知音符最相似的音符或者和絃。

最後,為資料集增加更多的樂器(的音樂)。現在網路僅僅支援只有一種單一樂器的作品。如果可以擴充套件到一整個管絃樂隊那將會是非常有趣的。

結語

在本教程中我們演示瞭如何建立一個 LSTM 神經網路去創作音樂。也許這個結果不盡如人意,但它們還是讓人印象深刻。而且它向我們展示了,神經網路可以創作音樂並且可以被用來幫助人們創作更復雜的音樂作品。

在 GitHub 倉庫檢視本教程

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章