「なろう小説検知器」を作ったので解説

プログラミング

勉強の傍らくだらないものを作ることに定評のある管理人ことぴのです。前回お話したように、深層学習を使って「なろう小説」と「普通の小説」をタイトルから分類する試みをしたので、解説を交えて記録に残しておこうと思います。前回↓

前回の終わりに「完成したらホームページに埋め込む」的なことを言いましたが、結論から言うと無理でした。理由としては、レンタルサーバーで使えるpipコマンド(Pythonのライブラリをインストールするやつ)のバージョンが古くて、深層学習するのに使うTensorFlowをインストールできなかったからです。年額1500円ぽっちの格安サーバーなので仕方ないと割り切りましょう。サーバー代はケチるな(戒め)

今回のソースコードはかなり長いので、特に深層学習に興味ない人は日本語の部分だけ流し見するくらいがちょうどいいと思います。

なろう小説検出器

概要

小説のタイトルを入力すると、それがなろう小説であるか否かを0~1の間で判定するプログラムです(1に近いほどなろう小説っぽい)。今回は、「小説をよもう!」=なろう小説、「星空文庫」=なろう小説でない、として学習させました。

開発環境

いつものように「Google Colaboratory」上で開発していきます。

形態素解析器「MeCab」のインストール

以下のコマンドを実行して形態素解析器「MeCab」をインストールします。前々回使った「Janome」のほうが導入は楽なのですが、こっちのほうが処理が早いらしいです。知らんけど。

!apt install aptitude
!aptitude install mecab libmecab-dev mecab-ipadic-utf8 git make curl xz-utils file -y
!pip install mecab-python3==0.7
!git clone --depth 1 https://github.com/neologd/mecab-ipadic-neologd.git
!echo yes | mecab-ipadic-neologd/bin/install-mecab-ipadic-neologd -n
!sed -e "s!/var/lib/mecab/dic/debian!/usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd!g" /etc/mecabrc > /etc/mecabrc.new
!cp /etc/mecabrc /etc/mecabrc.org
!cp /etc/mecabrc.new /etc/mecabrc

ウェブスクレイピング

例のごとく各サイトから小説のタイトルを10000件ずつ抽出し、リストにしています。20000件読みだすのに30分くらいかかるのでゲームでもしながら待ちましょう。

!pip install requests
!pip install beautifulsoup4
!pip install tqdm
import requests #HTMLを受け取るやつ
from bs4 import BeautifulSoup #HTMLを分解するやつ
from tqdm import tqdm #forの進捗具合を表すやつ

