漢字歌詞クイズのヒント機能をTFIDFで作った - 5月 17, 2020 こんにちは、ぐぐりら(@guglilac)です。 今回は、コロナによる外出自粛で暇なので休日に開発が進んでいる漢字歌詞クイズアプリに追加した新機能について書いてみようと思います。 ここまではあまり工夫した点もなく実装できていたのですが、今回つけたヒント機能は少し凝った(データサイエンスっぽい?)ことをしたので、せっかくなので記事にします。 ## 漢字歌詞クイズって? 趣味で開発しているTwitter BotとWebアプリです。 漢字のみで書かれた歌詞からタイトルを当てる、というクイズです。 記事執筆時点では、Twitterは問題のみ掲載、Webアプリでは四択形式で答えあわせあり、といった形式になっています。 [Webアプリ版](https://kanji-lyric.herokuapp.com/quiz) [Twitter Bot](https://twitter.com/kanji_lyric) 最初はTwitter Botとして運用を始め、しばらく運用していましたが、せっかくHerokuを使っているのでWebアプリの方もプランを変えずに同時に公開できるじゃんと思い、Web版も作り、今に至ります。 ## 新機能開発の背景 Webアプリ版を公開して、友人などにお試しで使ってもらって、いくつかフィードバックをもらったのですが、方々から **そもそも問題が難しすぎる** という意見をいただきました。 そもそもTwitter Botの方はアーテイストのコアなファンが多いので、そのような意見があまり出てこなかったのですが、一般には難しいのだな、という認識に変わりました。 そこからは、今回の記事では書かないですが、難易度を選べる機能をつけたりなど「いかに解きやすくするか」に焦点を当てて改善してきています。 今回つけた新機能であるヒント機能も、問題の難易度を下げる工夫の一つです。 ## 新機能 : ヒント機能 百聞は一見にしかず、ということでこんなかんじです。 RADWIMPSのEasyモードで試してみましょう。RADWIMPSの曲を幾つか知ってる方はわかるかな? (そういえばRAD、サブスク解禁しましたね。歓喜) どうでしょう? ここでわかればいいですが、わからない場合は諦める前に右上のヒントボタンを押すと、、、 この画像のように、右上のボタンをオンにすると歌詞の中で特徴的なフレーズを赤字でハイライトしてくれます。 * 喜怒哀楽 * 縦横無尽 * 起承転結 がハイライトされています。これでわかった人もいる(いてほしい)はず! この問題の正解は「君と羊と青」でした。 米津玄師でもやってみると 「**苦**」が多いですね。「苦いレモンの匂い~」、あ、Lemon! 正解はLemonでした。 ## 特徴的なフレーズの抽出 特徴的なフレーズの抽出方法については、 * その曲では頻繁に出現するが他の曲ではあまり含まれない * 漢字だけにしたときに原型をとどめている の二点を満たしているものを特徴的なフレーズと考えました。 一点目は自然言語処理としてはおそらく一般的なものだと思いますが、今回の問題の性質上「もとの歌詞が表示されない」こと考慮すると、二点目の性質も満たしてほしいと考えられます。 例えば、UNISON SQUARE GARDENの「シュガーソングとビターステップ」という曲には「蓋然性合理主義」というフレーズが一度だけ出現します。 これは頻繁には出現しないので一点目の性質はあまり強くないですが、リスナーの方からすれば「蓋然性合理主義」といえば「シュガーソングとビターステップ」だよね、となるので、ヒントとして抽出されてほしいわけです。 なので、今回は上の二点を考慮して特徴的なフレーズを抽出しました。具体的には * 漢字の塊に分かち書きしてTFIDFを算出 * 分かち書きされた漢字列の長さを考慮 の二点を使いました。 一点目については、 BUMP OF CHICKENの「天体観測」であれば `午前二時フミキリに望遠鏡を担いでったベルトに結んだラジオ雨は降らないらしい` を次の関数にいれて ```python def split_into_kanji_list(lyric): """漢字以外の文字を区切り文字として歌詞を漢字のリストに変換する.""" return regex.findall(r'\p{Han}+', lyric) ``` ```python ["午前二時", "望遠鏡", "担", "結", "雨", "降"] ``` と分割し、これらを単語としてgensimのTFIDFを計算するモデルに突っ込んだだけです。 一点目だけであれば、これで計算したTFIDFの高い方から幾つか返せば良さそうです。 上位のフレーズを見てみると、(フレーズ, TFIDF)の形式でこのようになります。 ```python [['望遠鏡', 0.4737280695065078], ['天体観測', 0.2842368417039047], ['担', 0.2842368417039047], ['星', 0.2218027413096079], ['握', 0.21956289875449875], ['一人追', 0.18949122780260313], ['二人追', 0.18949122780260313], ['二分後', 0.18949122780260313], ['午前二時', 0.18949122780260313], ['静寂', 0.16228808454422766]] ``` (今回は簡単に済ませようと思い、ヒントに曲名が入ってもとりあえずそのまま表示しています。) これでもだいたい良さそうですが、「午前二時」がもうちょい上にきてほしい感はあります。 二点目の、`漢字歌詞にしたときに元の歌詞の原型をとどめている`を考慮するために、今回はフレーズの長さを使ってスコアにしました。 漢字だけにしたときにフレーズが長いほど、元の歌詞と一致している部分が長いわけなので、これである程度実現できるのではと考えました。 ```python sorted(text_tfidf, key=self.sort_function, reverse=True) def sort_function(self, x): """x[0]が単語, x[1]がtfidf値""" return x[1] + self.w_len * len(x[0]) ``` 最終的に、tfidfの値だけでソートするのではなく、フレーズの長さの定数倍を足してからソートするようにしました。 フレーズの長さをそのまま使うとtfidfの影響が小さくなりすぎてしまうので、`w_len`で重み付けしています。 `w_len=1/6`で回すと結果が変わることがわかります。 ```python [['望遠鏡', 0.4737280695065078], ['天体観測', 0.2842368417039047], ['午前二時', 0.18949122780260313], ['一人追', 0.18949122780260313], ['二人追', 0.18949122780260313], ['二分後', 0.18949122780260313], ['一度君', 0.09474561390130157], ['予報外', 0.09474561390130157], ['全部覚', 0.09474561390130157], ['大袈裟', 0.09474561390130157], ``` 文字数が長いほど上位に来やすいわけです。この値はちょうどいい値を探す必要があります。 gensimのtfidfはデフォルトで正規化して返してくるので、TFIDFのmaxが1となっていることを考慮して`w_len`を決めると良さげです。 とりあえず`w_len=1/6`がしっくりきたのでこの設定でデプロイしています。 ## ハイライトする部分 完全に蛇足ではありますが、ヒントをどうやって赤字にハイライトするかもちょろっと書きます。 オンオフでclass名をいじればいいので、ヒントのフレーズだけspanタグとかで囲んで渡してあげればよいのですが、 単純にspanで囲んでテンプレートに渡してもそのままタグが表示されてしまいます。 解決策としては、FlaskのJinja2には、自作フィルターというものが作れて、viewから渡された変数を表示する際に関数をかませることができるので、それを使いました。 あらかじめヒントフレーズを何らかのトークンで囲っておいて ```python def decorate_with_hint(self, kanji_lyric, hint_phrase_list): for phrase in hint_phrase_list: kanji_lyric = kanji_lyric.replace( phrase, f"[SEP_S]{phrase}[SEP_E]") return kanji_lyric ``` viewで自作フィルターを定義します。 ```python @app.template_filter("sep2tag") def sep2tag_filter(s): return escape(s).replace("[SEP_S]", Markup('')).replace("[SEP_E]", Markup('')) ``` template側でフィルターを使えば、spanタグで囲ってくれます。 hintクラスをつけたのであとはオンオフでクラス名をいじってあげればよいですね。 ``` {{quiz["question"]|sep2tag}} ``` ## ゆるぼ 漢字歌詞のヒントフレーズをトークンで囲む処理を、複数回`replace`した ```python def decorate_with_hint(self, kanji_lyric, hint_phrase_list): for phrase in hint_phrase_list: kanji_lyric = kanji_lyric.replace( phrase, f"[SEP_S]{phrase}[SEP_E]") return kanji_lyric ``` わけですが、フレーズのリストのうち前半のフレーズが後半のフレーズを含んでいると、置換した後の結果をまた置換してしまうことになってしまいます。 ```python ["あいうえお","いう"] ``` とかだと、一度"あいうえお"をヒントで囲って、もう一度"いう"を囲んでしまいます。 とりあえず自分はそのような包含関係があったらその単語をswapする処理を先にしておくという処置をしましたが、もっといい方法あったら教えてください! ## 追記(2020/06/09) ヒントの個数が少なく(本番環境では三つ)、またヒントフレーズに包含関係が生まれること自体がレアだったため、前回実装した包含関係があったらswapする方法で問題なく動いていましたが、本当はこれでは正しく動作しないケースがある(`["こんにちは", "にちは", "にち", "は"]`などでも正しく動かない)ので、トポロジカルソートを使って実装し直しました。 phraseをノードとして、phrase A in phrase B の時に A->Bのエッジを張って有向グラフを作ります。閉路があるとトポロジカルソートできないので phrase A in phrase Aでもエッジは作らないことに注意します。 ```python def resolve_inclusion(word_list): edges = [] for i, word1 in enumerate(word_list): for j, word2 in enumerate(word_list): if (word1 != word2) and (word1 in word2): edges.append((i, j)) result = topological_sort(edges, len(word_list)) return [word_list[i] for i in result] ``` トポロジカルソートは次のように書きました。 ``` def topological_sort(edges, v): """ :params edges: [(親ノード,子ノード),...] :params v: ノード数 :return: トポロジカルソートしたノード番号リスト. """ outs = defaultdict(list) ins = defaultdict(int) for v1, v2 in edges: outs[v1].append(v2) ins[v2] += 1 q = deque(v1 for v1 in range(v) if ins[v1] == 0) res = [] while q: v1 = q.popleft() res.append(v1) for v2 in outs[v1]: ins[v2] -= 1 if ins[v2] == 0: q.append(v2) return res ``` 手順としては、 1. 入次数が0のノードをキューに入れて初期化 2. キューが空になるまで、3-5を繰り返す 3. キューの先頭ノードを取り出しリストに追加 4. 取り出したノードの子ノードの入次数を1減らす。 5. 入次数が0のノードがあればキューに追加 です。シンプルです。 (トポロジカルソート...そういえば最近AtCoderやれてないですね...) ## おわりに 外出自粛もあって継続的に開発を進めてきたのですが、そろそろ改善ネタがなくなってきました... 4択の出し方がランダムであるところが工夫の余地ポイントだと思いますが、その結果クイズの難易度が変わるほどの影響があるか?と言われると微妙でボツになりかけています。 そろそろ開発は控えて他の勉強をしようと思います。あー楽しかった 最後まで読んでいただきありがとうございます! この記事をシェアする Twitter Facebook Google+ B!はてブ Pocket Feedly コメント
コメント
コメントを投稿