この記事は、traP夏のブログリレー29日目の記事です。
はじめに
こんにちは!皆さんはめちゃ面白い小説を読んだとき、「おもしろ小説書きて~」と思った事はありませんか?

私は小説最近”三体”という凄まじく面白い小説を読んで「おもしろ小説書きて~」と思い、過去に全く同じことを思った過去の私が勢いそのままに『小説家になろう』へ小説を投稿していたのを思い出しました。
それを読んでみたのですが、めちゃめちゃつまらなかったです。
私には...
私にはおもしろ小説を書けないのか...
じゃあAIに書かせたらええやん!!!!!!!!!!!!!!!!手法
既存の文章をお手本にして新たに文章を生成する方法としては
- マルコフ連鎖
- RNN(LSTM)
- GAN
等色々ありますが、今回はマルコフ連鎖を用いて実装しようと思います。(最後のほうにチラッとLSTMの実装も置いておきます)
GANも面白そうなのですが、私の理解力が足らず断念しました。
モデルの学習については小説投稿サイト”小説家になろう”から文章をお借りして学習データとします。
出来るだけ意味の分かる文章が作れると良いですね
実装
- なろう小説のサイトからスクレイピングによって小説本文を取得し、分かち書きして保存する
- モデルに学習させる
- 構築したモデルを用いて文章を生成する
といった流れで作っていきます。
実装にはPythonを使用します。外部ライブラリは
- BeautifulSoup - スクレイピングをする
- MeCab - 分かち書きをする
- markovify - N階マルコフ連鎖モデルを作る
を使用しました
この記事で説明するコードは全てこのリポジトリにあります。
小説本文のスクレイピング、分かち書き
モデルの学習毎にスクレイピングをするとサーバーへ負担をかけてしまうので、目次ページURL、ページ数を入力すると分かち書き後、文章単位で改行された本文を保存してくれるスクリプトを作ります。
ソースコードはこちら
# coding: UTF-8
import time
import re
import os
import MeCab
from urllib.request import urlopen
from bs4 import BeautifulSoup
tagger = MeCab.Tagger('-Owakati')
save_folder = 'save'
narou_url = 'https://ncode.syosetu.com'
def make_bsobj(url):
'''
引数:(str)対象のURL
返り値:(BeautifulSoup)BeautifulSoupオブジェクト
サーバーへの負担軽減の為アクセスの間隔を1秒開けます
'''
time.sleep(1)
return BeautifulSoup(urlopen(url).read().decode(
'utf-8', 'ignore'), "html.parser")
def format_text(t):
'''
引数:(str)テキスト
返り値:(str)フォーマット後のテキスト
markovifyが読み込むとエラーを吐き出す文字があるので、それを除去する
'''
return re.sub(r'[\._-―─!@#$%^&\-‐|\\*\“()_■×+α※÷⇒—●★☆〇◎◆▼◇△□(:〜~+=)/*&^%$#@!~`){}[]…\[\]\"\'\”\’:;<>?<>〔〕〈〉?、。・,\./『』【】「」→←○《》≪≫\r\u3000\u2000]+', "", t)
def get_bodies_from_url(url):
'''
引数:(str)各ページのURL(リゼロならhttps://ncode.syosetu.com/n2267be/1/ など)
返り値:(str)分かち書き後、文章単位で改行された本文
'''
bsobj = make_bsobj(url)
bodies = ''
for body in bsobj.findAll("div", {"id": "novel_honbun"})[0].findAll("p"):
formatted = format_text(body.get_text())
if formatted == '':
continue
bodies += tagger.parse(formatted)+'\n'
return bodies
def get_num_of_pages(url):
'''
引数:(str)目次ページURL(リゼロならhttps://ncode.syosetu.com/n2267be/)
返り値:(int)ページ数
'''
bsobj = make_bsobj(url+'/1')
return int(bsobj.select('#novel_no')[0].get_text().replace('1/', ''))
def main(url, pages_num):
id = url[url.strip('/').rfind('/'):len(url)] # saveフォルダ構成用に識別番号を取得
all_pages_num = get_num_of_pages(url) # 全ページ数の取得
for i in range(1, pages_num+1):
if i > all_pages_num:
break
bodies = get_bodies_from_url(url+'/'+str(i)) # 本文の取得
folder_path = save_folder+id
os.makedirs(folder_path, exist_ok=True) # 保存用フォルダの構成
with open(folder_path+'/'+str(i)+'.txt', mode="w", encoding='utf-8') as f:
f.write(bodies)
if __name__ == '__main__':
url = input(
"Enter the URL of the novel.\n(ex:https://ncode.syosetu.com/n2267be)\n>") # url
pages_num = int(
input("Enter the number of pages you want to save.\n>")) # 保存するページ数
main(url, pages_num)
- 目次ページURL
- 保存するページ数
を入力し、実行するとsave/n2267be/1.txt
のように各ページが保存されます
分かりにくい部分の解説
format_text(t)
- markovifyには読み込むとエラーを吐き出す文字があるので、それを除去する関数です
get_num_of_pages(url)
- 小説の全ページ数を返します。
- 以下の画像の部分に全ページ数が書いてあるので、それをCSSのセレクターから取得しています(urlは1ページ目を指定)

