markovifyを使ってマルコフ連鎖でなろう小説のタイトルを自動生成するぞ!
こんにちは,しまさん(@shimasan0x00)です.
この記事を出しているときには明けましておめでとうございますになっているでしょうか.最近はというと卒論執筆と学会提出用の論文を書くというデスマーチな日々を送っています.
そんなこんなで卒論を書いてたり,他の人の論文を読むと「タイトル」と「概要」,「Intro」を書くのが非常に難しいことが素人な私でもわかります.
特に「タイトル」は自身のやった研究内容,主張すべき手法や該当研究の重要キーワードを含めてわかりやすく,かつインパクトがあるほうが好ましいとかどんだけ制約条件が厳しいんでしょうか.それに比べてTwitterで気軽に思った言葉と連ねることの楽さといったら最高です.
そんな卒論のような事実を連ねる文章形態とは違いますが小説もタイトルが非常に重要です.
小説は内容が素晴らしいことが必要条件であることはともかくWeb2.0以降,ユーザがコンテンツを自由に投稿することができるようになってWeb上の情報は氾濫しています.
ゆえにインパクトのあるタイトルでユーザを引きつけることは必須事項です.
ユーザ投稿型の小説投稿サイトで古くからある「小説家になろう(以下,なろう)」を例にとっても,出版されているライトノベルを例にとってもタイトルが長文化してきていることは皆さん感じ取られているのではないでしょうか.
また,なろうでは特に異世界転生ものが流行りに流行り,もはや物語のテンプレート/ジャンルが事実,共有知として受け入れられています.
異世界転生ものに限っていえば「小説家は剽窃家」と言葉遊びを貝木泥舟なら言いそうなものです.
小説を書いたことはありませんが内容があって,それを表する意味でタイトルはつくられるはずです.では逆にインパクトがあるタイトルが生成できれば内容を書きだすのは一発ネタとしては容易で面白いのではないでしょうか.
本来であれば流行りに乗ってDeepLearning的なアプローチで2周遅れくらいかもしれませんがLSTMやSeq2Seqなどを用いてタイトル生成するのが望ましいですが,比較できるものがあったほうがいいというのとマルコフ連鎖でもそこそこいい感じならわざわざコストかけなくていいんじゃないかと思ってマルコフ連鎖で今回はタイトル生成します.
p.s. 前口上を色々言ってますが,単純にやりたいからやってます.
環境
- Python3.6.8
- markovify-0.8.0
- SudachiPy 0.4.2
- SudachiDict-core 20191224
GitHub
技術選定
マルコフ連鎖はTwitterで自分のツイートを用いて自動で自分っぽい発言させるbotを作成している人がちらほらいますね.
「Python マルコフ連鎖」でググってみると2階のマルコフ連鎖(A,Bが与えられて次にどう遷移する?)のコードが多く,同一コードが多数見受けられました(コメントアウトまで同じものがあってホッコリしました).
今回は拡張がしやすいmarkovifyを使ってマルコフ連鎖のモデルを作成します.
また,形態素解析器にMecabとかJuman++とか使うのはいいんですけどせっかく制約がないのでGinzaやらSudachiを使いたいなと思って今回はSudachiPyを採用しました.
ワークフロー
- なろうAPIを用いて小説のタイトルを取得する
- 雑にオブジェクトを保存する
- markovify用に文字列を整形する
- markovifyでマルコフ連鎖のモデルを生成
- タイトル生成
markovifyのインストール
markovifyはシンプルかつ拡張性の高いマルコフ連鎖を生成するライブラリです.
pip install markovify
SudachiPyのインストール
SudachiはOSSの日本語形態素解析器の一つです.
語彙はUniDicとNeologdがベースでMecabに慣れてたらわかりやすいうえに,文字列の分割が3段階で調節できて固有表現抽出までいけるのでかなり嬉しい仕様になってます.
pip install SudachiPy
pip install https://object-storage.tyo2.conoha.io/v1/nc_2520839e1f9641b08211a5c85243124a/sudachi/SudachiDict_core-20191224.tar.gz
なろうAPIを用いて小説のタイトルを取得する「準備」
以前,大雑把になろうのAPIについて解説しています.
今回は基本的にGitHubのNotebookに記載したものを分割して書いていきます.
mkdir ndata
codeは以下
import json
import requests
import gzip
import time
import re
from datetime import datetime
import string
from pprint import pprint
import pandas as pd
import pickle
from sudachipy import tokenizer
from sudachipy import dictionary
tokenizer_obj = dictionary.Dictionary().create()
mode = tokenizer.Tokenizer.SplitMode.C
data_path = './ndata/'
## なろうAPIを試したい人は実行してみてください.
url = "http://api.syosetu.com/novelapi/api/?out=json&lim=1&gzip=5"
response = requests.get(url)
response.encoding = 'gzip'
r = response.content
res_content = gzip.decompress(r).decode("utf-8")
response_json = json.loads(res_content)
pprint(response_json)
Ncodeを生成する関数
なろうの小説を識別するのがncodeです.ncodeはn0000aのようにnと数字4桁,アルファベット1-2桁で構成されており,n9999aとなったらn0000bとなってまたn9999bとどんどん値が変化していきます.
以下に雑ではありますがncodeを生成する関数を用意しました.今回はn9999aで止めるようにしています.
def get_ncode_all():
'''
なろうのncodeを作成するGenerator関数
n0000a から n9999zzまでの文字列を順に作成し,ncodeを返す
今回はn9999aまでを返すようにする
'''
narou_ncode = 'n'
before_ncode_character = string.ascii_lowercase
after_ncode_character = string.ascii_lowercase
ncode_number = 0
# roop valiable i , j
i = 0
j = 0
while True:
ncode_number = str(ncode_number).zfill(4)
if j == 0:
ncode = narou_ncode + ncode_number + before_ncode_character[i]
else:
ncode = narou_ncode + ncode_number + before_ncode_character[i] + after_ncode_character[j]
ncode_number = int(ncode_number)
yield ncode
ncode_cap = ncode.upper()
# 今回の上限を設定する
if ncode_cap == 'N9999A':
break
# ncodeのxxxx[a-z][a-z]を判別する
if ncode_number == 9999:
i += 1
if i == 26:
if j == 26:
break
j += 1
i = 0
ncode_number = 0
ncode_number += 1
なろうAPIを用いて小説のタイトルを取得する「実践」
既存のncode辞書がなければ作成し,APIを使用してタイトルを取得します.
あとはnode:タイトルで様々な形式でオブジェクトを保存します.
後ほど使用するのは「narou_title_words_list.pkl」だけですがこれ以外でも使えるかもしれないので保存しています.
narou_title_dic = dict()
try:
with open(data_path+"narou_title_dic.pkl") as f:
narou_title_dic = pickle.load(f)
print('作成された辞書を使用します.')
except:
print('新規辞書データを作成し,使用します.')
count = 0
for narou_ncode in get_ncode_all():
url_before = "http://api.syosetu.com/novelapi/api/?out=json&gzip=5&ncode="
url = url_before + narou_ncode
if narou_ncode not in narou_title_dic:
time.sleep(1.09)
s = requests.Session()
response = s.get(url)
response.encoding = 'gzip'
r = response.content
res_content = gzip.decompress(r).decode("utf-8")
response_json = json.loads(res_content)
if response_json[0]['allcount'] == 1:
narou_title_dic[str(narou_ncode)] = response_json[1]['title']
else:
narou_title_dic[str(narou_ncode)] = '0'
if count % 1000 == 0:
print("now : ",datetime.now().strftime("%Y/%m/%d %H:%M:%S")," count : ",count)
count += 1
with open(data_path+"narou_title_dic.pkl","wb") as f:
pickle.dump(narou_title_dic,f)
narou_title_dic_s = dict()
for k,v in narou_title_dic.items():
narou_title_dic_s[str(k)] = v
df = pd.DataFrame(narou_title_dic_s,index=["nTitle"])
df = df.T
df_title = df[df['nTitle'] != '0']
with open(data_path+"narou_title_pd.pkl","wb") as f:
pickle.dump(df_title,f)
title_wakachi_word_set = set()
df_title_words_list = []
df_title_list = df_title['nTitle'].to_list()
for df_title in df_title_list:
# 前処理
df_title =re.sub(r'[!-~]', "", df_title)#半角記号,数字,英字
df_title =re.sub(r'[︰-@「」【】『』()]', "", df_title)#全角記号
df_title =re.sub(r'[「」【】『』()]', "", df_title)
df_title =re.sub(r'[〜。、]', "", df_title)#句読点
df_title =re.sub(r'\u3000', "", df_title)#全角スペース
df_title =re.sub(r' ', "", df_title)#半角スペース
df_title =re.sub('\n', "", df_title)#改行文字
wlist = [m.surface() for m in tokenizer_obj.tokenize(df_title, mode)]
for word in wlist:
if word != '':
title_wakachi_word_set.add(word)
if wlist != []:
df_title_words_list.append(wlist)
with open(data_path+"narou_title_vocab.pkl","wb") as f:
pickle.dump(title_wakachi_word_set,f)
with open(data_path+"narou_title_words_list.pkl","wb") as f:
pickle.dump(df_title_words_list,f)
markovifyでマルコフ連鎖のモデルを生成
ではmarkovifyを使用してマルコフ連鎖モデルを生成していきたいと思います.
元々日本語向けに作成されているものではないのでmarkovifyでclassをゴニョゴニョするか,入力を整形するかで対応します.
今回は入力を整形します.具体的には
- タイトルを分かち書きしたものを半角スペースで結合し,’\n’をつける
- 全ての1.で得たものを1つのオブジェクトとする
import pickle
import markovify # Default=2階マルコフ連鎖
data_path = './ndata/'
with open(data_path+"narou_title_words_list.pkl", 'rb') as f:
wakachi_list = pickle.load(f)
title_txt = ''
for title_l in wakachi_list:
nline = ''
for title in title_l:
nline += title + ' '
nline = nline[:len(nline)-1] + '\n'
title_txt += nline
# 改行で1文とする,今回は3階マルコフ.
text_model = markovify.NewlineText(title_txt, state_size=3)
タイトル自動生成してみる
今回はncodeがn9999aまでの10000小説のデータでタイトルを生成していきます.
実際には整形後に残った3936作品のタイトルデータになっています.現状なろうで残っている最古の作品はN0037Aの「総合調査会社アジディックファイル1ジャッジメント」で2004年に作成されています(15-6年前ってマジですか…).
とりあえず40作品ほど生成した結果をお見せします.
for _ in range(40):
sentence = text_model.make_short_sentence(max_chars=100, min_chars=20, tries=100).replace(' ', '')
print(sentence)
君といたときの中で・・・
しゃなりしゃなりとお姫さまのおなりぃ~ああ腹減った
いつも桜の木を見て君を詠む
一番近き愛しい人よどこにいても…
叶わぬ恋だと知っていても幸せでいて
そして星になったということ
私が私でいることと記すということ
気がつけばいつもそこに君がいた夏‐これからの僕‐
友と二人・・・・こっくりさん
「学生」という名の呪い恐怖のデパート
名の意味は神の仰せのままに
東京オカルトプロジェクト二つの物語が交錯するとき
トマトの胸の中には君がいた日々
雨とアスファルトと彼女の恋話
狐神リアル・・・そして出会い
噂が噂を本当になってね……
いつも桜の木を見て君を詠む
一歩先からヤミ集えぼくらの歌はあわわわぁ♪
僕と猫と彼女と魔法の関係式
あたしのお隣さんは極道さん
扉の向こうはミステリー第一章されど人はその歴史を知らず
笑わずの魔女と魔法の関係式
名の意味は神の仰せのままに
晴れのち雨またはあなたのそばで生きたい
魔法使いのいない国第一部友との絆・互いの思い外伝編
私が私でいることと記すということ
世界は耳障りな雨の中には君がいた日々
ここがゲームの世界だと誰もが知っていても幸せでいて
東京オカルトプロジェクト二つの物語が交錯するとき
東京オカルトプロジェクト二つの世界の誕生と崩壊の歴史
僕と猫と彼女と僕の気持ち
選択型お題背中オーサムコーラル国物語
トマトの胸の中にsinnerreturn
湖底の街に吹く風春野天使編
神剣慟哭する現代滝口譚・序
心臓が鳴るその音が鳴り止む前に
忍者がお家にやってこない親仁変
端的に物事を語れるほど僕らは太陽の下で
端的に物事を語れるほど僕らは太陽の下で
僕と猫と彼女と魔法の関係式
固有表現抽出のレベルを高くした+データサイズが大きくないので似たワードが見られると考えられますがそれっぽいタイトルがいくつかありますね.
しゃなりしゃなりとお姫さまのおなりぃ~ああ腹減った
扉の向こうはミステリー第一章されど人はその歴史を知らず
僕と猫と彼女と魔法の関係式
いつも桜の木を見て君を詠む
叶わぬ恋だと知っていても幸せでいて
そして星になったということ
気がつけばいつもそこに君がいた夏‐これからの僕‐
Web小説を書きたいなと思っていても案が浮かばない方はこれを作成して参考にするのも一興かもしれません.
さいごに
今回はmarkovifyを使ってマルコフ連鎖のモデルを作成し,なろう小説のタイトルを自動生成してみました.
今回のデータは古いものばかりだったので引き続きAPIを回して全データ回収し,もっと面白いタイトルを生成できたらと思います.分野に絞ったりすればもっと通用する,意味あるタイトルが生成できるでしょう.
次は概要も自動生成したいですね.ですがそれはタイトル生成も含めてLSTMやSeq2Seqで試したほうがいいでしょう.もし,今どきのDeepLearningのモデルでオススメがあったら是非教えて下さい!!