はじめに
この記事は、以下の記事に感銘を受けて書いたものです。
また、一部実装および記事の内容も参考にさせて頂きました。
要約
入力された単語(ひらがな)に最も似ているMTG(Magic: The Gathering)1のカード名を表示するWebアプリを作成しました。
サンプルWebアプリ(2023/09/05 エルドレインの森 追記)
姉妹アプリ(2023/03/10 追記)
ポケモンの名前に似た単語を探す
遊戯王のカード名に似た単語を探す
2023/02/07追記 みっちー氏ご本人にツイートしていただきました。
みっちー(シンガロンパレード)について
みっちー氏は京都発のインディーズバンド「シンガロンパレード」のボーカルを担当しており、普段はバンド活動で全国を飛び回る傍ら、トレーディングカードゲーム「Magic: The Gathering(マジック・ザ・ギャザリング)」 (以下MTGと呼称)のカード名のみを使って替え歌を歌うという動画をYouTubeに何本も投稿しています。
ご存じない方は、以下の動画を一度見てみてもらえると概念理解してもらえるのではないのかと思います。
- みっちー氏の投稿した動画
今回私は、そのみっちー氏の替え歌をプログラム的に作ることはできないのかと考え、検索するための辞書・データベースを作成しました。
評価方法の実装
みっちー氏の替え歌が元の歌詞とどれだけ近いかについて調査する方法として、文字列の一致具合を比較する方法を用いました。
比較する方法の種類としては、
- レーベンシュタイン距離(Levenshtein Distance)
- ジャロ・ウィンクラー距離(Jaro-Winkler Distance)
- ゲシュタルトパターンマッチング(Gestalt Pattern Matching)
の3種類の方法を用意しました。
実装に当たっては、以下のサイトを参考にしました。
#["ゲシュタルトパターンマッチング","レーベンシュタイン距離","ジャロウィンクラー距離"]の計算部分
import difflib
import Levenshtein
def string_similarity_calculation(str1, str2, mode):
if (mode == "ゲシュタルトパターンマッチング" or "Gestalt" in mode):
rate = difflib.SequenceMatcher(None, str1, str2).ratio()
ans = rate
elif (mode == "レーベンシュタイン距離" or "Leven" in mode):
lev_dist = Levenshtein.distance(str1, str2)
# 標準化(長い方の文字列の長さで割る)
devider = max(len(str1), len(str2))
lev_dist = lev_dist / devider
# 指標を合わせる(0:完全不一致 → 1:完全一致)
lev_dist = 1 - lev_dist
ans = lev_dist
elif (mode == "ジャロウィンクラー距離" or "Jaro" in mode):
jaro_dist = Levenshtein.jaro_winkler(str1, str2)
ans = jaro_dist
return (ans)
#main
print("ゲシュタルトパターンマッチング,レーベンシュタイン距離,ジャロウィンクラー距離")
str1=input(" 1つ目の単語:")
str2=input(" 2つ目の単語:")
mode=input(" 検索モード:")
result=string_similarity_calculation(str1, str2, mode)
print(f"結果:{result}")
実行してみましょう。
1つ目の単語:ぴあならー
2つ目の単語:さよならー
検索モード:ゲシュタルトパターンマッチング
結果:0.6
おお?うまくできたでしょうか?別のパターンを試してみます。
1つ目の単語:らりるれろ
2つ目の単語:まみむめも
検索モード:ゲシュタルトパターンマッチング
結果:0.0
おやおや?まったく一致していないという結果となってしまいました。
両方とも韻を踏んでいるのですが、単純な文字の種類の評価なので、「ら」と「ま」は全く別の文字として認識されているようです。これではみっちー氏のように韻を踏んだ単語を検索することはできません。
上記のように日本語に対して単純な文字列の近似度計算を行おうとすると、
- 単純な文字の比較なので、同音異義語に対応できない
- 韻を踏んでる言葉を比較できない
といった単語を見落とすこととなります。みっちー氏は替え歌の中でも、韻をよく踏んでいるのでそこが全く考慮されないのは問題です。
1つ目の単語:rarirurero
2つ目の単語:mamimumemo
検索モード:ゲシュタルトパターンマッチング
結果:0.5
そのため入力としてはローマ字のように、音を母音成分と子音成分に分解することが望ましいです。Pythonで日本語をローマ字に変換するライブラリとしてはpykakasiが挙げられます。
- pykakasi
https://pypi.org/project/pykakasi/
1つ目の単語:しつけ
2つ目の単語:さぬき
検索モード:ゲシュタルトパターンマッチング
結果:0.0
1つ目の単語:shitsuke
2つ目の単語:sanuki
検索モード:ゲシュタルトパターンマッチング
結果:0.42857142857142855
ただ今回の場合だとヘボン式ローマ字では、文字列長さが変わる(例)「い」と「し」の場合は「i」と「shi」の評価となり、1文字と3文字の評価となってしまい正確な評価ができないと考えました。
そのため、ひらがなを固定長の半角アルファベット+記号化で表現することで、韻を考慮した日本語の単語検索ができるのではないのかと考えました。
1つ目の単語:situke
2つ目の単語:sanuki
検索モード:ゲシュタルトパターンマッチング
結果:0.5
ひらがな->固定長半角アルファベット+記号化
引数にひらがなを入力し、内部で辞書による、置換を行って韻を考慮した日本語の単語検索を行う関数を作成しました。
方式としては、
- 2文字固定:もともとのローマ字にできるだけ近くなるようにする
- 4文字固定:WXYZ W:母音 X:子音 YZ:濁音拗音 表現記号
となる、2種類の日本語標準化関数を作成しました。以下にプログラムを示します。
#ひらがな->アルファベット(半角4文字)化関数
#さとざ が近くなる っとつ、ゃとっ が近く判定される。
def hira_to_roma_length_04(word):
alp_list={
#清音
'あ': '1a__', 'い': '2i__', 'う': '3u__', 'え': '4e__', 'お': '5o__',
'か': 'ka__', 'き': 'ki__', 'く': 'ku__', 'け': 'ke__', 'こ': 'ko__',
'さ': 'sa__', 'し': 'si__', 'す': 'su__', 'せ': 'se__', 'そ': 'so__',
'た': 'ta__', 'ち': 'ti__', 'つ': 'tu__', 'て': 'te__', 'と': 'to__',
'な': 'na__', 'に': 'ni__', 'ぬ': 'nu__', 'ね': 'ne__', 'の': 'no__',
'は': 'ha__', 'ひ': 'hi__', 'ふ': 'fu__', 'へ': 'he__', 'ほ': 'ho__',
'ま': 'ma__', 'み': 'mi__', 'む': 'mu__', 'め': 'me__', 'も': 'mo__',
'や': 'ya__', 'ゆ': 'yu__', 'よ': 'yo__',
'ら': 'ra__', 'り': 'ri__', 'る': 'ru__', 'れ': 're__', 'ろ': 'ro__', # should use 'l' rather than 'r'?
'わ': 'wa__', 'ゐ': 'wi__', 'ゑ': 'we__', 'を': 'wo__',
'ん': 'NN__',
#濁音半濁音(清音と近い判定)
"が":"ka+_","ぎ":"ki+_","ぐ":"ku+_","げ":"ke+_","ご":"ko+_",
"ざ":"sa+_","じ":"si+_","ず":"su+_","ぜ":"se+_","ぞ":"so+_",
"だ":"ta+_","ぢ":"ti+_","づ":"tu+_","で":"te+_","ど":"to+_",
"ば":"ha+_","び":"hi+_","ぶ":"hu+_","べ":"he+_","ぼ":"ho+_",
"ぱ":"ha$_","ぴ":"hi$_","ぷ":"hu$_","ぺ":"he$_","ぽ":"ho$_",
#拗音・記号
"ぁ":"1a%_","ぃ":"2i%_","ぅ":"3u%_","ぇ":"4e%_","ぉ":"5o%_",
"ゃ":"ya%_", "ゅ":"yu%_" ,"ょ":"yo%_",
"っ":"tu%_","ゔ":"hu+_",'ゎ':'wa%_',
"!":"!!__","?":"??__","、":",,__","。":"..__","ー":"--__",
"~":"--__",
}
result=""
for i in word:
try:
val = alp_list[i]
except:
val = "??"
result+=val
return(result)
#ひらがな->アルファベット(半角2文字)化関数
def hira_to_roma_length_02(word):
alp_list={
"あ":"1a","い":"2i","う":"3u","え":"4e","お":"5o",
"か":"ka","き":"ki","く":"ku","け":"ke","こ":"ko",
"さ":"sa","し":"si","す":"su","せ":"se","そ":"so",
"た":"ta","ち":"ti","つ":"tu","て":"te","と":"to",
"な":"na","に":"ni","ぬ":"nu","ね":"ne","の":"no",
"は":"ha","ひ":"hi","ふ":"hu","へ":"he","ほ":"ho",
"ま":"ma","み":"mi","む":"mu","め":"me","も":"mo",
"や":"ya", "ゆ":"yu", "よ":"yo",
"ら":"ra","り":"ri","る":"ru","れ":"re","ろ":"ro",
"わ":"wa","ゐ":"wi" ,"ゑ":"we","を":"wo",
#濁音半濁音
"が":"ga","ぎ":"gi","ぐ":"gu","げ":"ge","ご":"go",
"ざ":"za","じ":"zi","ず":"zu","ぜ":"ze","ぞ":"zo",
"だ":"da","ぢ":"di","づ":"du","で":"de","ど":"do",
"ば":"ba","び":"bi","ぶ":"bu","べ":"be","ぼ":"bo",
"ぱ":"pa","ぴ":"pi","ぷ":"pu","ぺ":"pe","ぽ":"po",
#拗音・記号
"ぁ":"la","ぃ":"li","ぅ":"lu","ぇ":"le","ぉ":"lo",
"ゃ":"xa", "ゅ":"xu" ,"ょ":"xo",
"っ":"qu","ゔ":"bu","ん":"NN",'ゎ':'ka',
"!":"!!","?":"??","、":",,","。":"..","ー":"--",
"~":"--", #"?":"??","、":",,","。":"..","ー":"--",
}
result=""
for i in word:
try:
val = alp_list[i]
except:
val = "??"
result+=val
return(result)
これらを組み合わせることで、日本語の入力について、母音子音を考慮した、各種の距離を計算する方法を準備することができました。以下がそれぞれ実行してみた結果です。
1つ目の単語:なまあしみわくのまーめいど
2つ目の単語:いのちのめぐみのあるせいど
検索モード:ゲシュタルトパターンマッチング
結果:0.23076923076923078
1つ目の単語:なまあしみわくのまーめいど
2つ目の単語:いのちのめぐみのあるせいど
検索モード:ゲシュタルトパターンマッチング
固定長:2
結果:(42.308, 0, 0, 13, 'nama1asimiwakunoma--me2ido', '2inotinomegumino1aruse2ido')
1つ目の単語:なまあしみわくのまーめいど
2つ目の単語:いのちのめぐみのあるせいど
検索モード:ゲシュタルトパターンマッチング
固定長:4
結果:(53.846, 0, 0, 13, 'na__ma__1a__si__mi__wa__ku__no__ma__--__me__2i__to+_', '2i__no__ti__no__me__ku+_mi__no__1a__ru__se__2i__to+_')
- 2文字固定長の場合、部分一致の語を高く評価しやすい(ひにん/ひにんのちから)
- 4文字固定長の場合、文字数の完全一致を高く評価しやすい2
カード名データベースの用意
似た音の単語同士を比較・検討する方法ができたので、次はデータベース部分の構築に取り掛かります。MTGのカード名を取得する方法としては、いくつか方法があり公式のAPIやMTG wiki等のサイトで公開されているデータ
を取得すればいいのですが、読み仮名部分の取得することが難しかったので、今回はWisdom Guild様のページからデータを取得しました。
取得した結果を以下に抜粋して示します。
英語名,日本語名,読み仮名,発音
+2 Mace,メイス+2,めいすぷらすに,めいすぷらすに
A Display of My Dark Power,我が闇の力の一片を見せよう,わがやみのちからのいっぺんをみせよう,わがやみのちからのいっぺんをみせよう
A Little Chat,耳打ち,みみうち,みみうち
AEther Adept,霊気の達人,れいきのたつじん,れいきのたつじん
AEther Barrier,上天の障壁,じょうてんのしょうへき,じょうてんのしょうへき
AEther Burst,霊気の噴出,れいきのふんしゅつ,れいきのふんしゅつ
Syncopate,中略,ちゅうりゃく,ちゅうりゃく
Zurgo Bellstriker,鐘突きのズルゴ,かねつきのずるご,かねつきのずるご
Zurgo Helmsmasher,兜砕きのズルゴ,かぶとくだきのずるご,かぶとくだきのずるご
"Zurzoth, Chaos Rider",混沌の乗り手、ザルゾス,こんとんののりてざるぞす,こんとんののりてざるぞす
Webアプリとしての実装
上記までで、
(1)似た単語の検索方法
(2)カード名のデータベース
がそろったので、CUI環境でMTGのカード名に似た単語を検索することができるようになりました。これをWebアプリとして実装します。
今回はフレームワークとしてStreamlitを採用しました。
- Streamlit
以下がStreamlitで作成したコードです。
from pathlib import Path
import os
import streamlit as st
import pandas as pd
from datetime import datetime
import time
from time import sleep
import re
import csv
from argparse import ArgumentParser
from pathlib import Path
from typing import List
import pykakasi
import numpy as np
from tqdm import tqdm
# 自作関数部分
import highmitchiy as himit
print(himit.__name__)
@st.cache
def convert_df(df):
# IMPORTANT: Cache the conversion to prevent computation on every rerun
return df.to_csv().encode('utf-8')
################################################################################
# MAIN
################################################################################
# 変数初期化
submit_btn = False
keyword = ""
option = ""
threshold = 0.5
n_cards = 100
VERSION = 3.0
# ページ自体のタイトル
st.set_page_config(
page_title="MtGのカード名に似た単語探してみた",
page_icon="📕",
layout="centered",
# initial_sidebar_state="auto",
menu_items={
'Get Help': 'https://www.extremelycoolapp.com/help',
'Report a bug': "https://script.google.com/macros/s/AKfycbzP-STIVSK2swsyTzwj79qMVibxLvNioTVcIlDz3ciOjqpmXJ6G-NLh7YRaBoRntpdq8A/exec",
'About': "# This is a header. This is an *extremely* cool app!"
}
)
# ダミーファイル生成
df5 = pd.read_csv('dummy_df.csv')
csv = convert_df(df5)
st.title(f"MtGのカード名に似た単語探してみた🧙")
st.caption(
f"入力した単語(日本語・ひらがな)に対して「よく似た名前のマジック・ザ・ギャザリング(MtG)のカードを探す」Web Appです。Version:{VERSION}")
with st.form(key="kakasi"):
st.subheader("検索パラメータ設定")
st.caption("はじめての方は、レーベンシュタイン距離、固定2文字、閾値0.5で検索するのがお勧めです。")
# 検索方法(選択式)
option = st.selectbox(
'検索方法:',
["レーベンシュタイン距離", "ジャロウィンクラー距離", "ゲシュタルトパターンマッチング"],
index=0)
st.caption(f"固定2文字:標準モード 固定4文字:文字数を一致させたい、拗音部分もしっかり合致させたい")
char_length_mes = st.selectbox(
'ローマ字化するときの長さ:', # (固定2文字)単語の完全一致見つけやすい/(固定4文字)ふわっと母音子音一致させたい
["固定2文字(あかさ->1akasa)", "固定4文字(あかさ->1a__ka__sa__)"],
index=0)
# 検索閾値
threshold = st.slider(
"検索閾値:(推奨値:レーベンシュタイン距離/ゲシュタルトパターンマッチング=0.6 ジャロウィークラー距離=0.8)", 0.0, 1.0, 0.5, 0.05)
# 検索語句
cardname = st.text_input("検索したい語句(ひらがな):(例) ないぶちょうさ さらささいりん みつやく")
# 検索開始/中止のボタン
submit_btn2 = st.form_submit_button("よく似た名前のカードを探す")
cancel_btn2 = st.form_submit_button("取り消し/検索停止")
# 検索するボタン押されたら以下の処理
if (submit_btn2):
# ローマ字化の固定長を選択
if ("固定2文字" in char_length_mes):
char_length = 2
elif ("固定4文字" in char_length_mes):
char_length = 4
else:
char_length = 4
# ひらがなのみの入力なのかの調整
input_check = False
word = cardname.replace(" ", "").replace(" ", "")
word_check = word
# 伸ばし棒等の記号は、んに置換して無理くりひらがなのみにしてしまう。
word_check = word_check.replace("ー", "ん").replace("!", "ん").replace(
"?", "ん").replace("・", "ん").replace("、", "ん").replace("。", "ん")
# ひらがな
re_hiragana = re.compile(r'^[あ-ん]+$')
if (re_hiragana.fullmatch(word_check) and len(word_check) > 0):
input_check = True
print(f"ひらがな判定の結果:{input_check}")
# ひらがな以外の入力含まれる場合は検索に入れないとしたい。
elif (len(word_check) == 0):
input_check = False
st.subheader(f"文字が入力されていません。")
else:
st.subheader(f"おそらくひらがなでない入力が含まれます。")
print("おそらくひらがなではない入力です。")
kks = pykakasi.kakasi()
result = kks.convert(word)
word = (''.join([item['hira'] for item in result]))
st.subheader(f"ひらがな化した「{word}」で検索します。")
input_check = True
if (input_check):
starttime = time.time()
keyword = word.replace("、", "").replace("。", "").replace(
"!", "").replace(",", "").replace(".", "").replace("?", "")
condition = f"検索ワード:「{keyword}」" + f'検索 方法:「{option}」' + \
f'表示 件数:「{n_cards}」' + f'検索 閾値:「{threshold}」' + \
f'固定文字数:「{char_length}」'
st.caption(f'{condition}')
st.subheader(f"上記条件で検索行います。")
the_list = himit.himit0(db_path="db_annotated.csv", keyword=keyword, rank=n_cards,
mode=option, limen=float(threshold), char_length=char_length)
finishtime = round((time.time() - starttime), 1)
# 検索結果の表示
st.caption(f"検索ワード:「{keyword}」 ({len(keyword)}文字)の検索結果")
st.dataframe(the_list)
st.caption(f"検索時間{finishtime}秒")
print(f"END\n検索時間{finishtime}秒")
# 保存用の奴生成
csv = convert_df(the_list)
save_file_name = f"{keyword}_{option}_{threshold}.csv"
print(save_file_name)
if (cancel_btn2):
submit_btn2 = False
st.download_button(
label="結果をcsv形式で保存",
data=csv,
file_name=(f"{keyword}_{option}_{threshold}.csv"),
mime='text/csv',)
# このアプリケーションの説明
st.subheader("データベースについて")
st.caption("2023/02/03日更新 ファイレクシア:完全なる統一(メインセット/統率者)まで")
完成品
これらを組み合わせて作成したのが、以下のWebアプリです。
Webアプリの使い方/コツ
検索方法、ローマ字化の方法、検索閾値(結果が大量にヒットすると表示時間が掛かる為)を設定します。
初めて検索する場合は
- 検索条件
- レーベンシュタイン距離
- 固定2文字
- 検索閾値:0.5
で検索するのがお勧めです。
結果
みっちー氏の替え歌の中で、印象的・よく似てると思った単語について上記のプログラムでどの程度似ているのか調べてみました。実際の計算結果では0から1の値となっているのですが、表示するにあたって数値を100倍しております。
単語を検索した結果の例をいくつか示します。
- 「なまあしみわくのまーめいど」
- レーベンシュタイン距離
- 固定4文字
- 検索閾値:0.6
日本語名 | にほんごめい | スコア |
---|---|---|
ガイアー岬の災い魔 | がいあーみさきのわざわいま | 67.308 |
命の恵みのアルセイド | いのちのめぐみのあるせいど | 65.385 |
うろ穴生まれのバーゲスト | うろあなうまれのばーげすと | 65.385 |
隠れた入り江のナイアード | かくれたいりえのないあーど | 63.462 |
山岳猛火のオリアード | さんがくもうかのおりあーど | 63.462 |
- <条件>
- 「こころおどるあんこーる」
- レーベンシュタイン距離
- 固定2文字
- 検索閾値:0.5
日本語名 | にほんごめい | スコア |
---|---|---|
胸躍るアンコール | むねおどるあんこーる | 72.7270 |
虚空のスコール | こくうのすこーる | 50.0000 |
心の傷跡 | こころのきずあと | 50.0000 |
カイラのオルゴール | かいらのおるごーる | 50.0000 |
心を削るものグリール | こころをけずるものぐりーる | 50.0000 |
- <条件>
- 「いつのまにか」
- レーベンシュタイン距離
- 固定2文字
- 検索閾値:0.5
日本語名 | にほんごめい | スコア |
---|---|---|
鉄のマイア | てつのまいあ | 66.6670 |
銅のマイア | どうのまいあ | 58.3330 |
一か八か | いちかばちか | 58.3330 |
きんのマイア | きんのまいあ | 58.3330 |
ぎんのマイア | ぎんのまいあ | 58.3330 |
- 「いけるでしょう」
- レーベンシュタイン距離
- 固定2文字
- 検索閾値:0.5
日本語名 | にほんごめい | スコア |
---|---|---|
生ける伝承 | いけるでんしょう | 87.5000 |
うねる歩哨 | うねるほしょう | 64.2860 |
生ける伝承、佐津樹 | いけるでんしょうさつき | 63.6360 |
おいざる歩哨 | おいざるほしょう | 62.5000 |
怒れる群衆 | いかれるぐんしゅう | 61.1110 |
上記のように、概ねみっちー氏の替え歌の結果とも一致する結果を得ることができました。ソフトウェアでは概ね一瞬で約23000件のカードの検索を行っておりますが、その中から、歌詞に似ている単語をおそらく人力で検索しているみっちー氏の凄さを改めて感じました。みっちー!すごい!本当にすごいんだ!3
所感
いかがだったでしょうか。
みっちー氏本人も配信や動画で言及していましたが、「カード名とよく似た単語を探す辞書がない」為デッキ構築の難易度(注:替え歌を作るの意)が今までは非常に高い壁であったと思います。(エクセルなどもはや不要だ)
本サービスが、フォーマット「歌ってみた」への呼び水となることと、30周年を迎えたMTG自体への更なる人気の向上へと繋がってほしいと思います。
-
1993年にWizards of the Coast社から発売されたトレーディングカードゲーム。世界中で遊ばれており、現在では20,000種類以上のカードが存在している。 ↩
-
3音あれば違いを説明できるが、単語帳をあえて長く伸ばすことで、削除、挿入コストを増やすことで、字余り、字足りずをはじくようにしている。 ↩
-
カラデシュ!すごい!本当にすごいんだ!
https://dic.nicovideo.jp/a/%E3%82%AB%E3%83%A9%E3%83%87%E3%82%B7%E3%83%A5%21%E3%81%99%E3%81%94%E3%81%84%21%E6%9C%AC%E5%BD%93%E3%81%AB%E3%81%99%E3%81%94%E3%81%84%E3%82%93%E3%81%A0%21 ↩