get_bodies_from_url(url)
- 本文が格納されている要素を
bsobj.findAll("div", {"id": "novel_honbun"})[0].findAll("p")
によって一括で取得しています - そして
format_text(body.get_text())
にて得られたフォーマット後の文章をtagger.parse(formatted)
で分かち書きしています。 +'\n'
と改行を加えているのは後に使用するmarkovify.NewlineText()
メソッドに形式を合わせる為です。

では次に、本題のマルコフ連鎖モデルを構築していきます
モデルの構築、文章の生成
ソースコードはこちら
# coding: UTF-8
import save_novel
import os
from glob import glob
import markovify
def main(url, word_size, out_max, out_min, out_num, first_word):
id = url[url.strip('/').rfind('/'):len(url)] # saveフォルダ識別番号
bodies = ''
if not os.path.exists(save_novel.save_folder+id):
print('Path does not exist.')
exit()
for file in glob(save_novel.save_folder+id+'/*.txt'):
with open(file, mode="r", encoding='utf-8') as f:
bodies += f.read() # txtファイルを読み込んで結合
markov_model = markovify.NewlineText(
bodies, state_size=word_size, well_formed=False) # マルコフ連鎖モデルを構成
for _ in range(out_num):
if(first_word != 'none'):
try:
sentence = markov_model.make_sentence_with_start(
max_words=out_max, min_words=out_min, tries=100, beginning=first_word).replace(' ', '') # 文章生成(最初の言葉あり)
except:
print(
'The word you entered may not exist in the novel.\nOr it may contain unidentifiable characters.')
exit()
else:
sentence = markov_model.make_sentence(
max_words=out_max, min_words=out_min, tries=100).replace(' ', '') # 文章生成(ランダム)
print('out', sentence)
if __name__ == '__main__':
print('If you haven\'t generated the data for your novel yet, run save_novel.py and run.')
url = input(
'Enter the URL of the novel.\n(ex:https://ncode.syosetu.com/n2267be)\n>')
word_size = int(input(
'Enter the state history (the more, the closer to the original).\n>'))
out_max = int(
input('Enter the maximum length of the text to be generated.\n>'))
out_min = int(
input('Enter the minimum length of the text to be generated.\n>'))
out_num = int(input('Enter the number of texts to be generated.\n>'))
first_word = input(
'Enter the first word of the sentence to be generated.To generate randomly, enter "none".\n>')
main(url, word_size, out_max, out_min, out_num, first_word)
- 目次ページURL
- 状態履歴の数
- 生成する文章の最大文字数
- 生成する文章の最小文字数
- 生成する文章の数
- 生成する文章の最初の言葉
を入力して実行すると、パラメータに沿った文章が生成されます

