この世に存在しない交響曲の楽曲解説をつくろう(形態素解析とマルコフ連鎖で)
はじめに
執筆者:あおじい
クラシックの演奏会で、必ずと言っていいほど配られるパンフレット。
その中で、楽団紹介や演者の経歴といった情報に混じって、演奏する楽曲を解説しているページがあるのを、皆さんも一度は見たことがあるのではないでしょうか。
あれって作る側になると分かるんですが……楽曲解説を書くのは超〜〜〜〜〜〜めんどくさいんです。
主題がどの楽器で回されているだとか、このフレーズは何をモチーフに作られているかだとか……とにかく緻密にスコアを読み解くところからスタートしなくてはいけません。作曲者を取り巻く状況や時代背景を紐付けないと、説明できない箇所もあるでしょう。
加えて、情報の裏取りや自己の解釈を交えた考察、日本語の校正まで必要です。全体の作業量は、膨れ上がってしまいます。
伝統を重んじるクラシック界隈とはいえ、これではIoTの手も借りたい気分です。
せめて、この世にはびこる楽曲解説によく出てくるワードをかき集めてくれて、ボキャブラリーの助けになるツールがあればな〜〜!!
てかいっそのこと、いい感じのことばを適当に組み合わせて、楽曲解説っぽい文章がいつの間にか勝手に出来上がってくれればいいのにな〜〜〜〜〜!!!!!!!!!!!!!
やること
Wikipediaの楽曲解説を解析しよう
単語のランキングを生成しよう
架空の楽曲解説を生成しよう
1. Wikipediaの楽曲解説を解析しよう
まずは、世界最大のインターネット情報リソースであるWikipediaから、いろんな交響曲の楽曲情報を取得しましょう。
例として、「交響曲第10番 (ショスタコーヴィチ)」のWikipediaページを開いてみます。すると、記事全体が次のようなセクション群で構成されていることが分かります。
概要
作曲の経緯
作品の解釈
初演
曲の構成
楽器編成
順番など細かい差異はあれど、他の交響曲のページも大体こんな感じです。楽曲解説に盛り込みたいセクションは、この中でも「曲の概要」とか「作曲の経緯」とか「楽章構成」とか、あたりではないでしょうか。
今回は、「曲の構成」のセクションからテキストを抽出することにします。調性とか形式とか主題とかを解説しているアレですね。
どこから記事を取る?
とはいえ、1記事だけを解析しても、情報がぜんぜん足りません。
Wikipediaという超ビッグな情報源を最大限に活かし、解析の対象を広げたいですね。
そこで、「交響曲の一覧」というWikipediaページの出番です。ここから内部リンクを辿れば、たいていは交響曲に関する個別記事に飛ぶことが出来るでしょう。
遷移先の中には、楽曲構成のセクションが設けられていないものや、ページそのものが作られていないもの(いわゆる赤リンク)なども含まれています。が、得られるデータの膨大さと比べれば些細な問題です。
そんなこんなで、交響曲505曲分の楽曲構成を抽出しました。
素のテキストなのに、1MBに近いサイズがあります。初代ゲームボーイのソフト容量くらいです。大変ワクワクしますね。
2. 単語のランキングを生成しよう
楽曲解説を書くときに、「おんなじ単語ばっかり使ってしまいがち」という悩みは、たびたび発生するものです。
例として、ショスタコーヴィチ「祝典序曲」を題材に、簡単な楽曲解説の冒頭を作ってみました。
最悪ですね!ゲシュタルト崩壊待ったなしです。
もちろん、この楽曲解説が全くの嘘というわけでもありません。
ただ、楽曲解説を作るからには、そこそこ面白い読み物として提供されたいものです。似たことばを別の言い回しにしたり、よりクリティカルな表現に変換するために、相応のボキャブラリーを求められることになります。
読書嫌いには辛い展開です。
「こんなときに、楽曲解説によく出てくるワードをかき集めてくれて、ボキャブラリーの助けになるツールがあればな〜〜!!」
そんなこんなで、分析しましたよ。
楽曲解説(505曲分)における頻出単語ランキング、品詞別TOP100を。
🏅【動詞】頻出単語ランキング🏅
🥇 第1位 🎉 現れる 🎉(411回)
🥈 第2位 🎉 始まる 🎉(298回)
🥉 第3位 🎉 続く 🎉(246回)
🏅 第4位 奏す(234回)
🏅 第5位 入る(220回)
🏅【名詞】頻出単語ランキング🏅
🥇 第1位 🎉 主題 🎉(2,883回)
🥈 第2位 🎉 部 🎉(1,828回)
🥉 第3位 🎉 拍子 🎉(1,440回)
🏅 第4位 長調(1,217回)
🏅 第5位 形式(1,168回)
(多少、Wikipedia特有の謎の語句が混ざっているのはご容赦を🤫)
🏅【形容詞】頻出単語ランキング🏅
🥇 第1位 🎉 短い 🎉(165回)
🥈 第2位 🎉 ない 🎉(149回)
🥉 第3位 🎉 長い 🎉(102回)
🏅 第4位 力強い(94回)
🏅 第5位 明るい(78回)
🏅【副詞】頻出単語ランキング🏅
🥇 第1位 🎉 再び 🎉(146回)
🥈 第2位 🎉 やがて 🎉(118回)
🥉 第3位 🎉 次第に 🎉(79回)
🏅 第4位 そのまま(77回)
🏅 第5位 すぐ(74回)
使用例
特に動詞・形容詞・副詞に関しては、上記はかなり有益なデータなのではないでしょうか?
例えば、「『演奏する』という動詞を、文章の中に使いすぎちゃったな〜…」なんてときは、ランキングの出番です。
ニュアンスの似ている単語をピックアップして、差し替えちゃいましょう。
現れる
奏す、奏する、奏でる
歌う、刻む、描く
築く
これらは割と、「演奏する」と気軽に置き換えることができそうです。
前後のフレーズとの関係性や、曲調などにフォーカスした具体性を持つ単語であれば、さらに
続く、転じる
入る、加わる、伴う
盛り上がる
なども、視野に入ると思います。
改めて、最初に作った楽曲解説を見てみます。重複して使用されている語彙を、ランキングに掲載されている言葉に置き換えると、どうなるでしょう?
↓
比べ物にならないほど、立派な曲紹介になりましたね!
コスパ最強。
3. 架空の楽曲解説を生成しよう
語彙は補完できるようになったものの、言葉を入れ替える前の解説文そのものは、やはり自分で用意しなくてはなりません。
とはいえ、Wikipediaや知らない人のブログから解説を丸パクリ…というのも、もしバレてしまったときに世間の目が怖いです。
筆者は怠けの権化ですので、更に楽をしたいと思ってしまいました。
よくある言葉をいい感じに組み合わせて、楽曲解説っぽい文章がいつの間にか勝手に出来上がってくれればいいのにな〜〜〜〜〜!!!!!!!!!!!!!
マルコフ連鎖とは
確率論・統計学の用語ですが、今回は自然言語処理の分野での用いられ方に限定します。
突然ですが、クイズです。
Q. 「今日 / の / 天気 / は / 」に続くことばとして、適切なものはどれ?
1. 晴れ
2. トルネード
3. ハンバーグ
当たり前ですが、「1」を選ぶ人が多いと思います。
次点で可能性があるなら「2」でしょうが、「3」はよっぽどのことが無い限りあり得ないでしょう。
でも、「なぜ?」と訊かれると案外不思議なものです。
どれを選んでも、日本語の文法としては破綻していません。意味の上でも、(「3」はともかく)「1」も「2」も天候を表す名詞ですから、入らない理屈はないはずです。
また、もし仮に問題文の1単語のみが置き換わって、「今日 / の / 災害 / は / 」「今日 / の / 夕食 / は / 」になった途端、答えは大きく変わってくると思います。
なぜ、このように答えを判断できるのでしょうか?
それは、学習を通して次の単語を予測することができるからです。皆さんが「お天気ニュース」や「普段の日常生活」といった文章の学習源を元に、「この言葉の次はこれが来やすいんじゃね?」という遷移確率を推測していることに他なりません。
これこそが、マルコフ連鎖を用いた文章生成の基本です。学習元の文章において、特定の単語(群)の次に現れやすい言葉の確率を計算し、その予測パターンをモデル化します。
楽曲解説においても、同様のことが言えます。
「第1」と言われれば、「番」「楽章」や「主題」などが来そうです。
「セルゲイ」と言われれば、「・プロコフィエフ」か「・ラフマニノフ」でしょう。
もし曲紹介の文頭で、「第2楽章」とだけ書かれていれば、「Allegro 変ロ長調 2/4拍子」みたく、楽章の情報が続きそうな気がしますよね。
マルコフ連鎖は文脈を考慮せず、あくまで直前の単語のみに依存した文の生成を行います。そのため、深層学習モデルを使用した高度なAIのモデリングより、文の品質が劣る側面も見られます。
ですが、単純な文法ルールの再現で事足りるのであれば、少ない計算リソースでモデルを作ることができるマルコフ連鎖は最適です。
できたもの
そんなこんなで、作りましたよ。
楽曲解説(505曲分)における日本語の形態素解析データと、マルコフ連鎖による自然言語文章生成学習モデルを。
百聞は一見に如かず、まずはモデルから自動で出力された短文をば。
なんか、「っぽさ」ありません?
楽曲解説「っぽさ」。
厳密に読み解くと、日本語としては何も成り立っていないです。が、パンフレットの片隅で流して読んでしまう(※)と、「ふーん」と納得できそうな謎のオーラはあります。
(※良くないですよ!皆さんホントに頑張って書いてるので、実際の演奏会ではちゃんと読んであげてくださいね。)
あとはこのモデルを用いて、第{n}楽章 (ただしnは任意の3とか4とか5くらいまでの自然数)から始まる短文を用意すれば、存在しない楽曲解説の完成です!!
おもしろ楽曲解説たち
期せず誕生してしまった、楽曲解説っぽい何かをいくつかご紹介します。
何も伝わってこない
楽章1つでカップ麺1個しか作れない
数十年単位で続くトリル
トイザらスで演奏してる?
無礼千万
ほか、
といった怪文たちが生まれました。
こういった偶然の産物も、長い文脈を一切無視して文章を生成する、マルコフ連鎖ならではの面白さと言えますね。
おわりに
Wikipediaに収録されている交響曲のページから、楽曲解説のデータを抽出した結果、品詞別の頻出単語ランキングとマルコフ連鎖の文章生成モデルをつくることができました。
ここまで作っておいてなんですが、文章モデルはぶっちゃけ実用に値しないです。流し読みされる前提の低クオリティで、楽曲解説を書く意味も分かりませんからね。
でも、単語と単語のつなぎ方を学んだり、文章構成の大枠を引くくらいであれば、参考にはなるかも分かりません。
個人的にはむしろ、よく用いられる単語を一覧化できたことのほうが収穫でした。
単に「この言葉を言い換えたい!」なんてときの材料としても重宝できそうです。ただそれよりも、音楽の調子や流れを表すボキャブラリーの繊細さ、豊富さに圧倒されたのが本音です。
音楽は、聴いたときの受け取り方も、その表し方も千差万別です。ご自身の言葉ではどうしても紡ぎきれない、鮮やかな感動を味わうこともあるかもしれません。
そんな情景を言語化したいとき、本記事が助けになれば幸いです。
………。
こっちのが良くね?
(おわり)
補遺:技術的解説
筆者はPython初学者につき、コードの粗はご容赦ください。
1. Wikipediaの楽曲解説を解析しよう
Wikipediaはクローラを用いたデータの収集を禁止している代わりに、データベースのダンプデータやAPIを提供しています。
今回は、MediaWiki APIを元に設計されたPython用のAPIライブラリ「Wikipedia」を使用して、記事を取得します。HTMLの解析には「Beautiful Soup 4」を用います。
pip install wikipedia beautifulsoup4
モジュールをインポートします。reは正規表現操作のモジュールです。
import wikipedia
from bs4 import BeautifulSoup as bs4
import re
「交響曲のタイトルを渡すと、該当するWikipediaのページから『楽曲構成っぽいセクション』のテキストを取得してくれる」関数を定義します。
まずは、ページ内のh2タグ(=セクションの見出し)を全て取得します。続いて、その中に楽曲の構成にまつわる見出しがあれば(厳密には、h2の子要素に「構成」という文字列を含むidのspanタグがあれば)、次に出現するh2(=「構成」の次のセクションに移り変わる箇所)までの兄弟要素をスキャンします。そして、h2見出し間にある平文を逐次取得します。
def get_forming(title):
try:
# 引数と一致するタイトルのwikipediaページを取得
single_page = wikipedia.page(title = title, auto_suggest = False, redirect = False)
if single_page is None:
print(title + " ... ⚠️Skipped: Page not found.⚠️")
return None
# ページ全体のHTML情報を取得
soup = bs4(single_page.html(), 'lxml')
# 不要なタグ・クラスに該当する要素を削除
for tag in soup.find_all(True, ["audio", "img"]):
tag.decompose()
for class_ in soup.find_all(True, {"class": ["noprint", "mw-editsection", "infobox", "reference"]}):
class_.decompose()
# 「構成」「曲の構成」「楽曲構成」などに該当するh2見出しを検索
headings = soup.find_all("h2")
for heading in headings:
heading_inner = heading.find("span", id = re.compile(r".*構成.*"))
if heading_inner:
break
else:
return None
# 「構成」セクションのテキストを格納
target_heading = heading_inner.parent
next_heading = target_heading.find_next_sibling("h2")
text_list = []
current_element = target_heading
while current_element.next_sibling != next_heading:
current_element = current_element.next_sibling
if current_element != "\n" and current_element.text.strip() != "":
text_list.append(current_element.text.strip())
# 不要な記号を削除し、1文に連結して返却
text_line = " ".join(text_list)
text_line = re.sub(r"\n|\r", "", text_line)
text_line = re.sub(r"\u200b|\u3000", " ", text_line)
if text_line is not None:
return text_line
# エラーハンドリング
# PageError: タイトル不一致のエラー。赤リンクで作成されていない記事とか
# DisambiguationError: 曖昧さ回避ページに飛んだエラー
# RedirectError: リダイレクトのエラー
except (wikipedia.exceptions.PageError, wikipedia.exceptions.DisambiguationError, wikipedia.exceptions.RedirectError):
return None
交響曲のタイトルは、Wikipedia「交響曲の一覧」ページに含まれる、サイト内の別記事へのリンクを元に取得しています。この中には、赤リンク(記事タイトルは設定されているもののページとしての実体が無い)や、曖昧さ回避(項目名が重複したり曖昧だったりした場合、転送先をユーザーに選択させるページ)も含まれています。
このように、目的以外のページや実在しないページも、解析の対象に含んでしまいます。ですが、正常に情報を取得できる件数も充分に多いですから、ほとんど問題になりません。
wikipedia.set_lang("ja")
list_page = wikipedia.page(title = "交響曲の一覧")
forming_list = []
count = 0
# 「交響曲の一覧」ページから飛べる別記事(=だいたいは交響曲単独のページ)のタイトルを取得
titles = list(set(list_page.links))
total_titles = len(titles)
for i, title in enumerate(titles):
print(f"Processing {i + 1}/{total_titles} titles... ({title})")
single_forming = get_forming(title)
if single_forming is not None:
forming_list.append(single_forming + "\n")
count += 1
print("🎉🎉🎉 Done! 🎉🎉🎉")
print(f"Succeeded: {count}/{total_titles}")
with open("forming.txt", "w", encoding="utf-8", newline="\n") as f:
f.writelines(forming_list)
「交響曲の一覧」ページから取得できた記事リンクは、1213件です。
この中から最終的に、「構成」に類似する見出しのセクションを取得できた記事の数は、執筆時点で505件でした。
2. 単語のランキングを生成しよう
日本語の形態素解析エンジン「MeCab」を利用し、取得したテキストを解析します。使用する分かち書き辞書は、MeCabでも推奨されている「IPAdic」です。
pip install mecab-python3 ipadic
インポートします。
また、先ほど出力したtxtファイルを学習源とするので、filesモジュールで予めアップロードしておきます。
import MeCab
import ipadic
import collections
from google.colab import files
uploaded = files.upload()
forming_list = open(list(uploaded.keys())[0], "r").readlines()
品詞別に辞書を作ります。形態素の原形や品詞の種別を解析し、既存の語句に一致するものはカウントを増やします。一致しない場合は、新規のkeyとして登録します。
Counterは、collectionsモジュールに組み込まれている大変優秀なオブジェクトです。dictやtupleを用いて自力でデータ構造を実装せずとも、要素ごとの出現回数を含んだ辞書のコンテナを生成してくれます。
def get_hinshi_dic(text):
tagger = MeCab.Tagger(ipadic.MECAB_ARGS)
node = tagger.parseToNode(text)
dic_hinshi = {
"動詞": collections.Counter(),
"名詞": collections.Counter(),
"形容詞": collections.Counter(),
"副詞": collections.Counter()
}
while node:
splitted = node.feature.split(",")
hinshi = splitted[0] # 品詞の種類
genkei = splitted[6] # 原形
hyoso = node.surface # 表層形。文字列中のそのままの形。
if hinshi in dic_hinshi:
if hinshi == "名詞":
dic_hinshi[hinshi][hyoso] += 1
else:
dic_hinshi[hinshi][genkei] += 1
node = node.next
return dic_hinshi
品詞ごとに辞書のランキングを出力します。ランキングに載せるだけしょうもないkeyは、予めリストで定義しています。辞書と照らし合わせて合致した語句は、辞書から削除します。
ランキングの生成は非常にシンプルで、most_commonメソッドを呼び出すだけです。keyと出現回数のtupleを出現頻度の高い順に並べ、リストとして返却してくれます。マジで優秀過ぎてエグいです。
forming_full_text = " ".join(forming_list)
dic_hinshi = get_hinshi_dic(forming_full_text)
print("品詞別ランキング")
print("")
print("- - - - - - - - - - - - - - -")
print("")
# あんまり参考にならない単語を結果から省くリスト
del_keys_doushi = ["する", "いる", "ある", "なる", "られる", "れる"]
for key in del_keys_doushi:
dic_hinshi["動詞"].pop(key, None)
c_doushi = collections.Counter(dic_hinshi["動詞"])
print("動詞ランキング:")
print(c_doushi.most_common(20))
del_keys_meishi = ["1", "2", "3", "4", "一", "二", "三", "四", "楽章"]
for key in del_keys_meishi:
dic_hinshi["名詞"].pop(key, None)
c_meishi = collections.Counter(dic_hinshi["名詞"])
print("名詞ランキング:")
print(c_meishi.most_common(20))
del_keys_keiyoushi = []
for key in del_keys_keiyoushi:
dic_hinshi["形容詞"].pop(key, None)
c_keiyoushi = collections.Counter(dic_hinshi["形容詞"])
print("形容詞ランキング:")
print(c_keiyoushi.most_common(20))
del_keys_fukushi = []
for key in del_keys_fukushi:
dic_hinshi["副詞"].pop(key, None)
c_fukushi = collections.Counter(dic_hinshi["副詞"])
print("副詞ランキング:")
print(c_fukushi.most_common(20))
3. 架空の楽曲解説を生成しよう
マルコフ連鎖の文章ジェネレーターである「markovify」ライブラリを使用します。
pip install markovify
import markovify
辞書の作成にも用いたMeCabを用いて、テキストデータを「分かち書き」します。
日本語は英語などとは異なり、形態素の間がブランクで明確に分かれているわけではありません。ですからマルコフ連鎖の重み付けに先立って、まずはことばの分割ポイントを解析し、区切りを記号に置き換える処理が必要になります。これが、「分かち書き」です。
↓
ただ、いざテキストを出力する時に分かち書きされたままだと、日本語として読みにくいことこの上ないです。
そのため出力時には、英数字間を除いた文字どうしの間に区切り文字を残さないよう、正規表現による置換パターンを定義しています。
def get_wakatied_list(forming_list):
wakati = MeCab.Tagger(ipadic.MECAB_ARGS + " -Owakati")
wakatied_list = []
for forming_text in forming_list:
wakatied_forming = wakati.parse(forming_text)
wakatied_list.append(wakatied_forming)
return wakatied_list
def format_text(text):
text = re.sub(r"((?<=[^a-zA-Z0-9])\s(?=\w))|((?<=\w)\s(?=[^a-zA-Z0-9]))", "", text)
return text
早速、マルコフ連鎖でテキストを生成します。
以下は、特別な制限なしに、とりあえずテキストを生成してみる処理です。
state_size = 4
wakatied_list = get_wakatied_list(forming_list)
text_model = markovify.NewlineText(wakatied_list, state_size=state_size, well_formed=False)
for i in range(10):
try:
sentence = text_model.make_sentence()
if sentence:
sentence = format_text(sentence)
print(sentence)
except markovify.text.ParamError:
pass
else:
pass
state_sizeは、単語を生成する際に遡って参照する単語の連結数です。N階マルコフ連鎖の、「N」の部分にあたります。
この値が小さくなればなるほど、語句どうしの依存関係が希薄になります。文章に自由度が生まれる一方で、文脈は薄れます。
逆に、値が大きいと生成の幅は制限され、より意味理解が容易な高クオリティのテキストに近付きます。その反面、文意の自由度や、そもそも正常に生成できる確率は下がります。
たとえばstate_sizeが4のとき、筆者の環境では下記のようなテキストが5〜7個ほど出力されることが多かったです。正常であれば10個出力されるはずなので、成功確率は概ね6割といったところです。
文字数の上限を設定したいときは、make_short_sentenceメソッドを用います。
たとえば、TwitterことXに投稿できる文字数に収めたければ、引数に140を渡します。
state_size = 4
wakatied_list = get_wakatied_list(forming_list)
text_model = markovify.NewlineText(wakatied_list, state_size=state_size, well_formed=False)
for i in range(10):
try:
sentence = text_model.make_short_sentence(140)
if sentence:
sentence = format_text(sentence)
print(sentence)
except markovify.text.ParamError:
pass
else:
pass
また、make_sentence_with_startメソッドを使うことで、特定の語群から始まる文章を出力することも可能です。
以下は「第◯楽章」から始まる文章を、第1楽章から第4楽章までの合計4つ分、出力する処理です。文章にボリュームを確保したかったので、100回の試行の中で300文字に達した場合、for文をbreakするようにしています。
state_size = 3
wakatied_list = get_wakatied_list(forming_list)
text_model = markovify.NewlineText(wakatied_list, state_size=state_size, well_formed=False)
for i in range(4):
for j in range(100):
try:
sentence = text_model.make_sentence_with_start(beginning=f"第 {i+1} 楽章", strict=False)
if sentence and len(sentence) > 300:
sentence = format_text(sentence)
print(sentence)
break
except markovify.text.ParamError:
pass
else:
pass
「第2楽章」から始まる文章の例です。state_sizeは甘めに3で設定したので、日本語としての流暢さはやや失われています。
なお、実はこの方法だと、実際のアウトプットが300文字に満たない場合もあります。前述の「分かち書き」の区切りスペースを含めて300文字を超えればbreakする仕組みであるために、区切り文字を削除して連結した出力文は、元々よりも短くなってしまうからです。
こればかりは仕方ないので、理想のテキストが出力されるまで回転数を上げて生成し続けるしかなさそうです。
元も子もないけど
「マルコフ連鎖ガチャ」のリセマラに頼り続けるくらいなら、素直にGPTに任せるのが賢明だと思います。
この記事が気に入ったらサポートをしてみませんか?