こんにちは。はっさくです!
最近、自然言語処理が流行ってますよね。なので私も文章の生成モデルを作ってみました。
先に遊んでみたい方はこちらのリンクからどうぞ!
詳しく知りたい人はWikipediaをご覧ください(定期)。
簡単にまとめると、ボジョレー・ヌーヴォーは次のような特徴を持ったワインです。
この記事ではキャッチコピーに注目します。
私はボジョレー・ヌーヴォーといえばキャッチコピーだと思っています。こんな歪んだ認知をしてしまっているのにも理由があります。
過去のキャッチコピーから数年分抜粋して紹介します。
年 | キャッチコピー |
---|---|
2009年 | 過去最高と言われた05年に匹敵する50年に一度の出来 |
2010年 | 2009年と同等の出来 |
2011年 | 100年に1度の出来とされた03年を超す21世紀最高の出来栄え |
どうでしょうか?これはひどくないですか?
ボジョレーヌーヴォーといえばキャッチコピーと思ってしまうのも無理ないですよね。
あまりにすごいので、相関図を作成してみました。
Wikipediaには2015年分までしか掲載されていなかったので、ボジョレーワイン委員会のホームページも参考にして作成しました。
ボジョレーワイン委員会のリンク(2022年分しか見つからなかった)
出来上がった相関図はこれです!
時系列に沿ったバージョン(超縦長)
1995〜1997年の数年に一度ラッシュは素晴らしいですね。そして10年経たずにそれを超えていく2001年。
過去最高と言われる2005年の翌年にそれに近い味が出ているのも興味深いです。
そしてそれに匹敵する2009年の翌年には同等のものが生産され、さらに翌年は21世紀最高が出るというインフレっぷり。
いつ見ても芸術的ですね。
そのうえで、今回の目標は次のとおりです。
🍷
ボジョレーヌーヴォーのキャッチコピーを学習して自動生成する
ここでいう「学習」とは、機械学習とかの学習です。今回使う方法はたぶん機械学習とは言えませんが、コンピュータを用いるということだけ覚えておいてください。
一世を風靡しているChatGPTがレベル100だとして、これから作るのはレベル0.01ぐらいです。あまり期待せずご覧ください。
実はここだけの話、ボジョレーヌーヴォーを題材に選んだのには「面白いから」以外の大きな理由があるのです。
それは、多少ぐちゃぐちゃになっても許容されるということです。元がぐちゃぐちゃなので。
ソムリエの食レポとかはレベルが高すぎて素人にはなんのことやらなので、それにプラスして「〇〇年に一度」のオンパレードと来れば完璧です。
では自動生成器はどうやって作ればいいのでしょうか。そう思いながらネットの海を泳いでいたときに、ある方法を発見しました。
それが「マルコフ連鎖」です。
「連鎖」とついているので想像がつく方もいるかもしれませんが、前から生成していって、それまでの単語の列から次に来そうな単語を予測する方法です。
「私は犬が」まで生成されているとき、次に来そうな単語は、例えば「好き」、「嫌い」でしょう。これらの候補のうちランダムで、または最も確率が高いものが選ばれます。
「私は犬」まで生成されていればおそらく次に「が」が来ると思います。エセ夏目漱石の文章を学習しない限りは、「です」は流石に来ないでしょうね。
今回使うのはその中でも最も初歩的なものです。
「私は犬が好きです。」という文章を学習する際に、次のように連鎖を作ります。
これは幅が3単語の区間で区切ったものを全列挙しています。
次にその3単語を2と1に分けます。「私は→犬」という感じです。
1つの文章を学習するだけでは、新たな文章は生成できません。他の文章も学習してみましょう。
「私はからあげが好きだった。」を学習します。
となります。
文字での説明には限界があるので図解してみましょう。
この図を見ると以下の4つの文章が同じ確率で生成されることがわかります。
こんな感じでボジョレーヌーヴォーのキャッチコピーを学習していきます。
💡
マルコフ連鎖
学習データはWikipediaや個人ブログから集めました。
信頼性の低い出典なのでもしかしたら学習データ自体がニセモノかもしれません。
学習用データは改行区切りのテキストデータとして保存しています。
学習用のデータは揃ったので、これを学習するところを実装しましょう。
言語は別になんでも良いですが、書きやすいPythonにします。
Pythonでテキストデータを読み込む時はwith文を使います。
1with open("/path/to/data.txt", "r") as f:
2 data = f.readlines()
変数data
は、キャッチコピーのリストになっています。
次にテキストを単語で区切る処理が必要です。分かち書きとも言われます。
「私は人間だ。」という文章を(私, は, 人間, だ, 。)に区切ります。また、文章の開始と終了にはそれを示す記号を追加します。(後の処理でこれがあると便利)
単語で区切る部分のアルゴリズムを自分で考えるのは難しいので、ライブラリの力を借りましょう。
今回使用するライブラリはMeCabです。詳しいインストール方法は公式のWebページをご参照ください。
MeCab: Yet Another Part-of-Speech and Morphological Analyzer
Pythonのライブラリをインストールするだけでは動かない点には注意してください。コマンドラインのMeCabをPythonで呼び出しているので本体をインストールしなければ動きません。
ここではインストールが済んでいるという前提で進めます。
1__BEGIN__ = "__BEGIN__"
2__END__ = "__END__"
3
4mecab = MeCab.Tagger("-O wakati")
5wakati_li = [[__BEGIN__] + mecab.parse(line).split() + [__END__] for line in data]
変数wakati_li
は文字列の二次元配列になっています。
ここからは少し込み入った話になります。
前の2単語から次の単語を予測するので、前の単語を入力すると、次の単語候補が返ってこないといけません。
さらに、次の単語は出現頻度が単語によって異なるので、次の単語を入力すると、出現回数が返ってくるデータも持たなければなりません。
出現回数がわかれば、各単語の出現確率は次のように計算できます。
ただし、は番目に登場する単語、は単語の登場回数で、ここでは前の2単語が決まった上での登場回数を表しています。または学習データに登場する全単語の集合です。
ゴールは上のようなデータ構造です。
このデータ構造を言葉で説明すると、「[2単語の系列を入力に与えると、(次の単語, 出現回数)を並べたリストが返ってくるというもの]のあつまり」、になります。
あとついでに文章の最初の単語も別で変数に入れます。
1from collections import defaultdict
2
3pred: defaultdict[tuple[str, str], defaultdict[str, int]] = defaultdict(lambda: defaultdict(int))
4starts: defaultdict[str, int] = defaultdict(int)
5for line in wakati_li:
6 starts[line[1]] += 1
7 for i in range(len(line) - 2):
8 pred[(line[i], line[i + 1])][line[i + 2]] += 1
ここまでで生成する準備が完了しました。
最初の単語が選ばれたら、そこから予測を繰り返すことで文章が生成されていきます。
予測が文章の終りを示す記号"__END__"
だったとき、予測が終了します。
1import random
2
3sentence = [__BEGIN__]
4next_word = random.choices(list(starts.keys()), list(starts.values()))[0]
5sentence.append(next_word)
6while next_word != __END__:
7 pre_words = (sentence[-2], sentence[-1])
8 next_word = random.choices(list(pred[pre_words].keys()), list(pred[pre_words].values()))[0]
9 sentence.append(next_word)
10print("".join(sentence[1:-1]))
では次の2つの文章を学習させて、20回生成しましょう。
【学習する文章】
1私は犬が好きです。
2私はからあげが好きだった。
【生成された文章】
1私はからあげが好きだった。
2私は犬が好きだった。
3私はからあげが好きだった。
4私は犬が好きだった。
5私は犬が好きだった。
6私は犬が好きです。
7私は犬が好きです。
8私はからあげが好きです。
9私はからあげが好きです。
10私はからあげが好きだった。
11私はからあげが好きです。
12私はからあげが好きだった。
13私はからあげが好きだった。
14私は犬が好きだった。
15私はからあげが好きだった。
16私は犬が好きだった。
17私はからあげが好きだった。
18私はからあげが好きだった。
19私は犬が好きだった。
20私はからあげが好きです。
マルコフ連鎖の説明のときに使った例ですが、ちゃんと4種類の文章が出力されていますね。
というわけで、この学習データをボジョレー・ヌーヴォーのキャッチコピーに置き換えれば完成です!
今日からあなたもソムリエですね!
再利用したいので、クラスにまとめましょう。
https://gist.github.com/ayu0616/794f9c6a56f28a21cdcb2c4be06862fc
1import random
2from collections import defaultdict
3
4import MeCab
5
6class Marcov:
7 __BEGIN__ = "__BEGIN__"
8 __END__ = "__END__"
9 mecab = MeCab.Tagger("-O wakati")
10
11 def __init__(self, data_path: str | None = None, text_data: list[str] | None = None) -> None:
12 self.text_data: list[str] = []
13 if data_path:
14 self.load_textfile(data_path)
15 if text_data:
16 self.text_data.extend(text_data)
17
18 def __wakati(self, text: str) -> list[str]:
19 return [self.__BEGIN__] + self.mecab.parse(text).split() + [self.__END__]
20
21 def load_textfile(self, data_path: str):
22 with open(data_path, "r") as f:
23 self.text_data.extend(f.readlines())
24
25 def train(self):
26 self.pred_dict: defaultdict[tuple[str, str], defaultdict[str, int]] = defaultdict(lambda: defaultdict(int))
27 self.start_dict: defaultdict[str, int] = defaultdict(int)
28 wakati_li = [self.__wakati(line) for line in self.text_data]
29 for line in wakati_li:
30 self.start_dict[line[1]] += 1
31 for i in range(len(line) - 2):
32 self.pred_dict[(line[i], line[i + 1])][line[i + 2]] += 1
33
34 def generate(self):
35 if self.pred_dict is None:
36 raise Exception("Please train before generate")
37 sentence = [self.__BEGIN__]
38 next_word = random.choices(list(self.start_dict.keys()), list(self.start_dict.values()))[0]
39 sentence.append(next_word)
40 while next_word != self.__END__:
41 pre_words = (sentence[-2], sentence[-1])
42 next_word = random.choices(list(self.pred_dict[pre_words].keys()), list(self.pred_dict[pre_words].values()))[0]
43 sentence.append(next_word)
44 return "".join(sentence[1:-1])
私は独学でプログラミングを習得したので、クラスの作りのお作法みたいのものがもしあるなら全然わかんないんですよね。もし変な所があれば指摘してください。
ボジョレーヌーヴォーのキャッチコピー生成器が完成しました!
この方法を使った生成器をこちらのページで配信しているので、ぜひご覧ください!
ページ内の「ぐちゃぐちゃ」モードは、今回の方法の発展版です。好評だったら別の記事で解説するかもしれません。