分かりにくい部分の解説
for file in glob
- 前のスクリプトで保存したtxtファイルを読み込んで
bodies
に全て結合 *.txt
を全て読み込んでいるが、かなり高速
markovify.NewlineText()
- マルコフ連鎖モデルを生成する
state_size
は状態履歴の数(N階マルコフ連鎖のNのこと)
markov_model.make_sentence()
- 文章を生成する
- 生成する文章の最初の言葉を設定する場合、
markov_model.make_sentence_with_start()
を使っている
では早速生成していきましょう!!!
生成する
リゼロから生成
- 状態履歴の数:2
- 生成する文章の最大文字数:100
- 生成する文章の最小文字数:15
- 生成する文章の数:100
- 生成する文章の最初の言葉:none
俺は進むしかないこれがどんな戦も大事戦う前に勝敗は決まってる
歌詞
俺がいる世界というのもそれが戦闘狂というものがあることは美しきかな
そうかな
相手の素姓が見えているからこそのハリボテの希望なのかも全部
ツイートっぽさがある
いえこっちで連れ帰るわその方がこっちも余計な気をつかわなくていいから
本当の日常会話
無職転生から生成
- 状態履歴の数:2
- 生成する文章の最大文字数:100
- 生成する文章の最小文字数:15
- 生成する文章の数:100
- 生成する文章の最初の言葉:none
途中で俺が臭いと一瞬で踵を返してくれるらしいんだ
そう...
ほうれんそうはキッチリと謝っておきたいんだが彼の逆鱗に触れた
律儀だね
暖房が完備されても呪いは消えない
確かに
おまけ(LSTMを使用した方法)
ソースコードはこちら
- tensorflow
- keras
- numpy
- MeCab
を使用しています。
保存された本文データをもとにLSTMモデルを構成しているので、マルコフ連鎖モデルと同じように目次ページURLを入力して実行します。

この様にEpochが進むごとに学習が深まっていきます。
最終的な出力は以下の様になりました(batch_size=128,epochs==30)
これは本気でヤバいと棍棒ってそれはこれ以上に何のかエルザのないでもがを的に頭とかはなんにかていたということだろうがトンとのにものは確認をなかったか今はお前らにてるてるだぜ俺その
異世界にもしかないにも理由だろうかの魔法器はじゃない今のこのては見とそれは無理だぜ頭俺はそのぜで通りをスバルは顔を上げると自分の腰にはとするがたできしかないなかった携帯の画面は
彼女にすればどの面を下げてというところだろうどんな罵声を浴びせだろうともいいだろうが見ているのだがそれを気にした風でもないのが気にしたようなことをあってからすればからにも体を売
っているのだなともちょっとはそうになとはスバルのような気がしてきているのは何度も顔を叩いて気持ちをに二回目の世界のようなのに名前はをそうでフェルトはその名前を呼んだってのことこ
とだけどそれは気をサテラと魔法器だから持っていたなにもがかなかったような気がしてきているのはそれを確認するとをもう今にまでてあっていたそのしていたロム爺は聖金貨の二人が全てはそ
の自分の手を当てて後ろに向けてが蔵の中にていると思うが今のこのてに見ともはないしはけどないのかなのによなエルザの声をロム爺の言葉を笑みはこっちスバルて顔なかっでのないエルザはそ
の刃をするとでエルザにロム爺の出したミルクの入ったグラスを傾けるフェルト彼女の赤い瞳からは警戒心
めちゃめちゃですね
なろう小説を自動生成するという目的だとこちらの方が一見良さそうに見えますが、マルコフ連鎖モデルに比べて幾つかの短所があります
1. 構築に時間がかかる
tensorflow-gpuを使用し(RTX3080Ti)、25000単語ので構成したモデルの学習が30Epochs終わるのに3分程度かかっています
これは私のLSTMの実装が簡素なものである事を考慮してもマルコフ連鎖モデルに比べて遅いです
マルコフ連鎖モデルは同じ文章量を0.1sec以下で学習できます
2. メモリが足りない
私の実装では単語数を25000語に制限しています。もしリゼロ50話分(185391語)でモデルを構築した場合
numpy.core._exceptions.MemoryError: Unable to allocate 160. GiB for an array with shape (185386, 5, 185391) and data type bool
とNumpy配列でMemoryErrorが出ます。
これはより効率的な実装をすることで回避できそうな気はします....
まとめ
思ったより文章っぽい文章が生成出来て満足です
みんなもマルコフ連鎖を使ってなろう小説を書こう!
明日は@d_etteiu8383さんの記事です。お楽しみに!