やりたいこと
入力された文をCOTOHA APIで解析して、ダジャレかどうかを判定する。
ダジャレであれば、トマトが「🍅======」と迫ってくる。
なぜダジャレでトマト?
元ネタ:SCP-504(SCP財団 日本語ページ)
SCP-504のトマトに聞こえる範囲にいる人間がつまらない冗談を言う試みが発生したとき、トマトは瞬間的に少なくとも時速100マイル(およそ時速160キロメートル、秒速45メートル)で音源の方向に急接近します。
要するに、ジョークを言った人にすごい勢いでとんでいくトマトっぽい何かです。
今回は、ジョークの中でも特にダジャレに焦点をあてて、SCP-504っぽくダジャレに応じて何かしらのスコア(速度)を返すプログラムを作ってみました。
対象とするジョーク
そもそもの話として「コンピュータでジョークを理解する」のは明らかに難しい問題です。
そのため、今回の対象は(日本語の)ダジャレで、特に以下の2点のみに限定します(本来のSCP-504はもっと広いジョーク・言語に反応します)
- 同じ読みを持つ語を用いたもの「例:トイレにいっといれ」
- 似たような音を持つ語を用いたもの「例:布団が吹っ飛んだ」
※ダジャレの定義については「駄洒落@Wikipedia」を参照しています。
先に結果
具体的な判定やスコアリングの方法を説明をする前に、どういった入力で、どのような出力が得られるのかを見ていただければと思います。
入力
l = []
l.append('アルミ缶の上にあるみかん')
l.append('布団が吹っ飛んだ')
l.append('アイスはあーいいっすねぇ')
l.append('ピクルスがぴっくるするほどおいしい')
l.append('モーターが動かなくなってもうた')
l.append('ナシは無しな')
l.append('ナシは漢字で梨と書く')
l.append('あああああああああああああああ')
l.append('すもももももももものうち')
for sentence in l:
tomato = scp504(sentence)
print('{}\t🍅{} ({})'.format(sentence, ''.join(['=']*int(tomato.speed)), tomato.speed))
出力
アルミ缶の上にあるみかん 🍅==== (4.015165269566691)
布団が吹っ飛んだ 🍅== (2.3915395196490064)
アイスはあーいいっすねぇ 🍅===== (5.934909103989664)
ピクルスがぴっくるするほどおいしい 🍅======= (7.45176015603134)
モーターが動かなくなってもうた 🍅========= (9.642945825142927)
ナシは無しな 🍅==== (4.0202326134512045)
ナシは漢字で梨と書く 🍅 (-1)
あああああああああああああああ 🍅 (-1)
すもももももももものうち 🍅 (-1)
上述の通り、文(sentence)をscp504
に与えると、スコア(speed)が得られます。
最終的には、ダジャレに対するスコアを「=」として表現することを想定しています。
なお、ダジャレでない入力の場合、🍅はとびません(「=」なし)。
モデル解説
ダジャレ判断とスコアリングをPythonのクラス「scp504」として実装しました。(プログラム全体はGithubで公開しています。)
今回は、ダジャレのスコアリングにルールベースの手法を採用しました。
(本当は、単語分散表現を扱いやすく、ダジャレと相性の良さそうな深層強化学習で試してみたいと思っていたのですが、COTOHA APIの制約上、短期的な実現が厳しそうだったので、ひとまずルールベースで作ることにしました。1日1000件じゃ、深層学習は辛いです・・・)
大まかな流れは以下のとおりです。
- 入力された文を構文解析する
- 同じ読み・似たような音を持つ箇所を見つける
- ダジャレをスコアリングする
では、実際のプログラムから1~3の処理に相当する部分を引っ張り出して、順々に解説していきます。
1.入力された文を構文解析する
入力された文は、まず「COTOHA API 構文解析」で形態素単位に分解します。
このとき、ノイズとなり得る記号類は削除してから解析しています。
今回は、表層系とカナ読みの形態素区切り情報を使用します(文節情報は 有用そうですが 使用しません)。
rw = re.compile('[!-/:-@\\[-`{-~.。?!()「」#%@『』【】=+・…〜~]')
class scp504:
cotoha = COTOHA()
def __init__(self, sentence):
self.sentence = sentence
self.norm_sentence = rw.sub('', sentence)
self.parsed_sentence = self.cotoha.parse(self.norm_sentence)
self.kanas = [m.get('kana', '') for c in self.parsed_sentence for m in c.get('tokens',[])]
self.forms = [m.get('form', '') for c in self.parsed_sentence for m in c.get('tokens',[])]
なおCOTOHA APIは、以下のようにクラス化してまとめてあります。
COTOHA API CLASS
import requests
import requests
import json
import configparser
class COTOHA:
def __init__(self):
config = configparser.ConfigParser()
config.read('config.ini')
self.client_id = config.get('COTOHA_API', 'client_id')
self.client_secret = config.get('COTOHA_API', "client_secret")
self.endpoint_url_base = config.get('COTOHA_API', 'endpoint_url_base')
self.access_token_url = config.get('COTOHA_API', 'access_token_url')
self.access_token = ''
def _get_access_token(self):
# アクセストークンを取得
headers = {'Content-Type': 'application/json;charset=UTF-8'}
data = {'grantType': 'client_credentials', 'clientId': self.client_id, 'clientSecret': self.client_secret}
data = json.dumps(data).encode()
r = requests.post(self.access_token_url, data=data, headers=headers)
res = json.loads(r.content)
return res['access_token']
def _get_header(self, update_access_token=True):
if update_access_token or not self.access_token:
self.access_token = self._get_access_token()
return {'Authorization': 'Bearer ' + self.access_token,
'Content-Type': 'application/json;charset=UTF-8'}
def parse(self, sentence, type='', dic_type=[]):
url = self.endpoint_url_base + "v1/parse"
data = {'sentence': sentence}
if type:
data['type'] = type
if dic_type:
data['dic_type'] = dic_type
data = json.dumps(data).encode()
r = requests.post(url, data=data, headers=self._get_header())
res = json.loads(r.content)
return res.get('result')
def similarity(self, s1, s2, type=None, dic_type=None):
url = self.endpoint_url_base + 'v1/similarity'
data = {'s1': s1, 's2': s2, 'a':''}
if type:
data['type'] = type
if dic_type:
data['dic_type'] = dic_type
data = json.dumps(data).encode()
r = requests.post(url, data=data, headers=self._get_header())
res = json.loads(r.content)
return res['result'].get('score', -1)
※このクラスを動かすためには、COTOAH APIのアカウント情報をconfig.ini
に記述しておく必要があります(詳しくはGithubのREADME参照)。
2.同じ読み・似たような音を持つ箇所を見つける
(1)で獲得した、表層系とカナ読みの形態素のリストを使って、同じ読み・似たような音を持つ箇所を 気合で 探索します。
def dajaratio(self):
START, END = 0, 3
token_ratio = -1
length = len(self.kanas)
for s, e in [(i, h)for i in range(0, length) for h in range(START, END)]:
if not s+e < length-1:
continue
x_kana = ''.join(self.kanas[s:s+e+1])
x_form = ''.join(self.forms[s:s+e+1])
if len(x_kana) < 2:
continue
for t, f in [(j, k) for j in range(s+e+1, length) for k in range(START, END)]:
if not t+f < length:
continue
y_kana = ''.join(self.kanas[t:t+f+1])
y_form = ''.join(self.forms[t:t+f+1])
if len(y_kana) < 2:
continue
tr = SequenceMatcher(None, x_kana, y_kana).ratio()
if tr > max_ratio:
max_ratio = tr
self.x_form, self.y_form = x_form, y_form
self.x_kana, self.y_kana = x_kana, y_kana
return token_ratio
forの記述が結構やばいことになっていますが、やっていることは単純で、簡単にまとめると以下のとおりです。
- 「ある形態素を対象にその直後0~2つの形態素を連結させたもの」を2つ(X, Y)用意する。
- その間の類似度を
difflib
のSequenceMatcher
で計算する。 - 全組み合わせの(X, Y)の類似度を計算し、最も高い類似度を持つ(X, Y)を「同じ読み・似たような音を持つ箇所」として扱う。
なお、この関数の返り値であるtoken_ratio
や(X, Y)の情報は(3)の処理でも活用します。
3. ダジャレをスコアリングする
ダジャレのスコアリングの際には、以下の2点を考えます。
- そもそもダジャレとして適切か?
- ダジャレのスコアをどのように計算するか?
A. ダジャレとして適切か?
スコアリングの前に、まず、入力された文がダジャレであるかどうかを判定します。
以下の通り、3つのスコアを算出し、それらにしきい値を設けることでダジャレでないと思われる文を除外します。
- 「2.同じ読み・似たような音を持つ箇所を見つける」で得られた類似度が一定以上
- 「2.同じ読み・似たような音を持つ箇所を見つける」で得られた意味的類似度が一定以下
- 文全体の文字数と文中で最も多く出現する文字の頻度との比が一定以下
(1)は、ダジャレでない普通の文を除外するためのルールです。「2.同じ読み・似たような音を持つ箇所を見つける」で計算したtoken_ratio
を使って足切りしました。
self.token_ratio = self.dajaratio()
if self.token_ratio < 0.5:
return -1
(2)は、「りんごは林檎」のように、同じ単語を並べているだけの文を除外するためのルールです。「2.同じ読み・似たような音を持つ箇所を見つける」で得られた(X, Y)に対して「COTOHA API 類似度算出」を適用し、そのスコアで足切りしました。
self.similairty_ratio = self.tokens_similarity(self.x_form, self.y_form)
if self.similairty_ratio > 0.9:
return -1
(3)は、「ああああああああああ」のような同じ文字の羅列の文を除外するためのルールです。これは普通に頻度を計算するだけです。collections
のCounter
を使うとかっこよく書けます。
def single_character_occupancy(self):
return Counter(self.norm_sentence).most_common(1)[0][-1] / len(''.join(self.forms))
if 0.5 < self.single_character_occupancy():
return -1
B. ダジャレのスコア計算
上述の3つの足切りを突破したものに対してなんらかのスコアを付与します。
付与するスコアは、SCP-504のページの以下の記述を参考にしました。
関連する変数としては陳腐さ、ユーモア/長さ比、だじゃれの使用量があるようです。
この記述を今回は以下のように捉えます。
陳腐さ:ありふれたダジャレほど高スコア → Web検索エンジンのヒット件数
ユーモア/長さ比、だじゃれの使用量:文の長さや「同じ読み・似たような音を持つ箇所」
def tokens_occupancy(self, x_kana, y_kana):
x_len, y_lex = len(x_kana), len(y_kana)
total_len = len(''.join(self.kanas))
return (x_len + y_lex) / total_len
self.google_search_count = self.google.search_cnt(' '.join(self.forms))
unrelated_char_ratio = 1. - self.tokens_occupancy(self.x_kana, self.y_kana)
if self.google_search_count > 10:
speed = (math.log10(self.google_search_count) + len(self.forms) * self.alpha) * unrelated_char_ratio
else:
speed = len(self.forms) * self.alpha * unrelated_char_ratio
self.google_search_count
はGoogleでの検索結果件数、unrelated_char_ratio
はダジャレ部分と文の比、len(self.forms)
は文の長さ(形態素)で、これらを組み合わせて、スコア(speed)を決定しています。
なお、改善案や反論は色々あるとは思いますが、「一個人の主観マシマシなとりあえずの設計」としてご了承ください。
※ たとえば、unrelated_char_ratio
に関して、著者は「スマートにダジャレを表現していない」→「トマトの粛清対象」と判断し、不要に長いダジャレのスコア(速度)は高くなるように設計しました。
おわりに
この記事では、COTOHA APIを使って入力された文がダジャレかどうかを判断し、スコア(速度)を返すトマトをルールベースで実装しました。
ただ、ルールベースで実現しているため、色々と?????な箇所もあります。今後の課題とさせてくださいm(_ _ )m
TODO
- スコアリングの見直し
- 教師あり深層学習モデルの作成
- 深層強化学習モデルの作成