TITLE = 10000
narous = []
notnarous = []
headers = {'User-Agent' : 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36'}

#小説を読もう!
for i in tqdm(range(int(TITLE/20))): #20title/page
  url = 'https://yomou.syosetu.com/search.php?&order_former=search&order=hyoka¬nizi=1&p=' + str(i+1)
  response = requests.get(url, headers = headers)
  soup = BeautifulSoup(response.text, "html.parser")
  elems = soup.find_all('a', class_ = 'tl')
  for elem in elems:
    narous.append(elem.text.replace(' ', ''))

#星空文庫
for i in tqdm(range(int(TITLE/10))): #10title/page
  url = 'https://slib.net/works/?type=1&length=©=&rating=&pickup=&tag%5B%5D=on&index=' + str(i+1)
  response = requests.get(url, headers = headers)
  soup = BeautifulSoup(response.text, "html.parser")
  elems = soup.find_all('h2')
  for elem in elems:
    notnarous.append(elem.contents[0].text.replace(' ', ''))

タイトルデータを保存

毎回ウェブスクレイピングするのは時間の無駄なので、タイトルリストをCSV形式で保存します。左のメニューからGoogleドライブをマウント(連携)するのを忘れずに。

import csv

#なろう書き込み
with open('/content/drive/My Drive/naroutitle10000.csv', 'w') as file:
  writer = csv.writer(file, lineterminator='\n')
  writer.writerow(narous)

#notなろう書き込み
with open('/content/drive/My Drive/notnaroutitle10000.csv', 'w') as file:
  writer = csv.writer(file, lineterminator='\n')
  writer.writerow(notnarous)

テキストエンコーダ

深層学習にかけるためには、文字列を数値ベクトルに変換する必要があります。そのため、タイトルを形態素解析(単語に分解)し、単語→数値の割り当てをするエンコーダを作ります。

from collections import Counter
import codecs
from sklearn.model_selection import train_test_split
import MeCab

PADDING_INDEX = 0
UNKNOWN_INDEX = 1
PADDING_TOKEN = '<pad>'
UNKNOWN_TOKEN = '<unk>'

class JapaneseTextEncoder:
  #初期化
  def __init__(self, corpus):
    reserved_tokens = [PADDING_TOKEN, UNKNOWN_TOKEN]
    self.tagger = MeCab.Tagger("-Owakati")
    self.tokens = Counter()

    for sentence in corpus:
      self.tokens.update(self.tokenize(sentence))
      self.itos = reserved_tokens.copy()
      self.stoi = {token: index for index, token in enumerate(reserved_tokens)}
      for token, cnt in self.tokens.items():
        if cnt >= 1:
          self.itos.append(token)
          self.stoi[token] = len(self.itos) - 1

  #語彙リスト
  @property
  def vocab(self):
    return self.itos

  #語彙→数値の辞書
  @property
  def word2id(self):
    return self.stoi

  #数値→語彙の辞書
  @property
  def id2word(self):
    return {index: token for token, index in self.stoi.items()}
  
  #文字列→数値ベクトル
  def encode(self, sentence):
    tokens = self.tokenize(sentence)
    indices = [self.stoi.get(token, UNKNOWN_INDEX) for token in tokens]
    return indices

  #数値ベクトル→文字列
  def decode(self, indices):
    tokens = [self.itos[index] for index in indices]
    return "".join(tokens)

  #文字列→(単語)
  def tokenize(self, sentence):
    return self.tagger.parse(sentence).strip().split()

データローダー

CSVからタイトルリストを呼び出し、先ほどのエンコーダーで全てのタイトルを数値ベクトルにエンコードして、学習用データとテスト用データに分ける関数です。

def load_data_m():
  dataset = []

  #なろう読み込み
  with open('/content/drive/My Drive/naroutitle10000.csv', 'r') as file:
    reader = csv.reader(file)
    for row in reader:
      narous = row

  #notなろう読み込み
  with open('/content/drive/My Drive/notnaroutitle10000.csv', 'r') as file:
    reader = csv.reader(file)
    for row in reader:
      notnarous = row

  #ラベル付け
  for narou in narous:
    dataset.append((narou, 1))

  for notnarou in notnarous:
    dataset.append((notnarou, 0))

  sentences, labels = list(zip(*dataset))
  sentences, labels = list(sentences), list(labels)

  #エンコード
  encoder = JapaneseTextEncoder(sentences)
  dataset = [encoder.encode(sentence) for sentence in sentences]

  #訓練データとテストデータに分割
  train_x, test_x, train_t, test_t = train_test_split(dataset, labels, test_size=0.1)
  train_data, test_data = (train_x, train_t), (test_x, test_t)
  return train_data, test_data, encoder

データ整形

深層学習への入力は固定長ベクトルでないといけないので、数値ベクトルに変換されたタイトルの後ろを切ったり0で埋めたりして長さを揃えます。

import numpy as np
import tensorflow as tf
from tensorflow import keras

#データロード
(train_x, train_t), (test_x, test_t), encoder = load_data_m()

#数値ベクトルの長さを揃える
train_x = keras.preprocessing.sequence.pad_sequences(train_x, value=PADDING_INDEX, padding="post", maxlen=32)
test_x = keras.preprocessing.sequence.pad_sequences(test_x, value=PADDING_INDEX, padding="post", maxlen=32)

#学習のためNumPy用のリストに変換
train_x = np.array(train_x)
train_t = np.array(train_t)
test_x = np.array(test_x)
test_t = np.array(test_t)

#訓練データからさらに訓練中用検証データを分割
p = int(TITLE/3)
x_val, y_val = train_x[p:], train_t[p:]
partial_x_train, partial_y_train = train_x[:p], train_t[:p]

モデル構築

それっぽいモデルを構築します。自然言語処理に使われるEmbedding層とLSTM層がこのモデルのキモです。

n_vocab = len(encoder.vocab)

#モデル構築
model = keras.Sequential([
    keras.layers.Embedding(n_vocab, 64),
    keras.layers.Bidirectional(keras.layers.LSTM(64)),
    keras.layers.Dense(64, activation='relu'),
    keras.layers.Dense(32, activation='relu'),
    keras.layers.Dropout(0.5),
    keras.layers.Dense(1, activation='sigmoid')
])
#model.summary()

#コンパイル
model.compile(loss='binary_crossentropy', #損失関数
              optimizer='adam', #モデル更新方法
              metrics=['accuracy']) #正解率を監視

学習&テスト

モデルを訓練させます。

history = model.fit(
    partial_x_train,
    partial_y_train,
    epochs=20,
    batch_size=1024,
    validation_data=(x_val, y_val),
    verbose=1
)

訓練が終わったら、テスト用データで精度を検証します。今回のモデルの場合は93%程度の精度であることがわかります。

results = model.evaluate(test_x, test_t, verbose=2)
print(results)
#38/38 - 0s - loss: 0.2712 - accuracy: 0.9282
#[0.27115824818611145, 0.9282137155532837]

モデルのセーブ&ロード

毎回学習させるのは時間の無駄なので、学習済みモデルを適宜セーブ&ロードして使います。

model.save('/content/drive/My Drive/narou_model.h5')
model = keras.models.load_model('/content/drive/My Drive/narou_model.h5')
model.summary()

予測してみる

#(コメント)部分は「変換後の数値ベクトル」と「なろう可能性」を表しています。

title = "異世界に転生した俺、魔術チートで無双する"
print(encoder.encode(title))
predictions = model.predict(encoder.encode(title))
print(predictions[0][0])
#[9, 28, 97, 64, 44, 65, 37, 667, 224, 41, 73, 161]
#0.84435916
title = "パーティーから追放された俺、辺境の村でスローライフします"
print(encoder.encode(title))
predictions = model.predict(encoder.encode(title))
print(predictions[0][0])
#[246, 22, 364, 365, 135, 44, 65, 37, 248, 26, 670, 41, 99, 64, 90]
#0.7218301

↑適当に作ったそれっぽいタイトルはいい感じになりました。

title = "劣等職の最強賢者 ~底辺の【村人】から余裕で世界最強~"
print(encoder.encode(title))
predictions = model.predict(encoder.encode(title))
print(predictions[0][0])
#[971, 413, 26, 67, 221, 10498, 792, 26, 33, 346, 34, 22, 2476, 41, 112, 67, 10498]
#0.51488334

↑こちらは微妙な結果に。あまりに危険ワードが多すぎて一周回ってなろうっぽくないのかもしれん。

title = "とある科学の超電磁砲"
print(encoder.encode(title))
predictions = model.predict(encoder.encode(title))
print(predictions[0][0])
#[1]
#0.5725162
 BOS/EOS,*,*,*,*,*,*,*,*
とある科学の超電磁砲 名詞,固有名詞,一般,*,*,*,とある科学の超電磁砲,トアルカガクノレールガン,トアルカガクノレールガン
 BOS/EOS,*,*,*,*,*,*,*,*

↑なんか数値ベクトル変だなと思ったら「とある科学の超電磁砲」が固有名詞としてMeCabの辞書に登録されていました。なんじゃそら(笑)これは検証のしようがないですね。他の有名タイトルも同様でした。

title = "不思議な時計"
print(encoder.encode(title))
predictions = model.predict(encoder.encode(title))
print(predictions[0][0])
#[6929, 174, 6895]
#0.26180232

↑管理人が小学生の時に書いた短編小説のタイトルは0.26となろう小説度は低めでした。よかったよかった。

動かしてみた感じ、非常に短いタイトルは正答率が低い傾向がありそうです。そのようなタイトルは数値ベクトルに変換した際に未知語を表す数値のみで埋まってしまう可能性が高く、区別できずに正しい出力にならない場合が多いと考えられます。

おわりに

というわけで「なろう検出器」でした。ホームページに埋め込みたかった…

これからも研究の傍らいろいろ作っていこうと思うので、応援よろしくお願いします!

参考

コメント

タイトルとURLをコピーしました