はじめに
はじめまして、株式会社みらい翻訳エンジニアリング部の@reonyanarticleです。
1日遅れになりましたが、みらい翻訳アドベントカレンダーの25日を担当します。
突然ですが、私はMagic: The Gathering(以下、MTG)というカードゲームが大好きです。社内で布教活動を行い、「MTG部」を作りたいと思うほど好きです!(ちなみに、まだ部はできていません。絶賛部員募集中です。)
そんな私の好きなMTGですが、元々の言語は英語であり、現在では日本語、フランス語、イタリア語、ドイツ語、スペイン語、ポルトガル語、韓国語でも展開されています。
つまり、これらの言語間には対訳コーパスが存在しているということになります。
対訳コーパスがあると聞けば、自然言語処理エンジニアとして「このデータで何か面白いことができないか?」と考えるのが性です。
特に、MTGのカードテキストは特徴的な構造を持っています。たとえば、「マナコスト」「効果」「条件」といった要素が明確に分かれており、通常の自然言語よりも規則的に記載されています。
その一方で、MTG特有の用語や表現が多く含まれているため、従来の翻訳手法では対応が難しい場面もあります。
そこで、「LLMを使えば、MTGの独特のテキスト構造を少量のデータで学習し、うまく翻訳できるのではないか?」というアイデアが浮かびました。
今回は、この疑問をきっかけに、自分の興味・趣味・実益を兼ねた試みを記事としてまとめてみました。
とてもニッチなテーマですが、少しでも興味を持っていただけたら嬉しいです。
注意書き
本記事で紹介する内容は、Magic: The Gatheringのファンコンテンツポリシーに則って作成しています。
このポリシーに基づき、MTGのカード翻訳やテキスト使用は非公式かつ個人的な楽しみを目的としており、公式な製品やサービスを意図したものではありません。
また、記事内で取り上げる内容は、筆者の趣味と専門である自然言語処理の技術を組み合わせた個人的な実験です。
普段の業務で扱う技術と関連する部分もありますが、使用するデータやコーパスはあくまで個人的な範囲で利用しており、業務上のプロジェクトやデータとは一切関係ありません。
MTGを愛する一ファンとして、カードをより楽しむための試みであることをご理解いただければ幸いです。
コーパス収集
対象セット
MTGでは、新しいカードは「セット」という単位でまとめて発売されます。各セットは200枚程度のカードで構成されており、世界観やテーマが決まっています。
翻訳対象のセットには、『ダスクモーン:戦慄の館』(以下、ダスクモーン)を選びました。
古いセットを翻訳してしまうとLLMにはすでに学習されているかもしれないため、2024年9月27日に発売されたばかりのセットで新しい能力や固有名詞が多く含まれているダスクモーンを選択しました。
収集方法
今回は、ScryfallAPIを使用して対訳コーパスを作成することにしました。
他にもカード情報を取得するには MTGJSONやMagic: The Gathering APIといった選択肢がありますが、元のサービスであるScryfallを普段からカードイラスト検索などで利用しており、慣れ親しんでいるため今回はScryfallAPIを使用しました。
コーパス収集のプロセス
コーパス収集の手順は以下のように進めます。
- ダスクモーンの英語カード情報を取得
Scryfall APIを利用して、ダスクモーンセットの英語のカードデータを収集します。 - 各カードの日本語テキストとカード名を取得
英語カード情報と対応する日本語のテキストとカード名を取得します。 - 必要なデータの抽出
翻訳に必要なデータ(英語のカード名、日本語のカード名、英語のテキスト、日本語のテキストの4種類)に整理します。 - 欠損データの削除
抽出したデータの中に翻訳や評価に使えないデータが含まれているため、削除します。
1.ダスクモーンの英語カード情報を取得
セット名を入力したら、そのセット上のカード全ての情報を取得するプログラムを作成します。
import requests
import json
import time
def fetch_cards_by_set(expansion: str) -> list[dict]:
base_url: str = "https://api.scryfall.com/cards/search"
params: dict = {"q": f"set:{expansion}"}
print(f"Fetching cards for expansion: {expansion}...")
response = requests.get(base_url, params=params)
if response.status_code != 200:
print(f"Error: Unable to fetch data (status code {response.status_code})")
return []
cards = response.json().get("data", [])
all_cards = cards
# ハンドリング
while response.json().get("has_more", False):
next_page = response.json().get("next_page")
time.sleep(0.1) # リクエスト間隔を0.1秒(10リクエスト/秒)に調整
response = requests.get(next_page)
if response.status_code != 200:
print(f"Error: Unable to fetch data on next page (status code {response.status_code})")
break
all_cards.extend(response.json().get("data", []))
print(f"Retrieved {len(all_cards)} cards from expansion: {expansion}")
return all_cards
ダスクモーンの略称1はDSK
なので以下のように実行します。
dsk_cards = fetch_cards_by_set("DSK")
dsk_cards[0]
の中身は以下のようになっています。
dsk_cards[0]の中身
{
"object": "card",
"id": "ee0565f5-ebdb-43f9-bbb4-0485b1968937",
"oracle_id": "0eec9984-cd11-4a52-9234-469c6a5fb9aa",
"multiverse_ids": [
673660
],
"mtgo_id": 130641,
"arena_id": 92355,
"tcgplayer_id": 575112,
"cardmarket_id": 786616,
"name": "Abandoned Campground",
"lang": "en",
"released_at": "2024-09-27",
"uri": "https://api.scryfall.com/cards/ee0565f5-ebdb-43f9-bbb4-0485b1968937",
"scryfall_uri": "https://scryfall.com/card/dsk/255/abandoned-campground?utm_source=api",
"layout": "normal",
"highres_image": true,
"image_status": "highres_scan",
"image_uris": {
"small": "https://cards.scryfall.io/small/front/e/e/ee0565f5-ebdb-43f9-bbb4-0485b1968937.jpg?1726286826",
"normal": "https://cards.scryfall.io/normal/front/e/e/ee0565f5-ebdb-43f9-bbb4-0485b1968937.jpg?1726286826",
"large": "https://cards.scryfall.io/large/front/e/e/ee0565f5-ebdb-43f9-bbb4-0485b1968937.jpg?1726286826",
"png": "https://cards.scryfall.io/png/front/e/e/ee0565f5-ebdb-43f9-bbb4-0485b1968937.png?1726286826",
"art_crop": "https://cards.scryfall.io/art_crop/front/e/e/ee0565f5-ebdb-43f9-bbb4-0485b1968937.jpg?1726286826",
"border_crop": "https://cards.scryfall.io/border_crop/front/e/e/ee0565f5-ebdb-43f9-bbb4-0485b1968937.jpg?1726286826"
},
"mana_cost": "",
"cmc": 0.0,
"type_line": "Land",
"oracle_text": "Abandoned Campground enters tapped unless a player has 13 or less life.\n{T}: Add {W} or {U}.",
"colors": [],
"color_identity": [
"U",
"W"
],
"keywords": [],
"produced_mana": [
"U",
"W"
],
"legalities": {
"standard": "legal",
"future": "legal",
"historic": "legal",
"timeless": "legal",
"gladiator": "legal",
"pioneer": "legal",
"explorer": "legal",
"modern": "legal",
"legacy": "legal",
"pauper": "legal",
"vintage": "legal",
"penny": "legal",
"commander": "legal",
"oathbreaker": "legal",
"standardbrawl": "legal",
"brawl": "legal",
"alchemy": "legal",
"paupercommander": "legal",
"duel": "legal",
"oldschool": "not_legal",
"premodern": "not_legal",
"predh": "not_legal"
},
"games": [
"paper",
"mtgo",
"arena"
],
"reserved": false,
"foil": true,
"nonfoil": true,
"finishes": [
"nonfoil",
"foil"
],
"oversized": false,
"promo": false,
"reprint": false,
"variation": false,
"set_id": "a111d8a9-b647-48ec-afab-2b78f92173f5",
"set": "dsk",
"set_name": "Duskmourn: House of Horror",
"set_type": "expansion",
"set_uri": "https://api.scryfall.com/sets/a111d8a9-b647-48ec-afab-2b78f92173f5",
"set_search_uri": "https://api.scryfall.com/cards/search?order=set&q=e%3Adsk&unique=prints",
"scryfall_set_uri": "https://scryfall.com/sets/dsk?utm_source=api",
"rulings_uri": "https://api.scryfall.com/cards/ee0565f5-ebdb-43f9-bbb4-0485b1968937/rulings",
"prints_search_uri": "https://api.scryfall.com/cards/search?order=released&q=oracleid%3A0eec9984-cd11-4a52-9234-469c6a5fb9aa&unique=prints",
"collector_number": "255",
"digital": false,
"rarity": "common",
"flavor_text": "They say every inhabitant vanished in a single night, leaving their belongings untouched but curiously covered in moths.",
"card_back_id": "0aeebaf5-8c7d-4636-9e82-8c27447861f7",
"artist": "Cristi Balanescu",
"artist_ids": [
"c09ede88-a1b5-4a18-9895-dc8a965d28a5"
],
"illustration_id": "c5c3e213-6ea7-4e18-8e1b-0ebbb358eb95",
"border_color": "black",
"frame": "2015",
"full_art": false,
"textless": false,
"booster": true,
"story_spotlight": false,
"edhrec_rank": 14576,
"penny_rank": 12530,
"preview": {
"source": "CoolStuffInc",
"source_uri": "https://www.coolstuffinc.com/a/paigesmith-seo-09022024-ten-new-common-dual-lands-in-duskmourn-house-of-horrors",
"previewed_at": "2024-09-02"
},
"prices": {
"usd": "0.05",
"usd_foil": "0.05",
"usd_etched": null,
"eur": "0.12",
"eur_foil": "0.13",
"tix": "0.03"
},
"related_uris": {
"gatherer": "https://gatherer.wizards.com/Pages/Card/Details.aspx?multiverseid=673660&printed=false",
"tcgplayer_infinite_articles": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&trafcat=infinite&u=https%3A%2F%2Finfinite.tcgplayer.com%2Fsearch%3FcontentMode%3Darticle%26game%3Dmagic%26partner%3Dscryfall%26q%3DAbandoned%2BCampground",
"tcgplayer_infinite_decks": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&trafcat=infinite&u=https%3A%2F%2Finfinite.tcgplayer.com%2Fsearch%3FcontentMode%3Ddeck%26game%3Dmagic%26partner%3Dscryfall%26q%3DAbandoned%2BCampground",
"edhrec": "https://edhrec.com/route/?cc=Abandoned+Campground"
},
"purchase_uris": {
"tcgplayer": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&u=https%3A%2F%2Fwww.tcgplayer.com%2Fproduct%2F575112%3Fpage%3D1",
"cardmarket": "https://www.cardmarket.com/en/Magic/Products/Singles/Duskmourn-House-of-Horror/Abandoned-Campground?referrer=scryfall&utm_campaign=card_prices&utm_medium=text&utm_source=scryfall",
"cardhoarder": "https://www.cardhoarder.com/cards/130641?affiliate_id=scryfall&ref=card-profile&utm_campaign=affiliate&utm_medium=card&utm_source=scryfall"
}
}
'name'
を見ると、Abandoned Campground(日本語名:放棄されたキャンプ地)というカードのデータであることがわかります。
ダスクモーンのカードの枚数分だけ上記のようなデータがdsk_cards
に保存されています。
2. 各カードの日本語テキストとカード名を取得
次に公式サイトのLanguagesを参考に、対応するカードの日本語テキストを取得するプログラムを作成します。
from tqdm import tqdm
def fetch_cards_by_japanese_text(cards: list[dict]) -> list[dict]:
default_url: str = "https://api.scryfall.com/cards/"
for card in tqdm(cards):
set_name: str = card["set"]
collector_number: str = card["collector_number"]
url: str = f"{default_url}{set_name}/{collector_number}/ja"
response = requests.get(url)
if response.status_code != 200:
print(f"Error: Unable to fetch data (status code {response.status_code})")
continue
card['japanese_name'] = response.json().get("printed_name")
card['japanese_text'] = response.json().get("printed_text")
time.sleep(0.1) # リクエスト間隔を0.1秒(10リクエスト/秒)に調整
return cards
各カードの'id'
を指定するのではなく、'collector_number'
なので注意します。
上記のコードを実行することで、先ほどのdsk_cards
の各要素に'japanese_name'
と'japanese_text'
が追加されたデータが作成されます。
dsk_japanese_cards = fetch_cards_by_japanese_text(dsk_cards)
を実行し、最初の要素dsk_japanese_cards[0]
を確認してみます。
全体を表示するとサイズが大きくなるため、差分のみを表示します。
+ "japanese_name": "放棄されたキャンプ地",
+ "japanese_text": "ライフ総量が13点以下であるプレイヤーがいないかぎり、放棄されたキャンプ地はタップ状態で戦場に出る。\n{T}:{W}か{U}を加える。"
これで、英語テキストと日本語テキストの両方を取得することができました!
3. 必要なデータの抽出
最後に必要なデータである、英語のカード名、日本語のカード名、英語のテキスト、日本語のテキストの4種類にまとめる処理を作成します。
def extract_card_text_and_name(cards: list[dict]) -> list[dict]:
processed_cards = []
for card in tqdm(cards):
card_name_en = card.get("name", "N/A")
text_en = card.get("oracle_text", "N/A")
card_name_jp = card.get("japanese_name", "N/A")
text_jp = card.get("japanese_text", "N/A")
processed_cards.append({
"original_name": card_name_en,
"japanese_name": card_name_jp,
"original_text": text_en,
"japanese_text": text_jp,
})
return processed_cards
上記の処理を実行した結果、必要なデータのみが抽出されていることを確認できます。
dsk_processed_cards = extract_card_text_and_name(dsk_japanese_cards)
を実行し、 dsk_processed_cards
の最初の要素を確認すると以下のようになっています。
{
"original_name": "Abandoned Campground",
"japanese_name": "放棄されたキャンプ地",
"original_text": "Abandoned Campground enters tapped unless a player has 13 or less life.\n{T}: Add {W} or {U}.",
"japanese_text": "ライフ総量が13点以下であるプレイヤーがいないかぎり、放棄されたキャンプ地はタップ状態で戦場に出る。\n{T}:{W}か{U}を加える。"
}
4. 欠損データの削除
作成されたデータの中身を見てみると、英語のテキストが取得できていないカードや、日本語のテキストが取得できていないカードがあります。
データを眺めてみると、部屋カードと呼ばれる、今回新しく出たシステムのカードのテキストは取得に失敗していることがわかります。
{
"original_name": "Painter's Studio // Defaced Gallery",
"japanese_name": null,
"original_text": "N/A",
"japanese_text": null
}
欠損したデータは翻訳や評価に使えないので欠損データを削除する処理を作成します。
def delete_empty_text_cards(data: list[dict]) -> list[dict]:
return [
card for card in data
if all([
card["japanese_text"] != "N/A",
card["original_text"] != "N/A",
card["japanese_name"] != None,
card["original_name"] != None,
card["japanese_text"] != None
])]
上記プログラムを実行することで元々277個あったカードが248個になりました。
これですべての準備が整いました!
それでは、いよいよ翻訳処理を実行してみます!
(翻訳までの準備に長い道のりを辿るのは、自然言語処理エンジニアならではのあるあるだなぁ、と思いながら進めていました。)
翻訳
今回使用するLLMはGPT-4o、具体的にはgpt-4o-2024-08-06
を採用しました。
カードごとに翻訳する処理を作成します。
すべてのカードを一度に訳すことで訳質が向上する可能性もありますが、悪影響を避けたいので今回はカードごとに翻訳することにしました。
from openai import OpenAI
OPEN_AI_API_KEY: str = 'APIのキーを書いてください'
MODEL: str = "gpt-4o-2024-08-06"
def translate_text(card: dict, user_prompt: str) -> str:
client = OpenAI(api_key=OPEN_AI_API_KEY)
original_text: str = card["original_text"]
user_prompt_with_card_text: str = user_prompt + '\n' + original_text
messages: list[dict] = [
{"role": "user", "content": user_prompt_with_card_text},
]
response = client.chat.completions.create(
model=MODEL,
messages=messages,
temperature=0)
return response.choices[0].message.content
これを先ほど作ったデータdsk_processed_cards_without_empty = delete_empty_text_cards(dsk_processed_cards)
に実行します。
新たに'translated_text'
というキーを作成し、元の辞書に追加するようになっています。
user_prompt = '''
以下のマジックザギャザリングのカードのテキストを英語から日本語に翻訳してください。
翻訳結果以外の不必要なものは出力しないでください。:
'''
for card in tqdm(dsk_processed_cards_without_empty):
card["translated_text"] = translate_text(card, user_prompt)
大体10分ぐらいで処理が完了します。(個人差があります)
dsk_processed_cards_without_empty
の最初の要素を確認してみましょう!
{
"original_name": "Abandoned Campground",
"japanese_name": "放棄されたキャンプ地",
"original_text": "Abandoned Campground enters tapped unless a player has 13 or less life.\n{T}: Add {W} or {U}.",
"japanese_text": "ライフ総量が13点以下であるプレイヤーがいないかぎり、放棄されたキャンプ地はタップ状態で戦場に出る。\n{T}:{W}か{U}を加える。",
"translated_text": "見捨てられたキャンプ場は、プレイヤーのライフが13以下でない限り、タップ状態で戦場に出る。\n{T}: {W}か{U}を加える。"
}
思ったよりうまく翻訳できてそうですね…! それでは評価に移ります。
評価
今回は、BLEUによる機械的な評価に加え、翻訳結果を確認して定性的な評価も行います。
BLEUは、機械翻訳の評価で広く用いられる指標の一つです。 翻訳結果と参照訳の間で、n-gramの一致率を計算することで、翻訳の精度を数値化します。
機械評価
sacrebleuというライブラリ使用して評価します。
先ほど取得したデータの"japanese_text"
だけをまとめたテキストファイルdsk_original_japanese_text.txt
と、"translated_text"
だけをまとめたテキストファイルdsk_translated_japanese_text.txt
を作成します。
以下のコマンドを実行してBLEUを測ります。
% sacrebleu dsk_original_japanese_text.txt -i dsk_translated_japanese_text.txt --tokenize ja-mecab -b
48.4
BLEUでの評価は48.4
でした。
今回の結果は単独の評価値しかないため、他との比較は難しいですが、思った以上に良い結果だと感じました。
翻訳の高品質な基準として、BLEUスコアは40以上が良いとされています。(要出典)
定性的な評価
機械評価だけでは、具体的にどのような翻訳結果になっているのか分からないため、定性的な評価を行ってみましょう。
ダスクモーンには新しいキーワード能力2が含まれています。それらが適切に翻訳されているかを確認していきましょう。
キーワード能力の翻訳結果
ダスクモーンで新たに登場したキーワード能力は以下の通りです。英語表記、日本語表記の順で記載しています。順番は公式情報(こちら)を参考にしています。
Rooms 部屋
Manifest Dread 戦慄予示
Survival 生存
Eerie 違和感
Impending 兆候
上記のキーワード能力が適切に翻訳されている割合を調査します。
計算方法は以下の通りです:
正答率 = (翻訳結果にキーワード能力を含む文の数) / (日本語テキストにキーワードを含む文の数)
この方法で正答率を算出します。
-
部屋
: 21/21 = 1.0 -
戦慄予示
: 0/25 = 0.0 -
生存
: 14/14 = 1.0 -
違和感
:0/15 = 0.0 -
兆候
: 0/5 = 0.0
結果は以上の通りでした。
翻訳できているものはすべて適切なキーワード能力に翻訳されている一方、翻訳できていないものはすべて適切なキーワード能力に翻訳されていないという、極端な結果となりました。
また、翻訳できているキーワード能力は、一般的な翻訳でも再現できそうな内容であるため、MTG特有の雰囲気に合う翻訳を再現するのは簡単ではないと感じました。
それでは、翻訳がうまくいかなかった例についてもう少し詳しく見ていきましょう。
戦慄予示
{
"original_name": "Abhorrent Oculus",
"japanese_name": "忌まわしき眼魔",
"original_text": "As an additional cost to cast this spell, exile six cards from your graveyard.\nFlying\nAt the beginning of each opponent's upkeep, manifest dread. (Look at the top two cards of your library. Put one onto the battlefield face down as a 2/2 creature and the other into your graveyard. Turn it face up any time for its mana cost if it's a creature card.)",
"japanese_text": "この呪文を唱えるための追加コストとして、あなたの墓地にあるカード6枚を追放する。\n飛行\n各対戦相手のアップキープの開始時に、あなたは戦慄予示する。(あなたのライブラリーの一番上にあるカード2枚を見る。それらのうち1枚を2/2のクリーチャーとして裏向きで戦場に出し、残りをあなたの墓地に置く。それがクリーチャー・カードなら、そのマナ・コストで、いつでも表向きにしてよい。)",
"translated_text": "この呪文を唱えるための追加コストとして、あなたの墓地からカードを6枚追放する。\n飛行\n各対戦相手のアップキープの開始時に、恐怖を具現化する。(あなたのライブラリーの一番上からカードを2枚見る。そのうち1枚を2/2のクリーチャーとして裏向きで戦場に出し、もう1枚をあなたの墓地に置く。それがクリーチャー・カードであるなら、いつでもそのマナ・コストを支払って表向きにすることができる。)"
}
全てmanifest dread
は恐怖を具現化する
と翻訳されていました。誤訳ですがお洒落ですね。
違和感
{ "original_name": "Fear of Infinity",
"japanese_name": "無限への恐怖",
"original_text": "Flying, lifelink\nFear of Infinity can't block.\nEerie — Whenever an enchantment you control enters and whenever you fully unlock a Room, you may return Fear of Infinity from your graveyard to your hand.",
"japanese_text": "飛行、絆魂\n無限への恐怖ではブロックできない。\n違和感 — あなたがコントロールしているエンチャント1つが戦場に出るたび、および、あなたが部屋1つを完全に開放するたび、あなたの墓地にある無限への恐怖を手札に戻してもよい。",
"translated_text": "飛行、絆魂 \n無限の恐怖はブロックできない。 \n不気味 — あなたがコントロールするエンチャントが戦場に出るたび、またはあなたが部屋を完全に解放するたび、あなたは無限の恐怖をあなたの墓地からあなたの手札に戻してもよい。"
}
全てEerie
は不気味
と翻訳されていました。
このキーワード能力はうまく翻訳されていませんが、昔から存在するキーワード能力である飛行
や絆魂
、さらにはカード名については正しく翻訳されています。
兆候
{
"original_name": "Overlord of the Balemurk",
"japanese_name": "ベイルマークの大主",
"original_text": "Impending 5—{1}{B} (If you cast this spell for its impending cost, it enters with five time counters and isn't a creature until the last is removed. At the beginning of your end step, remove a time counter from it.)\nWhenever Overlord of the Balemurk enters or attacks, mill four cards, then you may return a non-Avatar creature card or a planeswalker card from your graveyard to your hand.",
"japanese_text": "兆候5 — {1}{B}(兆候コストでこの呪文を唱えたなら、これは時間カウンター5個が置かれた状態で戦場に出て、最後の時間カウンターが取り除かれるまで、これはクリーチャーではない。あなたの終了ステップの開始時に、これの上から時間カウンター1個を取り除く。\nベイルマークの大主が戦場に出るか攻撃するたび、カード4枚を切削する。その後、あなたの墓地にありプレインズウォーカー・カードやアバターでないクリーチャー・カードである1枚をあなたの手札に戻してもよい。",
"translated_text": "差し迫る 5—{1}{B}(この呪文を差し迫るコストで唱えた場合、それは時間カウンターが5個置かれた状態で戦場に出て、最後の1個が取り除かれるまでクリーチャーではない。あなたの終了ステップの開始時に、これから時間カウンターを1個取り除く。)\nバレマークの支配者が戦場に出るか攻撃するたび、カードを4枚切削し、その後あなたはあなたの墓地からアバターでないクリーチャー・カード1枚かプレインズウォーカー・カード1枚をあなたの手札に戻してもよい。"
}
全てImpending
は差し迫る
に翻訳されていました。
キーワード能力の切削
はうまく翻訳できていますが、カード名に含まれている固有名詞のBalemurk(ベイルマーク)
がバレマーク
と翻訳されています。
最後に
今回の記事では、GPT-4o を用いてMTGのカードを翻訳し、その精度を評価する試みを紹介しました。
当初は「モデルの学習も試したい」と考えていましたが、今回は評価までに留まりました。
次回は、モデルを実際に学習させ、精度がどのように変化するかを検証してみたいと思います。
機械翻訳の評価に関しては良い結果となりましたが、MTG特有の単語や固有名詞には弱い部分も見られました。
さらに良い翻訳を目指すためには、さまざまなアプローチが考えられます。
この記事がきっかけとなり、興味を持った皆さんがそれぞれの工夫で新しい方法を試していただければ嬉しいです!
最後までお読みいただき、ありがとうございました。
この記事が、MTGや自然言語処理に興味を持つ方々にとって少しでも参考になれば幸いです。
最後に一言(二言?)
みなさん、MTGをしましょう!!!!一緒に遊びましょう!!!!