「魚の名前で小さな恋のうた」生成器作ってみた
概略
Twitter 上で「魚の名前で小さな恋のうた」が話題になっていました.
これは面白い.本当に面白い.ということで作ってみました.
歌詞(日本語) を入れる入力すると,発音がそれっぽい魚の名前に変換してくれるスクリプトです.
「小さな恋のうた」以外でも何でも変換できます.
結果
広い -> シロウ
宇宙の -> ウバウオ
数 -> アユ
ある -> アユ
ひとつ -> イトウ
青い -> サヨリ
地球の -> シラウオ
広い -> シロウ
世界で -> メカジキ
小さな -> チンアナゴ
恋の -> コイチ
思いは -> コモンハタ
届く -> ドジョウ
小さな -> チンアナゴ
島の -> シシャモ
あなたの -> アカハナ
もとへ -> オオセ
完全版は下の方に乗せておきます.
構成
歌詞ファイル(.txt)から歌詞を分節に分解して,ローマ字化します.
似てる発音となる魚名は事前に用意した辞書ファイル(.csv)から探します.
文節に分割した歌詞と魚の名前の,ローマ字のレーベンシュタイン距離が最小のものを探します.
それらをつなげて完成です.簡単.
def main():
# read lyrics
if(len(sys.argv) <= 1):
print("Specify a lyrics file")
sys.exit(1)
lyrics_file_name = sys.argv[1]
lyrics_lines = []
with open(lyrics_file_name, "r") as f:
lyrics_lines = f.readlines()
# chunknize
chunknized_lyrics = []
for lyrics_line in lyrics_lines:
chunknized_lyrics += chunknize(lyrics_line)
# map to romaji
romaji_lyrics = [romajinize(chunk) for chunk in chunknized_lyrics]
# search fish
fishnized_lyrics = [fishnize(romaji_chunk)
for romaji_chunk in romaji_lyrics]
# print result
for i in range(len(fishnized_lyrics)):
print(
"{} \t-> {} \t ".format(chunknized_lyrics[i], fishnized_lyrics[i]))
魚の辞書の用意
まず,魚の名前の辞書を用意します.
こちらのサイトを参考にさせていただきました.
WEB 魚図鑑 和名一覧
スクレイピングして魚名だけ取り出します.
こちらの際は単純に魚名だけではなく「アオダイ属未同定種」といったように
名前以外の種に関する記述が入っている場合があるのでそれは取り除きます.
各魚名に関してローマ字変換したものも合わせて csv に保存しておきます.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import requests
from bs4 import BeautifulSoup
from pykakasi import kakasi
kakasi = kakasi()
kakasi.setMode('H', 'a')
kakasi.setMode('K', 'a')
kakasi.setMode('J', 'a')
conv = kakasi.getConverter()
URL = "https://zukan.com/fish/level5"
res = requests.get(URL)
soup = BeautifulSoup(res.text, 'html.parser')
soup = soup.select(
"#alphabetal_order a")
with open('fish_name_list.csv', 'w') as f:
f.write("name,romaji\n") # set header
for s in soup:
t = s.text
t = t if t.find("属") == -1 else t[:t.find("属")]
t = t if t.find("科") == -1 else t[:t.find("科")]
t = t if t.find("群") == -1 else t[:t.find("群")]
t = t if t.find("L") == -1 else t[:t.find("L")]
t = t if t.find("太") == -1 else t[:t.find("太")]
t = t if t.find("日") == -1 else t[:t.find("日")]
t = t if t.find("」") == -1 else t[:t.find("」")]
t = t if t.find("「") == -1 else t[t.find("「")+1:]
if t != "":
f.write(t + ","+conv.do(t)+"\n")
歌詞の文節分割
CaboCha というものを用いました.
CaboCha/南瓜
CaboCha はまともなドキュメントがなく大変でした.
感謝して読みました.
CaboCha/南瓜 Python Document (CaboCha.py)
どうやら分節ごと区切れるけど,そのまま出力する機能はない?ようなので,
一つづつチャンク(文節)を読み込んでいって
token_size(含まれている単語の数)分だけ単語を読んで足します.
chunk の先頭の場所を表すのに chunk.token_pos だったり,単語の文字自体を
出すのが token.surface だったりとよくわかりませんでした.なんとかやっつけました.
for i in range(t.chunk_size()):
chunk = ""
token_pos = t.chunk(i).token_pos
for j in range(t.chunk(i).token_size):
chunk += t.token(token_pos+j).surface
chunknized_string.append(chunk)
全体的にはこんなふうになりました.
def chunknize(string):
chunknized_string = []
c = CaboCha.Parser()
t = c.parse(string)
for i in range(t.chunk_size()):
chunk = ""
token_pos = t.chunk(i).token_pos
for j in range(t.chunk(i).token_size):
chunk += t.token(token_pos+j).surface
chunknized_string.append(chunk)
return chunknized_string
CaboCha はハマりポイントが 2 つあって
環境が /usr/local/lib にパスが通っていなかったため Not Found のエラーが出続けていました.
また,CaboCha の前に MeCab をインストールする際に辞書を UTF-8 でやっておかないと辞書関連でエラーが出ます.(詳しく覚えてなくてすみません.)
適用部分はこのようになっています.
# read lyrics
if(len(sys.argv) <= 1):
print("Specify a lyrics file")
sys.exit(1)
lyrics_file_name = sys.argv[1]
lyrics_lines = []
with open(lyrics_file_name, "r") as f:
lyrics_lines = f.readlines()
# chunknize
chunknized_lyrics = []
for lyrics_line in lyrics_lines:
chunknized_lyrics += chunknize(lyrics_line)
lyrics_file を直接文節に区切るのではなく一度 readline()してから分節化したのには理由があります.
歌詞は文法に則った正確な日本語ではないため,自立語と付属語の区別に失敗し,上手にパースされないことがあります.
「小さな恋のうた」を例に上げると,
ただ あなたにだけ届いて欲しい 響け恋の歌
ほら
ほら
ほら
響け恋の歌
この部分の「ほら」が正確に解釈されなかったりしました.
一行ごと分節化することで「ほら」のみ分節化対象にできるのでこの問題が解決できます.
文節のローマ字化
pykakasi というものを用います.
pip を使うとエラーが出るらしいので使わず自分でビルドしました.
参考 漢字をローマ字に変換できる Python ライブラリ "pykakasi" を使ってみた。
kakasi = pykakasi.kakasi()
kakasi.setMode('H', 'a')
kakasi.setMode('K', 'a')
kakasi.setMode('J', 'a')
conv = kakasi.getConverter()
こんなものを定義しておいて次のように用意すれば良いです.
def romajinize(string):
return conv.do(string)
適用部分はこのようになっています.
# map to romaji
romaji_lyrics = [romajinize(chunk) for chunk in chunknized_lyrics]
検索
一番中核なところ.ローマ字の文節とローマ字の魚名のレーベンシュタイン距離をもとに
文節を魚名に変換します.
レーベンシュタイン距離
レーベンシュタイン距離は名前だけすごそうですが,定義自体は難しくありません.
何文字変えればその文字になりますか?というものです.
小さいほどその文字は似ているということになります.
詳しくはこちら
編集距離(レーベンシュタイン距離)を理解し、実装する
Levenshtein というライブラリがあるので import して,
Levenshtein.distance(str1, str2)
で,求めることができます.
今回のの実装では n_levenshtein_distance_with_threshold 関数内部で使用しています.
n(正規化した)_levenshtein_distance(レーベンシュタイン距離)_with_threshold(母音数の制限あり)
という実装にしました.
これは本当にいきあたりばったりで実装した部分で余分に複雑なので後ほど解説します.
ともかく,ここで歌詞の文節と魚名の距離を算出しているというところが大切です.
変換部分
文節に対し,各魚のローマ字名との距離をリストに格納します.
その中で最小のものを取り出すようにしています.
index()の挙動上,最短距離であるものが複数ある場合は,辞書内で一番若いものになります.
いちいち全部の魚名に対して距離を計算するのは遅くなってしまうので褒められた方法ではありません.おそらくこれが原因でとても遅いです.
ただ,冗長性とかもない気がするので難しいですが考え中です.
実装はこのようになっています.
def fishnize(string):
fish_table["distance"] = []
for romaji_fish_name in fish_table["romaji"]:
fish_table["distance"].append(
n_levenshtein_distance_with_threshold(romaji_fish_name, string))
index = fish_table["distance"].index(min(fish_table["distance"]))
return fish_table["name"][index]
適用部分はこのようになっています.
# search fish
fishnized_lyrics = [fishnize(romaji_chunk)
for romaji_chunk in romaji_lyrics]
結果
あとは出力するだけです.
# print result
for i in range(len(fishnized_lyrics)):
print(
"{} \t-> {} \t ".format(chunknized_lyrics[i], fishnized_lyrics[i]))
小さな恋の歌全体を変換するとこのようになります.
広い -> シロウ
宇宙の -> ウバウオ
数 -> アユ
ある -> アユ
ひとつ -> イトウ
青い -> サヨリ
地球の -> シラウオ
広い -> シロウ
世界で -> メカジキ
小さな -> チンアナゴ
恋の -> コイチ
思いは -> コモンハタ
届く -> ドジョウ
小さな -> チンアナゴ
島の -> シシャモ
あなたの -> アカハナ
もとへ -> オオセ
あなたと -> ハナタツ
出会い -> ヘダイ
時は -> トミヨ
流れる -> マガレイ
思いを -> ウメイロ
込めた -> コボラ
手紙も -> タマギンポ
増える -> ブリル
いつしか -> イシダイ
二人 -> ニタリ
互いに -> タラキヒ
響く -> チチブ
時に -> トウジン
激しく -> アメギス
時に -> トウジン
切なく -> セトダイ
響くは -> ヒシコバン
遠く -> ポラック
遥かかなたへ -> ホカケアナハゼ
やさしい -> ナガサギ
歌は -> ワタカ
世界を -> メカジキ
変える -> カツオ
ほら -> ヒラ
あなたにとって -> アカタナゴ
大事な -> ハシキンメ
人ほど -> カナド
すぐ -> スギ
そばに -> サバヒー
いるの -> キレンコ
ただ -> アラ
あなたにだけ -> アカタナゴ
届いて欲しい -> ソコイトヨリ
響け -> ヒラメ
恋の -> コイチ
歌 -> スマ
ほら -> ヒラ
ほら -> ヒラ
ほら -> ヒラ
響け -> ヒラメ
恋の -> コイチ
歌 -> スマ
あなたは -> アカタチ
気付く -> スズキ
二人は -> カワビシャ
歩く -> カラス
暗い -> キダイ
道でも -> ギチベラ
日々 -> ギギ
照らす -> カラス
月 -> ダツ
握りしめた -> キビレミシマ
手離す -> カワマス
こと -> コイ
なく -> アユ
思いは -> コモンハタ
強く -> ウツボ
永遠誓う -> キチヌ
永遠の -> キリンミノ
淵きっと -> ウシエイ
僕は -> コクレン
言う -> ギス
思い変わらず -> アメリカナマズ
同じ -> マアジ
言葉を -> コトヒキ
それでも -> オキエソ
足りず -> カラス
涙に -> ナミハタ
変わり -> カンダリ
喜びに -> モロコシハギ
なり -> アラ
言葉に -> コトヒキ
できず -> メギス
ただ -> アラ
抱きしめる -> アカヒメジ
ただ抱きしめる -> タカサゴヒメジ
ほら -> ヒラ
あなたにとって -> アカタナゴ
大事な -> ハシキンメ
人ほど -> カナド
すぐ -> スギ
そばに -> サバヒー
いるの -> キレンコ
ただ -> アラ
あなたにだけ -> アカタナゴ
届いて欲しい -> ソコイトヨリ
響け -> ヒラメ
恋の -> コイチ
歌 -> スマ
ほら -> ヒラ
ほら -> ヒラ
ほら -> ヒラ
響け -> ヒラメ
恋の -> コイチ
歌 -> スマ
夢ならば -> ユメカサゴ
覚めないで -> サメガレイ
夢ならば -> ユメカサゴ
覚めないで -> サメガレイ
あなたと -> ハナタツ
過ごした -> アゴハタ
時 -> コイ
永遠の -> キリンミノ
星と -> シシャモ
なる -> アユ
ほら -> ヒラ
あなたにとって -> アカタナゴ
大事な -> ハシキンメ
人ほど -> カナド
すぐ -> スギ
そばに -> サバヒー
いるの -> キレンコ
ただ -> アラ
あなたにだけ -> アカタナゴ
届いて欲しい -> ソコイトヨリ
響け -> ヒラメ
恋の -> コイチ
歌 -> スマ
ほら -> ヒラ
あなたにとって -> アカタナゴ
大事な -> ハシキンメ
人ほど -> カナド
すぐ -> スギ
そばに -> サバヒー
いるの -> キレンコ
ただ -> アラ
あなたにだけ -> アカタナゴ
届いて欲しい -> ソコイトヨリ
響け -> ヒラメ
恋の -> コイチ
歌 -> スマ
ほら -> ヒラ
ほら -> ヒラ
ほら -> ヒラ
響け -> ヒラメ
恋の -> コイチ
歌 -> スマ
いかがでしょうか?
コード全体は以下のようになりました.
もしかしたら GitHub 上のものは書き換えるかも,というか書き換えたい.
import sys
import pandas as pd
import CaboCha
import pykakasi
import Levenshtein
import re
def romajinize(string):
return conv.do(string)
def normalized_levenshtein_distance(str1, str2):
max_len = max([len(str1), len(str2)])
return Levenshtein.distance(str1, str2)/max_len
def count_vowels(string):
n_vowel = 0
for vowel in ["a", "i", "u", "e", "o"]:
n_vowel += len(re.findall(vowel, string))
return n_vowel
def n_levenshtein_distance_with_threshold(str1, str2, threshold=0):
n_str1_vowel = count_vowels(str1)
n_str2_vowel = count_vowels(str2)
return normalized_levenshtein_distance(str1, str2) if n_str2_vowel >= 5 or abs(n_str1_vowel - n_str2_vowel) <= threshold else 100
def fishnize(string):
fish_table["distance"] = []
for romaji_fish_name in fish_table["romaji"]:
fish_table["distance"].append(
n_levenshtein_distance_with_threshold(romaji_fish_name, string))
index = fish_table["distance"].index(min(fish_table["distance"]))
return fish_table["name"][index]
def chunknize(string):
chunknized_string = []
c = CaboCha.Parser()
t = c.parse(string)
for i in range(t.chunk_size()):
chunk = ""
token_pos = t.chunk(i).token_pos
for j in range(t.chunk(i).token_size):
chunk += t.token(token_pos+j).surface
chunknized_string.append(chunk)
return chunknized_string
def main():
# read lyrics
if(len(sys.argv) <= 1):
print("Specify a lyrics file")
sys.exit(1)
lyrics_file_name = sys.argv[1]
lyrics_lines = []
with open(lyrics_file_name, "r") as f:
lyrics_lines = f.readlines()
# chunknize
chunknized_lyrics = []
for lyrics_line in lyrics_lines:
chunknized_lyrics += chunknize(lyrics_line)
# map to romaji
romaji_lyrics = [romajinize(chunk) for chunk in chunknized_lyrics]
# search fish
fishnized_lyrics = [fishnize(romaji_chunk)
for romaji_chunk in romaji_lyrics]
# print result
for i in range(len(fishnized_lyrics)):
print(
"{} \t-> {} \t ".format(chunknized_lyrics[i], fishnized_lyrics[i]))
conv = None
fish_table = None
if __name__ == "__main__":
kakasi = pykakasi.kakasi()
kakasi.setMode('H', 'a')
kakasi.setMode('K', 'a')
kakasi.setMode('J', 'a')
conv = kakasi.getConverter()
fish_table = {"name": [], "romaji": []}
with open("fish_name_list.csv", "r") as f:
for line in f.readlines()[1:]: # skip the headr line
values = line.split(",")
fish_table["name"].append(values[0])
fish_table["romaji"].append(values[1][:-1]) # remove \n
main()
hoge.py とかで保存して
$python hoge.py lyrics.txt
ってすればどんな歌詞でも変換できます.ぜひ試してみてください.
改善点
改善点もやっぱりあります.
「恋の歌」はひとかたまりとして扱いたいですが,文節として区切ってしまうと別れてしまうので,まとめて扱うものを指定する機能とかあったらいいですね,
歌詞は記号とかを現段階では想定してないので対応したい.
レーベンシュタイン距離よりもよい距離を見つける.
上 2 つはできそうですが 3 つ目は難しいです.
レーベンシュタイン距離よりもよい距離を見つける.
ほら -> ヒラ
友人「ボラじゃないじゃん」
僕「...はい」
確かに,「ヒラ」よりも「ボラ」のほうがチカイ気がします.
レーベンシュタイン距離というのはそもそも何文字編集すればいいか,という数
なので音声ではなくテキストベースの距離だと言えます.
音声的にチカイ単語を検索したい今回では最適とは言い難いです.
英語では Soundex や Metaphone といったスペルから発音に変換する
アルゴリズムが存在しますが,
ざっと探した限りでは日本語ではそれに当たるものが見当たりませんでした.
- IPA
基本的にはこちらの論文を参考にしました.ほぼ読んでいないですが,国際発音記号の IPA の
レーベンシュタイン距離を計測して空耳を作成していました.
しかし,日本語を IPA に変換するツールでポピュラーなものはなさそうなのでこの手法は採用しませんでした.
- カタカナ
そもそもカタカナは表音文字であることを思い出し,レーベンシュタイン距離出やりました.
とてもじゃないですが載せられないくらい制度が悪かったです.
文節は文字数が少ないです.4 文字の場合一文字変えるだけで 25%違う単語になります.
候補が大量に出て,トオイ文字に変換されてしまいました.
(結果残しておけばよかった)
- ローマ字
文字数が少ないことが問題になったので文字数を増やしつつ,発音を表せるローマ字を選択.
割と改善しました.(結果残しておけばよかった)
しかしながら,似てはいるものの次は日本語にした時の文字数が合わなかったりしました.
歌なので文字数が増えてしまうと歌いにくいです.
- 標準化レーベンシュタイン距離
4 文字中の 1 文字変更は影響が大きいですが,10 文字中の 1 文字変更は影響が小さいです.
標準化レーベンシュタイン距離はどのくらいの割合変更すると一方の文字になれるかを表していると言えます.
標準化レーベンシュタイン距離は値の範囲が 0~1 になるので候補の数が減らせるのではないかと期待して導入しました.
こんなふうに実装できます.
def normalized_levenshtein_distance(str1, str2):
max_len = max([len(str1), len(str2)])
return Levenshtein.distance(str1, str2)/max_len
候補 1 と候補 2 の文字数とレーベンシュタイン距離距離が同じ場合,複数の候補になってしまいます.
- 母音の数を考慮に入れてローマ字
現在のスタイルです.歌いづらいのは音節の数が揃っていないからなので,
両者の母音の数が等しいまたは近いもののみ候補に入れて距離を計算します.
文字数が多い場合はちょっとくらいずれていても問題ないので母音が 5 個以上ある場合は
母音数を考慮して距離計算していません.
逆に言えば母音数 4 個以下(カタカナで 4 文字以下くらい)の歌詞は必ず母音数が一致します.
割と良かったです.
実装はこのようになりました.
def count_vowels(string):
n_vowel = 0
for vowel in ["a", "i", "u", "e", "o"]:
n_vowel += len(re.findall(vowel, string))
return n_vowel
def n_levenshtein_distance_with_threshold(str1, str2, threshold=0):
n_str1_vowel = count_vowels(str1)
n_str2_vowel = count_vowels(str2)
return normalized_levenshtein_distance(str1, str2) if n_str2_vowel >= 5 or abs(n_str1_vowel - n_str2_vowel) <= threshold else 100
いい距離の条件
- 音声ベースの計測方法であること.
レーベンシュタイン距離はテキストベースなので変更したいですが,そんなものが存在するのか知らないので
教えください.
- 複数候補が出ることがない
標準化レーベンシュタイン距離は実数なのでバラけることを期待しましたが,
全体的に文字数が似ているので距離が一致してしまうものもありました.
「ヒラ」と「ボラ」など
もっとバラける実数値だと良いんだと思います.
- かぶって変換しない
異なる単語に対しては異なる魚名に変換して欲しいです.
歌詞のリズムが崩れてしまいます.
数 -> アユ
ある -> アユ
見つかったらいいな.
感想
なんか改善点長いですね.
GitHub にあげておいたので改良するかも.
SingFish
でも,できました.意外と楽しくできてよかったです.
届いて欲しい -> ソコイトヨリ
なんて結構お気に入りです.ちょうどよい無理矢理感です.