Recommendation Systemを考える その4

Recommendation Systemを考える その3の続き。

1.やりたいこと

その3でやりたかったことの続き。回帰ではなく分類で予測してみる。

2.分類

データを読み込む

前回作成したDoc2Vecデータを読み込み、配列に変換する。

from gensim.models.doc2vec import Doc2Vec
from pathlib import Path

MODEL_DATA_PATH = str(Path.home()) + '/vscode/syosetu/data/search_novel_doc2vec_100.model'

m = Doc2Vec.load(MODEL_DATA_PATH)
vectors_list = [m.dv[n] for n in range(len(m.dv))]

データ処理しやすいように、必要な情報をPandasにまとめる。

import pandas as pd
import numpy as np

MODEL_TITLES_CSV_PATH = str(Path.home()) + '/vscode/syosetu/data/search_novel_titles.csv'

df = pd.read_csv(MODEL_TITLES_CSV_PATH, thousands=',')
df = df.drop(columns=['URL'])
df['VECTORS'] = vectors_list
データを加工する

評価ポイントから 不要な文字を取り除き、数値に変換する。

df['EVALUATION'] = df['EVALUATION'].str.replace(',', '')
df['EVALUATION'] = df['EVALUATION'].str.replace('評価受付停止中', '').astype(float)

df['EVALUATION'].describe()

count      2000.000000
mean      32735.061500
std       33540.729876
min        4019.000000
25%       14118.000000
50%       21271.000000
75%       38219.250000
max      323929.000000
Name: EVALUATION, dtype: float64

評価ポイントの値によって5つくらいに分類する。

df['EVALUATION_BAND'] = pd.cut(df['EVALUATION'], 5)
df[['EVALUATION_BAND', 'EVALUATION']].groupby(['EVALUATION_BAND'], as_index=False).count().sort_values(by='EVALUATION_BAND', ascending=True)

EVALUATION_BAND				EVALUATION
0	(3699.09,   68001.0]	1801
1	(68001.0,  131983.0]	149
2	(131983.0, 195965.0]	38
3	(195965.0, 259947.0]	6
4	(259947.0, 323929.0]	6

3699.09から68001.0が最も多く1801件も存在する。逆に予測したい評価の高い小説は6件しか存在しない。今回予測したいのは数の少ない小説の方なので、このままでは正しく予測することが出来ない。そこで区分2、3、4を一つのグループ、区分1のグループ、区分0のグループに分けることにする。

df.loc[ df['EVALUATION'] <= 68001, 'EVALUATION'] = 0
df.loc[(df['EVALUATION'] >  68001) & (df['EVALUATION'] <= 131983), 'EVALUATION'] = 1
df.loc[ df['EVALUATION'] > 131983, 'EVALUATION'] = 2

df = df.drop(['EVALUATION_BAND'], axis=1)
df['EVALUATION'].value_counts()

0.0    1801
1.0     149
2.0      50
Name: EVALUATION, dtype: int64

区分2の50本に合わせて他の区分からランダムに50本の小説を抽出することにする。

df_0 = df[df['EVALUATION'] == 0].sample(n=50, random_state=0)
df_1 = df[df['EVALUATION'] == 1].sample(n=50, random_state=0)
df_2 = df[df['EVALUATION'] == 2]

df = pd.concat([df_0, df_1, df_2])
作成したデータを元に予測する

先程作成したデータをトレーニングデータとテストデータに分ける。

from sklearn.model_selection import train_test_split

X = pd.DataFrame(df['VECTORS'].tolist(), index=df.index)
X.columns = [f'No{i+1}' for i in range(len(X.columns))]
y = df['EVALUATION']

X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0)

試しにランダムフォレストにかけてみる。

from sklearn.ensemble import RandomForestClassifier

random_forest = RandomForestClassifier(random_state=0)
random_forest.fit(X_train, y_train)

random_forest.score(X_test, y_test)

0.5263157894736842
random_forest.predict(X_test)

array([2., 1., 2., 2., 2., 1., 0., 1., 1., 0., 2., 1., 2., 2., 1., 0., 1.,
       1., 2., 1., 0., 2., 2., 0., 0., 1., 2., 1., 1., 0., 2., 2., 1., 2.,
       2., 2., 2., 1.])

5割を少しだけ超えるほどの精度での予測になった。このままチューニングしても良い結果は得られそうにない。トレーニングデータの作成からやり直す必要がありそう。

3.まとめ

評価ポイントを文章のみから予測するのは難しそう。

Recommendation Systemを考える その3

Recommendation Systemを考える2の続き。

1.やりたいこと

投稿されて間もない小説はユーザーによる評価がされていない場合が多いため、初期の評価ポイントはその小説の本来のおもしろさを反映していないと思われる。
小説の内容を機械学習で評価して、今後付くであろう評価ポイントを予測する。
最終的には新規投稿された小説、もしくは評価受付設定offになっている小説から評価ポイントが高くなると予想される小説を抽出するプログラムを作成する。

2.小説のスクレイピング

これまでの方法は累積ランキングから小説をダウンロードするため、Top300までしか小説を参照出来なかった。そのため、常にデータが不足していた。今回は方法を変えて小説検索画面から小説を参照するためのURL一覧を作成する。

まずは「小説を読もう!」の検索画面のURLを生成し、検索結果一覧を取得する。検索結果一覧は1ページあたり20リンクあり、最大100ページまで遡って表示する事ができる。その取得した結果をテキストファイルに書き出しておく。

import requests
import socket

from bs4 import BeautifulSoup
from pathlib import Path
from urllib.error import HTTPError, URLError
from time import sleep
from tqdm import tqdm

REQUEST_HEADERS = {
    'User-Agent':
    'Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko'
  }
REQUEST_TIMEOUT = 20

SEARCH_URL = 'https://yomou.syosetu.com/search.php'
NOVEL_SEARCH_RESULT_PATH = str(Path.home()) + '/vscode/syosetu/data/search_novels.txt'

# 小説を検索してダウンロード
def novel_search_dler(max_search_page=100, max_search_result=20):
  with open(NOVEL_SEARCH_RESULT_PATH, 'w') as f:
    for page in tqdm(range(max_search_page)):
      #search_params = {'word': '', 'type': 're', 'order_former': 'search', 'order': 'new', 'notnizi': '1', 'minlen': '5000', 'min_globalpoint': '1000', 'p': page + 1}
      search_params = {'word': '', 'type': 're', 'order_former': 'search', 'order': 'hyoka', 'notnizi': '1', 'minlen': '5000', 'min_globalpoint': '1000', 'p': page + 1}
      r = requests.get(SEARCH_URL, headers=REQUEST_HEADERS, timeout=REQUEST_TIMEOUT, params=search_params)
      r.encoding = r.apparent_encoding
      soup = BeautifulSoup(r.text, 'html.parser')
      sleep(1)

      search_result = soup.find_all('div', class_='searchkekka_box')

      for r_count in range(max_search_result):
        a_list = search_result[r_count].find_all('a', limit=3)
        url = a_list[0].get('href')
        info_url = a_list[2].get('href')
        title = a_list[0].get_text()
        # 小説のURL、小説情報のURLと小説のタイトルを設定
        f.write('{0}\t{1}\t{2}\n'.format(url, info_url, title))
        print('url: {0} info_url: {1} title: {2}'.format(url, info_url, title))

novel_search_dler()

続いて先程作成した検索結果を順番に読み込み、小説の内容と評価ポイントを取得する。※morphologicalAnalysisのコードはmecab設定のページに記載。

import morphologicalAnalysis as ma

import requests
import socket

import pandas as pd

from bs4 import BeautifulSoup
from pathlib import Path
from urllib.error import HTTPError, URLError
from time import sleep
from tqdm import tqdm

REQUEST_HEADERS = {
    'User-Agent':
    'Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko'
  }
REQUEST_TIMEOUT = 60

SCRAP_WORD = 5000
SCRAP_CHAPTER = 40

COL_NAMES = ['URL', 'INFO_URL', 'TITLE']
NOVEL_SEARCH_RESULT_PATH = str(Path.home()) + '/vscode/syosetu/data/search_novels.txt'
NOVEL_SEARCH_RESULT_DATA_PATH = str(Path.home()) + '/vscode/syosetu/data/search_novel_datas.txt'

# 小説情報をダウンロード
def novel_info_dler(url):
  KEYWORD = 2
  EVALUATION = 6

  r = requests.get(url, headers=REQUEST_HEADERS, timeout=REQUEST_TIMEOUT)
  r.encoding = r.apparent_encoding
  soup = BeautifulSoup(r.text, 'html.parser')
  sleep(1)

  keyword = soup.find(id='noveltable1').find_all('td')[KEYWORD].get_text(strip=True)
  evaluation = soup.find(id='noveltable2').find_all('td')[EVALUATION].get_text(strip=True).replace('pt', '')
  return keyword,evaluation

# 本文をダウンロード
def novel_text_dler(url):
  r = requests.get(url, headers=REQUEST_HEADERS, timeout=REQUEST_TIMEOUT)
  r.encoding = r.apparent_encoding
  soup = BeautifulSoup(r.text, 'html.parser')
  sleep(1)

  novel_text = ''
  # 章のタイトルに「設定」、「登場人物」が含まれる場合、戻り値は''とする
  subtitle = soup.find('p', class_='novel_subtitle')
  if subtitle is None or '設定' in subtitle or '登場人物' in subtitle:
    return novel_text

  honbun = soup.find_all('div', class_='novel_view')

  for text in honbun:
    novel_text += text.text.replace('\n', ' ') # 改行コードをスペースに変換
  return novel_text

# 小説の各話をダウンロード
def novel_chapter_dler(url, scrap_word=SCRAP_WORD, scrap_chapter=SCRAP_CHAPTER):
  chapter = 0
  word_count = 0
  total_words = ''

  while word_count < scrap_word and chapter < scrap_chapter:
    # 単語がscrap_word未満かつ、scrap_chapter話以下の間繰り返し取得する
    try:
      words = novel_text_dler('{0}{1}/'.format(url, chapter + 1))
    except (HTTPError, URLError) as e:
      print(e)
      break
    except socket.timeout as e:
      print(e)
      continue
    else:
      #print('chapter:{0}'.format(chapter + 1))
      word_count += len(ma.analysis(words).split())
      total_words += words
    chapter += 1
  return total_words

# 小説の検索結果をもとにダウンロード
def novel_search_data_dler():
  df = pd.read_table(NOVEL_SEARCH_RESULT_PATH, header=None, names=COL_NAMES)

  with open(NOVEL_SEARCH_RESULT_DATA_PATH, 'w') as f:
    for index,item in tqdm(df.iterrows()):
      url = item[COL_NAMES[0]]
      info_url = item[COL_NAMES[1]]
      title = item[COL_NAMES[2]]
      keyword,evaluation = novel_info_dler(info_url)
      # URL、小説のタイトル、キーワードと評価ポイントを設定
      f.write('{0}\t{1}\t{2}\t{3}\t'.format(url, title, keyword, evaluation))
      #print('url:{0} title:{1} keywords:{2} evaluation:{3}'.format(url, title, keyword, evaluation))
      # 小説の各話を設定
      f.write(novel_chapter_dler(url))
      f.write('\n')

novel_search_data_dler()

サーバーに負荷をかけないようにするため、ページの取得に1秒のインターバルをおいているため、全ての取得には7時間ほどかかる。

3.回帰分析

良い結果は得られていないが、せっかくなので回帰分析するところまでのソースコードを公開する。評価ポイントを5段階くらいに分けて分類したほうが良い結果になると思う。

データを読み込む

Doc2Vecデータを読み込み、配列に変換する。

from gensim.models.doc2vec import Doc2Vec
from pathlib import Path

MODEL_DATA_PATH = str(Path.home()) + '/vscode/syosetu/data/search_novel_doc2vec_100.model'

m = Doc2Vec.load(MODEL_DATA_PATH)
vectors_list = [m.dv[n] for n in range(len(m.dv))]

データ処理しやすいように、必要な情報をPandasにまとめる。

import pandas as pd
import numpy as np

MODEL_TITLES_CSV_PATH = str(Path.home()) + '/vscode/syosetu/data/search_novel_titles.csv'

df = pd.read_csv(MODEL_TITLES_CSV_PATH, thousands=',')
df = df.drop(columns=['URL'])
df['VECTORS'] = vectors_list

Doc2Vecのデータをそのまま使うと次元数が大きすぎるので、PCAを使って次元圧縮する。

from sklearn.decomposition import PCA

def dimension_reduction(data, pca_dimension=20):
  pca_data = data.copy()
  pca = PCA(n_components=pca_dimension)
  vector = np.array([np.array(v) for v in pca_data['VECTORS']])
  pca_vectors = pca.fit_transform(vector)
  pca_data['PCA_VECTORS'] = [v for v in pca_vectors]

  return pca_data

df = dimension_reduction(data=df)
df.info()

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 2000 entries, 0 to 1999
Data columns (total 5 columns):
 #   Column       Non-Null Count  Dtype 
---  ------       --------------  ----- 
 0   TITLE        2000 non-null   object
 1   KEYWORDS     2000 non-null   object
 2   EVALUATION   2000 non-null   object
 3   VECTORS      2000 non-null   object
 4   PCA_VECTORS  2000 non-null   object
dtypes: object(5)
memory usage: 78.2+ KB

不要な文字を取り除き、数値に変換する。

df['EVALUATION'] = df['EVALUATION'].str.replace(',', '')
df['EVALUATION'] = df['EVALUATION'].str.replace('評価受付停止中', '').astype(float)

df['EVALUATION'].describe()

count      2000.000000
mean      32735.061500
std       33540.729876
min        4019.000000
25%       14118.000000
50%       21271.000000
75%       38219.250000
max      323929.000000
Name: EVALUATION, dtype: float64

トレーニングデータとテストデータに分ける。

from sklearn.model_selection import train_test_split

X = pd.DataFrame(df['PCA_VECTORS'].tolist(), index=df.index)
X.columns = [f'No{i+1}' for i in range(len(X.columns))]
y = df['EVALUATION']

X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0)
データを確認する

必要なライブラリをインポートしておく。

from scipy import stats
from scipy.stats import norm, skew # for some statistics

# visualization
import matplotlib.pyplot as plt
%matplotlib inline

import seaborn as sns
color = sns.color_palette()
sns.set_style('darkgrid')

import warnings
def ignore_warn(*args, **kwargs):
  pass
warnings.warn = ignore_warn # Ignore annoying warning (from sklearn and seaborn)

評価ポイントの分布を可視化する。

sns.distplot(y_train, fit=norm)

# Get the fitted parameters used by the function
(mu, sigma) = norm.fit(y_train)
print( '\n mu = {:.2f} and sigma = {:.2f}\n'.format(mu, sigma))

# Now plot the distribution
plt.legend(['Normal dist. ($\mu=$ {:.2f} and $\sigma=$ {:.2f} )'.format(mu, sigma)],
            loc='best')
plt.ylabel('Frequency')
plt.title('Evaluation distribution')

# Get also the QQ-plot
fig = plt.figure()
res = stats.probplot(y_train, plot=plt)
plt.show()

だいぶ歪んだ分布をしているので、対数変換を行う。

# We use the numpy fuction log1p which applies log(1+x) to all elements of the column
y_train = np.log1p(y_train)

# Check the new distribution 
sns.distplot(y_train, fit=norm)

# Get the fitted parameters used by the function
(mu, sigma) = norm.fit(y_train)
print( '\n mu = {:.2f} and sigma = {:.2f}\n'.format(mu, sigma))

# Now plot the distribution
plt.legend(['Normal dist. ($\mu=$ {:.2f} and $\sigma=$ {:.2f} )'.format(mu, sigma)],
            loc='best')
plt.ylabel('Frequency')
plt.title('Evaluation distribution')

# Get also the QQ-plot
fig = plt.figure()
res = stats.probplot(y_train, plot=plt)
plt.show()
作成したデータを元に予測する

Lassoで回帰分析を実施する。

from sklearn.model_selection import cross_val_score, KFold

kf = KFold(5, shuffle=True, random_state=0).get_n_splits(X_train)

# Validation function
def rmsle_cv(classifier):
  return np.sqrt(-cross_val_score(classifier, X_train.values, y_train.values, scoring="neg_mean_squared_error", cv=kf))
from sklearn.linear_model import Lasso
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import RobustScaler

lasso = make_pipeline(RobustScaler(), Lasso(random_state=0))

score = rmsle_cv(lasso)
print("Lasso score: {:.4f} ({:.4f})\n".format(score.mean(), score.std()))

Lasso score: 0.7408 (0.0095)

4.まとめ

今回はやりたいことが出来なかったため、前述した通り分類問題で改めて分析してみる。

複数の機械学習アルゴリズムを比較する

Recommendation Systemを考える その2で、機械学習を実施した結果、ロジスティック回帰が良いスコアになったが、その他の分類器ではどうか大まかに調べてみる。

1.複数のアルゴリズムを比較する

ライブラリをインポートする。

# data analysis and wrangling
import pandas as pd
import numpy as np
import random as rnd

# visualization
import matplotlib.pyplot as plt
%matplotlib inline

import seaborn as sns
color = sns.color_palette()
sns.set_style('darkgrid')

import warnings
def ignore_warn(*args, **kwargs):
  pass
warnings.warn = ignore_warn # Ignore annoying warning (from sklearn and seaborn)

# machine learning
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC, LinearSVC
from sklearn.ensemble import RandomForestClassifier, AdaBoostClassifier, GradientBoostingClassifier, ExtraTreesClassifier, VotingClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.naive_bayes import GaussianNB
from sklearn.linear_model import Perceptron, SGDClassifier
from sklearn.tree import DecisionTreeClassifier

from sklearn.model_selection import GridSearchCV, cross_val_score, StratifiedKFold, learning_curve

保存しておいたデータを読み込む。

from pathlib import Path

TRAIN_DATA_PATH = str(Path.home()) + '/vscode/syosetu/train_data.pkl'

train = pd.read_pickle(TRAIN_DATA_PATH)

トレーニングデータとテストデータに分割する。

from sklearn.model_selection import train_test_split

X = train['vectors']
y = train['female']

X_array = np.array([np.array(v) for v in X])
y_array = np.array([i for i in y])

X_train, X_test, y_train, y_test = train_test_split(X_array, y_array, random_state=0)

クロスバリデーションのスコアが良い順に表示する。

kfold = StratifiedKFold(n_splits=10)
cls_weight = (y_train.shape[0] - np.sum(y_train)) / np.sum(y_train)
random_state = 0
classifiers = []
classifiers.append(LogisticRegression(random_state=random_state))
classifiers.append(SVC(random_state=random_state))
classifiers.append(KNeighborsClassifier(n_neighbors=3))
classifiers.append(GaussianNB())
classifiers.append(Perceptron(random_state=random_state))
classifiers.append(LinearSVC(random_state=random_state))
classifiers.append(SGDClassifier(random_state=random_state))
classifiers.append(DecisionTreeClassifier(random_state=random_state))
classifiers.append(RandomForestClassifier(n_estimators=100, random_state=random_state))
classifiers.append(AdaBoostClassifier(DecisionTreeClassifier(random_state=random_state), random_state=random_state, learning_rate=0.1))
classifiers.append(GradientBoostingClassifier(random_state=random_state))
classifiers.append(ExtraTreesClassifier(random_state=random_state))

cv_results = []
for classifier in classifiers :
  cv_results.append(cross_val_score(classifier, X_train, y=y_train, scoring='accuracy', cv=kfold, n_jobs=4))

cv_means = []
cv_std = []
for cv_result in cv_results:
  cv_means.append(cv_result.mean())
  cv_std.append(cv_result.std())

cv_res = pd.DataFrame({'CrossVal Means': cv_means, 'CrossVal Errors': cv_std,
                       'Algorithm': ['LogisticRegression', 'SVC', 'KNeighborsClassifier',
                                     'GaussianNB', 'Perceptron', 'LinearSVC', 'SGDClassifier',
                                     'DecisionTreeClassifier', 'RandomForestClassifier',
                                     'AdaBoostClassifier', 'GradientBoostingClassifier',
                                     'ExtraTreesClassifier']})

cv_res.sort_values(by=['CrossVal Means'], ascending=False, inplace=True)

cv_res
CrossVal MeansCrossVal ErrorsAlgorithm
00.9053570.105357LogisticRegression
30.8964290.094761GaussianNB
10.8928570.100699SVC
40.8910710.101157Perceptron
60.8910710.101157SGDClassifier
110.8785710.099360ExtraTreesClassifier
80.8660710.120387RandomForestClassifier
20.8446430.092461KNeighborsClassifier
50.8250000.107381LinearSVC
100.8017860.112783GradientBoostingClassifier
70.7464290.145248DecisionTreeClassifier
90.7089290.121389AdaBoostClassifier

回帰系のアルゴリズムでスコアが良い傾向があるように見える。

g = sns.barplot('CrossVal Means', 'Algorithm', data=cv_res, palette='Set3', orient='h', **{'xerr': cv_std})
g.set_xlabel('Mean Accuracy')
g = g.set_title('Cross validation scores')

2.パラメーターの最適化

続いて、いくつか選んだアルゴリズムのパラメーターを変更して、スコアの変化を見てみる。

### META MODELING WITH LR, RF and SGD

# Logistic Regression
LR = LogisticRegression(random_state=0)

## Search grid for optimal parameters
lr_param_grid = {'penalty': ['l1','l2']}

gsLR = GridSearchCV(LR, param_grid=lr_param_grid, cv=kfold, scoring='accuracy', n_jobs=4, verbose=1)

gsLR.fit(X_train, y_train)

LR_best = gsLR.best_estimator_

# Best score
gsLR.best_score_

Fitting 10 folds for each of 2 candidates, totalling 20 fits
0.9053571428571429

gsLR.best_params_

{‘penalty’: ‘l2’}

# Random Forest Classifier
RF = RandomForestClassifier(random_state=0)

## Search grid for optimal parameters
'''rf_param_grid = {'criterion': ['gini', 'entropy'],
                 'max_features': [1, 3, 10],
                 'min_samples_split': [2, 3, 10],
                 'min_samples_leaf': [1, 3, 10],
                 'n_estimators':[50, 100, 200]}'''
rf_param_grid = {'criterion': ['gini', 'entropy'],
                 'max_features': [40, 50, 60],
                 'min_samples_split': [2, 3, 10],
                 'min_samples_leaf': [1, 3, 10],
                 'n_estimators':[20, 30, 40]}

gsRF = GridSearchCV(RF, param_grid=rf_param_grid, cv=kfold, scoring='accuracy', n_jobs=4, verbose=1)

gsRF.fit(X_train, y_train)

RF_best = gsRF.best_estimator_

# Best score
gsRF.best_score_

Fitting 10 folds for each of 162 candidates, totalling 1620 fits
0.8946428571428573

gsRF.best_params_

{‘criterion’: ‘gini’,
‘max_features’: 50,
‘min_samples_leaf’: 3,
‘min_samples_split’: 2,
‘n_estimators’: 30}

# SGD Classifier
SGD = SGDClassifier(random_state=0)

## Search grid for optimal parameters
sgd_param_grid = {'alpha'  : [0.0001, 0.001, 0.01, 0.1],
                  'loss'   : ['log', 'modified_huber'],
                  'penalty': ['l2', 'l1', 'none']}

gsSGD = GridSearchCV(SGD, param_grid=sgd_param_grid, cv=kfold, scoring='accuracy', n_jobs=4, verbose=1)

gsSGD.fit(X_train, y_train)

SGD_best = gsSGD.best_estimator_

# Best score
gsSGD.best_score_

Fitting 10 folds for each of 24 candidates, totalling 240 fits
0.8660714285714285

gsSGD.best_params_

{‘alpha’: 0.0001, ‘loss’: ‘modified_huber’, ‘penalty’: ‘l1’}

3.学習曲線の確認

def plot_learning_curve(estimator, title, X, y, ylim=None, cv=None,
                        n_jobs=-1, train_sizes=np.linspace(.1, 1.0, 5)):
  '''Generate a simple plot of the test and training learning curve'''
  plt.figure()
  plt.title(title)
  if ylim is not None:
    plt.ylim(*ylim)
  plt.xlabel('Training examples')
  plt.ylabel('Score')
  train_sizes, train_scores, test_scores = learning_curve(
      estimator, X, y, cv=cv, n_jobs=n_jobs, train_sizes=train_sizes)
  train_scores_mean = np.mean(train_scores, axis=1)
  train_scores_std = np.std(train_scores, axis=1)
  test_scores_mean = np.mean(test_scores, axis=1)
  test_scores_std = np.std(test_scores, axis=1)
  plt.grid()

  plt.fill_between(train_sizes, train_scores_mean - train_scores_std,
                   train_scores_mean + train_scores_std, alpha=0.1,
                   color='r')
  plt.fill_between(train_sizes, test_scores_mean - test_scores_std,
                   test_scores_mean + test_scores_std, alpha=0.1,
                   color='g')
  plt.plot(train_sizes, train_scores_mean, 'o-', color='r',
           label='Training score')
  plt.plot(train_sizes, test_scores_mean, 'o-', color='g',
           label='Cross-validation score')

  plt.legend(loc='best')
  return plt

g = plot_learning_curve(gsLR.best_estimator_, 'LR mearning curves', X_train, y_train, cv=kfold)
g = plot_learning_curve(gsRF.best_estimator_, 'RF learning curves', X_train, y_train, cv=kfold)
g = plot_learning_curve(gsSGD.best_estimator_, 'SGD learning curves', X_train, y_train, cv=kfold)

Random Forestも良い気がしてきた。何とかトレーニングデータを増やしてもう少し確認したい。

最後に

全くトレーニングで使用していない未知のデータに対して、 Random Forest の予測モデルで判定してみたところ83%程の正解率だった。なろう小説のTop300はファンタジー系の小説が多いため、ファンタジー系の小説において、この正解率とみた方が良いかも知れない。ジャンルが違う小説の場合どのような結果になるか現時点では予測不能である。

Recommendation Systemを考える その2

Recommendation Systemを考えるの続き。

更新履歴:2021/03/19 データ処理に誤りがあったため訂正

初めに

小説をユーザーに推薦するかしないかで分けたいので、これは分類問題となる。分類問題についてはこちらで解説している。

分類するために、作者、関連作品やタグ付けも参照した方が精度が良くなるかもしれないが、今回はDoc2Vecのベクトル情報のみで行ってみる。Doc2Vec で作成した文書ベクトルを教師あり学習の説明変数として使用する。

文書ベクトルを教師あり学習の説明変数とした分類問題ではRandom Forestが有効という論文を見たので、まずは Random Forest を試してみる。また、今回使用するDoc2Vecの文書ベクトルは次元数が100ほどあるため、事前に次元圧縮を行うことも検討する。

最初は、このモデルを正しく評価するために、ユーザーの好みかの様なあいまいな指標で分類するのではなく、機械的に分類できる指標にする。

分類の実施

データセットの作成

以前作成した学習済みモデルを読み込み、データセットを作成する。

from gensim.models.doc2vec import Doc2Vec

MODEL_DATA_PATH = 'drive/My Drive/Colab Notebooks/syosetu/doc2vec_100.model'

m = Doc2Vec.load(MODEL_DATA_PATH)
vectors_list = [m.docvecs[n] for n in range(len(m.docvecs))]
import pandas as pd

MODEL_TITLES_CSV_PATH = 'drive/My Drive/Colab Notebooks/syosetu/novel_titles.csv'

df = pd.read_csv(MODEL_TITLES_CSV_PATH, header=None, names=['url', 'title', 'tag'])
df = df.drop(columns=['url'])
df['vectors'] = vectors_list

df.head(2)
titletagvectors
0転生したらスライムだった件R15 残酷な描写あり 異世界転生 スライム チート[5.356541, 1.455931, 1.0426528, 1.1359314, 3.9…
1無職転生 - 異世界行ったら本気だす –R15 残酷な描写あり 異世界転生[0.010144993, -12.32704, 2.8596005, 6.5284176,…

タグを見て機械的に分類したいため、どんなタグが存在するのか最初に確認する。

import collections
import itertools

tag = collections.Counter(list(itertools.chain.from_iterable(df['tag'].str.split().tolist())))
sorted(tag.items(), key=lambda pair: pair[1], reverse=True)[:20]
[('R15', 233),
 ('残酷な描写あり', 215),
 ('異世界転生', 98),
 ('異世界', 87),
 ('異世界転移', 86),
 ('チート', 84),
 ('ファンタジー', 80),
 ('魔法', 73),
 ('男主人公', 65),
 ('冒険', 63),
 ('主人公最強', 59),
 ('書籍化', 53),
 ('ハーレム', 48),
 ('成り上がり', 43),
 ('ほのぼの', 33),
 ('ハッピーエンド', 32),
 ('女主人公', 31),
 ('恋愛', 30),
 ('オリジナル戦記', 30),
 ('転生', 28)]

2極化しやすそうな、男主人公、女主人公の小説を抽出することにする。

df_m = df[df['tag'].str.contains('男主人公')]
df_m['female'] = 0
df_f = df[df['tag'].str.contains('女主人公|女性視点|悪役令嬢|後宮|少女マンガ')]
df_f['female'] = 1

上記の抽出方法の場合、女主人公と分類した小説の中に男主人公の小説が混じっている可能性がある。

df_f[df_f['tag'].str.contains('男主人公')]
titletagvectors
47乙女ゲー世界はモブに厳しい世界ですR15 残酷な描写あり 異世界転生 乙女ゲーム 悪役令嬢 冒険 人工知能 男主人公 学園 …[6.775977, 4.3698134, -1.9109731, 9.731645, -0…
74【Web版】町人Aは悪役令嬢をどうしても救いたい【本編完結済み】R15 残酷な描写あり 異世界転生 乙女ゲーム 悪役令嬢 オリジナル戦記 男主人公 西洋 …[2.9941628, 1.892081, 1.9338807, 4.0745897, 6….

上記は男主人公の小説と思われるため、女主人公の小説の一覧から除外する。

df_f = df_f.drop([47, 74])
df = pd.concat([df_m, df_f])
df['female'].value_counts()

0 67
1 51
Name: female, dtype: int64

男主人公の小説の方が数が多いため、女主人公の小説の数と合わせる。

#男主人公の一覧
df_0 = df[df['female'] == 0]
#女主人公の一覧
df_1 = df[df['female'] == 1]

df_0 = df_0.sample(n=len(df_1), random_state=0)

df = pd.concat([df_0, df_1])
機械学習の実施

データセットの作成が出来たので、 Random Forest を用いて、分類を実施してみる。

X = df['vectors']
y = df['female']

X_array = np.array([np.array(v) for v in X])
y_array = np.array([i for i in y])
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(X_array, y_array, random_state=0)
from sklearn.ensemble import RandomForestClassifier

random_forest = RandomForestClassifier(random_state=0)
random_forest.fit(X_train, y_train)

random_forest.score(X_test, y_test)

0.8076923076923077

random_forest.predict(X_test)

array([0, 1, 0, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0,
0, 1, 0, 0])

試しに、ロジスティック回帰でも実施してみる。

from sklearn.linear_model import LogisticRegression

logreg = LogisticRegression()
logreg.fit(X_train, y_train)

logreg.score(X_test, y_test)

0.9230769230769231

logreg.predict(X_test)

array([0, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0])

ロジスティック回帰の方が良いスコアになった。しかし、過学習を起こしている可能性もある。

まとめ

Random Forest が一番良い結果になると予想していたが、違った。
データセットは大体問題なさそうなので、この問題を解くにはどの分類器が適しているのか、また最適なパラメーターを探す作業は次回行いたいと思う。

複数の機械学習アルゴリズムを比較するへ続く。

Recommendation Systemを考える

以下の機械学習の勉強の過程で小説の特徴を抽出することは出来た。ここからユーザーにおすすめの小説を推薦するにはどのような仕組みが必要なのか考える。

小説を読もうの累計ランキングをDoc2Vecで解析する その5

Recommendation System(推薦システム)とは

過去の行動をもとに、あるアイテムを好む可能性を予測するシステム。
例えば、Netflixでは、映画を見たり、投票したりするような過去の行動に応じて、新しい映画を提案している。
新しいアイテムを推奨するという点が重要となる。

・ユーザーベースの推薦システム

自分の経験と他の人の経験を組み合わせて推薦する。ユーザ間の類似度を計算し、類似度の高いユーザーが好む作品の中でまだ見ていない作品を推薦する。

このシステムを実現するには複数のユーザー情報が必要になるため、今回は不採用。

・アイテムベースの推薦システム

類似のユーザを見つけるのではなく、映画や物などのアイテムを比較する。アイテム間の類似度を計算する。

・どのような推薦システムを作成するか

ユーザーが入力した小説に似ている小説を推薦するシステムは以前作成したDoc2Vecのモデルを使用すれば実現可能である。しかし、このシステムではユーザーが毎回小説を入力しなければいけない点が煩わしい。

例えば、最近投稿された小説の中から自分の好みに合う小説があったら、自動的に通知が来るようなシステムであると良い。

このシステムは以前作成したモデルのみでは実現不可能であり、さらに自分の好みを学習させる必要がある。これまでと違い教師あり学習になるので、どのような手法があるか調べるところから始めることにする。

Recommendation Systemを考える その2へ続く。

小説を読もうの累計ランキングをDoc2Vecで解析する その5

小説を読もうの累計ランキングをDoc2Vecで解析する その4の続き。

形態素解析をする前に正規化処理を追加した。それ以外には類似文章の検索結果が良くなるように、文章数と1文章当たりの文字数、次元数などを調整した。
その代わり類似単語の検索については悪化しているように感じる。

追記(2020/09/16):
指定したURLの小説の類似小説を表示する関数を追加した。

修正版 (2020/09/16):

小説を読もうの累計ランキングをDoc2Vecで解析する その4

小説を読もうの累計ランキングをDoc2Vecで解析する その3の続き。

今回はこれまで解析した特徴を表にプロットして可視化してみる。2次元の表にプロットするために、t-SNEを使用し次元を削減する。

t-SNEによる次元圧縮

import numpy as np
import matplotlib.pyplot as plt
from gensim.models.doc2vec import Doc2Vec
from sklearn.manifold import TSNE

# 学習済みモデルを読み込む
m = Doc2Vec.load('drive/My Drive/Colab Notebooks/syosetu/doc2vec.model')
weights = []
for i in range(0, len(m.docvecs)):
  weights.append(m.docvecs[i].tolist())
weights_tuple = tuple(weights)
X = np.vstack(weights_tuple)

# t-SNEで次元圧縮する
tsne_model = TSNE(n_components=2, random_state=0, verbose=2)
np.set_printoptions(suppress=True)
t_sne = tsne_model.fit_transform(X)

# クラスタリング済みのデータを読み込む
with open('drive/My Drive/Colab Notebooks/syosetu/novel_cluster.csv', 'r') as f:
  reader = csv.reader(f)
  clustered = np.array([row for row in reader])
  clustered = clustered.astype(np.dtype(int).type)
  clustered = clustered[np.argsort(clustered[:, 0])]
  clustered = clustered.T[1]

# グラフ描画
fig, ax = plt.subplots(figsize=(10, 10), facecolor='w', edgecolor='k')

# Set Color map
cmap = plt.get_cmap('Dark2')

for i in range(t_sne.shape[0]):
  cval = cmap(clustered[i] / 4)
  ax.scatter(t_sne[i][0], t_sne[i][1], marker='.', color=cval)
  ax.annotate(i, xy=(t_sne[i][0], t_sne[i][1]), color=cval)
plt.show()

実行すると以下のようなグラフになった。満遍なく分布しているが同じクラスターの小説についてはある程度まとまって分布しているように見える。

続いて3次元のグラフを描画してくれる良いページがあるので使ってみる。

Embedding projector – visualization of high-dimensional data

上記のサイトで次元圧縮を行ってくれるので、こちらはTSV形式のデータを用意するだけで良い。

!pip install gensim torch tensorboardX

from torch import FloatTensor
from gensim.models import KeyedVectors
from tensorboardX import SummaryWriter

m = KeyedVectors.load('drive/My Drive/Colab Notebooks/syosetu/doc2vec.model')
weights = []
labels = []
for i in range(0, len(m.docvecs)):
  weights.append(m.docvecs[i].tolist())
  labels.append(m.docvecs.index_to_doctag(i))

# DEBUG: visualize vectors up to 1000
weights = weights[:1000]
labels = labels[:1000]

writer = SummaryWriter()
writer.add_embedding(FloatTensor(weights), metadata=labels)

実行すると「/content/runs/実行日付等/00000/default」にtensors.tsvとmetadata.tsvファイルが保存される。このファイルをEmbedding Projectorのページにアップロードする。
このままではどの点がどの小説を表しているかわかりずらいので、metadata.tsvを加工してタイトルが表示されるようにする。

とても見やすいけれども、第1から第3主成分までの累積寄与率が14%ほどしかないので、PCAにて次元圧縮したこのグラフは残念ながら殆どあてにならない。PCA以外にも先ほど使用したt-SNEを使用することも可能。

まとめ

もう少しきれいなグラフが描けるように、ライブラリの使い方から勉強しなおす。

小説を読もうの累計ランキングをDoc2Vecで解析する その5へ続く。

小説を読もうの累計ランキングをDoc2Vecで解析する その3

小説を読もうの累計ランキングをDoc2Vecで解析する その2の続き。

前回累積ランキングのクラスターを目視で確認をしたが、それぞれの特徴を的確に捉えることは出来なかった。今回はクラスターの特徴をTF-IDFを使って抽出してみる。

TF-IDFで分析する

今回もscikit-learnのライブラリを使用する。

import csv
from sklearn.feature_extraction.text import TfidfVectorizer

# 小説の本文を読み込む
with open('drive/My Drive/Colab Notebooks/syosetu/novel_datas.txt', 'r') as f:
  novels = [[i, data.split('\t')[2]] for i, data in enumerate(f)]

docs = ['','','','','','']

# クラスタリング結果を読み込む
with open('drive/My Drive/Colab Notebooks/syosetu/novel_cluster.csv', 'r') as f:
  reader = csv.reader(f)
  for row in reader:
    doc = novels[int(row[0])][1]
    #doc = ' '.join(set(doc.split())) #同一タイトル内で重複削除
    # クラスター毎に本文を纏める
    docs[int(row[1]) - 1] += ' {0}'.format(doc)

# tf-idfの計算
vectorizer = TfidfVectorizer(max_df=0.90, max_features=100)
# 文書全体の90%以上で出現する単語は無視する
# 且つ、出現上位100までの単語で計算する
X = vectorizer.fit_transform(docs)
#print('feature_names:', vectorizer.get_feature_names())

words = vectorizer.get_feature_names()
for doc_id, vec in zip(range(len(docs)), X.toarray()):
  print('doc_id:', doc_id + 1)
  for w_id, tfidf in sorted(enumerate(vec), key=lambda x: x[1], reverse=True)[:20]:
    lemma = words[w_id]
    print('\t{0:s}: {1:f}'.format(lemma, tfidf))

実行結果は以下の通りとなった。

doc_id: 1	
	蔵人: 0.364076
	魔物: 0.334649
	ベルグリフ: 0.296258
	魔王: 0.271560
	アンジェリン: 0.264133
	直継: 0.237363
	ガリウス: 0.205239
	魔術: 0.199051
	lv: 0.197992
	夜霧: 0.196315
	マイン: 0.194641
	パーティ: 0.190183
	身体: 0.161838
	女神: 0.156352
	ハンター: 0.127054
	魔族: 0.118583
	メンバー: 0.107892
	探索者: 0.106833
	公爵: 0.104819
	バス: 0.093903
doc_id: 2	
	魔物: 0.409209
	ダリヤ: 0.387583
	dp: 0.376927
	ポーション: 0.287506
	lv: 0.255560
	lv: 0.232489
	クマ: 0.227815
	ゴブリン: 0.226827
	聖女: 0.159725
	薬草: 0.142533
	魔石: 0.131330
	兄さん: 0.124231
	契約: 0.119544
	討伐: 0.111881
	加護: 0.104218
	hp: 0.102685
	ボックス: 0.094060
	金貨: 0.082761
	騎士団: 0.082761
	パン: 0.079863
doc_id: 3	
	リアム: 0.810088
	ドロップ: 0.416766
	導書: 0.335092
	領地: 0.152304
	hp: 0.102806
	殿下: 0.101409
	スケルトン: 0.076152
	mp: 0.049499
	皇帝: 0.044091
	キャラ: 0.041884
	王子: 0.030461
	兄さん: 0.017636
	冒険: 0.011423
	母さん: 0.011423
	クマ: 0.010291
	報酬: 0.007615
	経験値: 0.007615
	付与: 0.003808
	契約: 0.003808
	採取: 0.003808
doc_id: 4	
	名無し: 0.424656
	スバル: 0.420209
	コタロー: 0.326830
	真昼: 0.262535
	鑑定: 0.215285
	耐性: 0.200477
	ニート: 0.193944
	ボーナス: 0.187787
	魔物: 0.174278
	ポーション: 0.166195
	lv: 0.163557
	ガチャ: 0.158615
	ゴブリン: 0.158331
	lv: 0.155643
	プレイヤー: 0.132132
	盗賊: 0.129854
	身体: 0.105934
	ユニーク: 0.091012
	初期: 0.088848
	キャラ: 0.084291
doc_id: 5	
	リーシェ: 0.737429
	王子: 0.384356
	アルノルト: 0.342548
	殿下: 0.235180
	学院: 0.227594
	公爵: 0.123912
	皇帝: 0.118855
	バス: 0.103287
	先生: 0.096089
	剣士: 0.085170
	訓練: 0.076434
	母さん: 0.056780
	学校: 0.054596
	聖女: 0.053105
	身体: 0.048044
	それなり: 0.043677
	キャラ: 0.034941
	所属: 0.034941
	騎士団: 0.030574
	メンバー: 0.028390
doc_id: 6	
	魔物: 0.419558
	lv: 0.318543
	素材: 0.269150
	幼女: 0.265192
	魔術: 0.261251
	レイ: 0.259408
	身体: 0.239465
	マイン: 0.205894
	ゴブリン: 0.205821
	魔石: 0.199376
	爺さん: 0.186030
	転移: 0.178751
	万能: 0.166240
	それなり: 0.112806
	銀貨: 0.112292
	採取: 0.104890
	おじさん: 0.103126
	金貨: 0.096973
	美女: 0.094994
	訓練: 0.094994

形態素解析をしたときに残ってしまった人名がいくつか含まれてしまっているが今回は無視する。
また、予想はしていたがサンプル数が少ないことと、似ている内容が多いため特徴がわかり難くなってしまった。それでもある程度の特徴はつかむことが出来た。

doc_id: 1
  戦闘、戦争中心の話?
doc_id: 2
  ダンジョン中心の話?
doc_id: 3
  領地経営、内政中心の話?
doc_id: 4
  ファンタジーだけど現実要素強めの話?
doc_id: 5
  乙女ゲーム、内政中心の話?
doc_id: 6
  戦闘、戦争は少なそう。まったり系、生産系の話?

まとめ

教師無しであるにもかかわらず、ちゃんと分類出来るのは面白い。最終的にやりたいことは自分が読みたいと思う小説を機械学習で探すことなので、今回の知見を後に生かせれば良いと思う。

小説を読もうの累計ランキングをDoc2Vecで解析する その4へ続く。

小説を読もうの累計ランキングをDoc2Vecで解析する その2

小説を読もうの累計ランキングをDoc2Vecで解析するの続き。

前回学習済みモデルによって小説間の類似度を見ることが出来た。今度はクラスタリングを行う。

前回のモデルを改善する

次に進む前に少しスクレイピングの処理を修正する。1から5話まで取得する様にしていたが、作品によって1話当たりの文字数にかなり差があるので、単語数を基準に本文を取得するように変更する。またサンプルをTop 100(これでもかなり少ない)まで増やす。

# 累計ランキングをTop100まで取得
def novel_total():
  headers = {
    'User-Agent':
    'Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko'
  }
  r = requests.get('http://yomou.syosetu.com/rank/list/type/total_total/', headers=headers, timeout=20)
  r.encoding = r.apparent_encoding
  soup =  BeautifulSoup(r.text)
  rank_index = soup.find_all('div', class_='rank_h')
  sleep(1)
  with open('drive/My Drive/Colab Notebooks/syosetu/novel_datas.txt', 'w') as f:
    for rank in range(100):
      link = rank_index[rank].find('a')
      url = link.get('href')
      title = link.get_text()
      # 後でわかりやすいようにURLと小説のタイトルを設定
      f.write('{0}\t{1}\t'.format(url, title))
      print('rank:{0} title:{1}'.format(rank + 1, title))
      chapter = 0
      word_count = 0
      while word_count < 5000 and chapter < 40:
        # 単語が5000未満かつ、40話以下の間繰り返し取得する
        try:
          words = keitaiso(novel_text_dler('{0}{1}/'.format(url, chapter + 1)))
        except (HTTPError, URLError) as e:
          print(e)
          break
        except socket.timeout as e:
          print(e)
          continue
        else:
          f.write(words)
          print('chapter:{0}'.format(chapter + 1))
          word_count += len(words.split())
        chapter += 1
      f.write('\n')

モデルを作成するパラメーターも少し変更する。

# 学習の実行
m = Doc2Vec(documents=trainings, dm=1, vector_size=400, min_count=4, workers=4, epochs=40)

実行結果を確認する。

m = Doc2Vec.load('drive/My Drive/Colab Notebooks/syosetu/doc2vec.model')
# 0番目の小説に似ている小説は?(0番目の小説のタイトルは、「転生したらスライムだった件」)
print(m.docvecs.most_similar(0))

前回:
[(27, 0.3229605555534363), (11, 0.27810564637184143), (42, 0.24812708795070648), (13, 0.2365787774324417), (29, 0.22865955531597137), (33, 0.21636559069156647), (22, 0.19751328229904175), (20, 0.18333640694618225), (36, 0.1763637661933899), (25, 0.1638755202293396)]
今回:
[(27, 0.2789984941482544), (13, 0.26930510997772217), (60, 0.2584960460662842), (48, 0.25848516821861267), (68, 0.24882027506828308), (33, 0.24246476590633392), (49, 0.23103246092796326), (52, 0.22929105162620544), (94, 0.22420653700828552), (12, 0.22121790051460266)]

一番目が「27:モンスターがあふれる世界になったので、好きに生きたいと思います」であるのは変更なしであるが、二番目には「13:異世界のんびり農家」が出て来た。チートぐらいしか共通点はなさそう。試しにwindowをデフォルトの8から16に増やしてみる。

するとこのようになった。
[(60, 0.3200589418411255), (52, 0.3115108609199524), (27, 0.31150585412979126), (49, 0.2996276319026947), (13, 0.299365371465683), (18, 0.2926834225654602), (97, 0.2818679213523865), (12, 0.2745039761066437), (29, 0.2639138996601105), (68, 0.2628819942474365)]

一番目は「60:聖者無双 ~サラリーマン、異世界で生き残るために歩む道~」、二番目は「52:進化の実~知らないうちに勝ち組人生~」となった。今度は「残酷な描写あり」、「異世界」、「ハーレム」、「チート」と共通点は多そうなのでこちらを採用する。

クラスター分析する

今回も「Doc2Vecを使って小説家になろうで自分好みの小説を見つけたい話」を参考にさせて頂いた。

これらは便利な関数が用意されているので、それらに値を渡すだけでクラスタリングから図の作成まで行える。

import numpy as np
import matplotlib.pyplot as plt
from gensim.models.doc2vec import Doc2Vec
from scipy.cluster.hierarchy import linkage, fcluster, dendrogram
from matplotlib.font_manager import FontProperties

# 階層型クラスタリングの実施
def hierarchical_clustering(emb, threshold):
  # ウォード法 x ユークリッド距離
  linkage_result = linkage(emb, method='ward', metric='euclidean')
  # クラスタ分けするしきい値を決める
  threshold_distance = threshold * np.max(linkage_result[:, 2])
  # クラスタリング結果の値を取得
  clustered = fcluster(linkage_result, threshold_distance, criterion='distance')
  print("end clustering.")
  return linkage_result, threshold_distance, clustered

# 階層型クラスタリングの可視化
def plot_dendrogram(linkage_result, doc_labels, threshold):
  fp = FontProperties(fname=r'drive/My Drive/Colab Notebooks/IPAexfont00301/ipaexg.ttf')
  plt.figure(figsize=(16, 8), facecolor='w', edgecolor='k')
  dendrogram(linkage_result, labels=doc_labels, color_threshold=threshold)
  plt.title('Dendrogram', fontproperties=fp)
  plt.xticks(fontsize=10)
  print('end plot.')
  plt.savefig('drive/My Drive/Colab Notebooks/syosetu/novel_hierarchy.png')

# 階層型クラスタリング結果の保存
def save_cluster(doc_index, clustered):
  doc_cluster = np.array([doc_index, clustered])
  doc_cluster = doc_cluster.T
  doc_cluster = doc_cluster.astype(np.dtype(int).type)
  doc_cluster = doc_cluster[np.argsort(doc_cluster[:, 1])]
  np.savetxt('drive/My Drive/Colab Notebooks/syosetu/novel_cluster.csv', doc_cluster, delimiter=',', fmt='%.0f')
  print('save cluster.')

m = Doc2Vec.load('drive/My Drive/Colab Notebooks/syosetu/doc2vec.model')
vectors_list = [m.docvecs[n] for n in range(len(m.docvecs))]

threshold = 0.8
linkage_result, threshold, clustered = hierarchical_clustering(emb=vectors_list, threshold=threshold)
plot_dendrogram(linkage_result=linkage_result, doc_labels=list(range(100)), threshold=threshold)
save_cluster(list(range(100)), clustered)

実行すると以下のような画像が表示される。

100タイトルの小説が6つに分類された。

クラスター分析結果を検証する

グラフを見る限りそれぞれの小説の類似度は高いとは言えず満遍なく分布している。念のため小説の特徴にあった分類がされているか目視で確認する。(機械的に確認する処理の作成は次回)3、5のクラスターは比較的特徴がわかりやすい。

633レベル1だけどユニークスキルで最強です
513乙女ゲー世界はモブに厳しい世界です
533俺は星間国家の悪徳領主!
393没落予定の貴族だけど、暇だったから魔法を極めてみた
343貴族転生~恵まれた生まれから最強の力を得る
825転生王女は今日も旗を叩き折る。
885甘く優しい世界で生きるには
795今度は絶対に邪魔しませんっ!
155謙虚、堅実をモットーに生きております!
105一億年ボタンを連打した俺は、気付いたら最強になっていた~落第剣士の学院無双~
355乙女ゲームの破滅フラグしかない悪役令嬢に転生してしまった…
65陰の実力者になりたくて!【web版】
15ありふれた職業で世界最強
595ループ7回目の悪役令嬢は、元敵国で自由気ままな花嫁(人質)生活を満喫する
285公爵令嬢の嗜み
855黒の魔王

クラスター3の特徴は「異世界 貴族 中世 SF 最強 男主人公」だろうか。クラスター5は「異世界 乙女ゲーム 女主人公 成り上がり」だろうか。

まとめ

それなりに意味のあるまとまりになっている気がするがなかなか厳しい。次回はその他のクラスターについても特徴を検証してみる。

小説を読もうの累計ランキングをDoc2Vecで解析する その3に続く。

小説を読もうの累計ランキングをDoc2Vecで解析する

小説を読もう累計ランキングをDoc2Vecで解析して、色々遊んでみる。
Doc2Vecを使って小説家になろうで自分好みの小説を見つけたい話」を参考にさせて頂いた。

(9/2 追記: 下記のコードを修正してGitHub Gistに載せました。=> 小説を読もうの累計ランキングをDoc2Vecで解析する その5)

小説のスクレイピング

Google Colaboratoryを使ってプログラムを作成していくため、まずは諸々必要なものをインストールする。

!apt install aptitude swig
!aptitude install mecab libmecab-dev mecab-ipadic-utf8 git make curl xz-utils file -y
!pip install mecab-python3 unidic-lite
!git clone --depth 1 https://github.com/neologd/mecab-ipadic-neologd.git
!echo yes | mecab-ipadic-neologd/bin/install-mecab-ipadic-neologd -n -a

前準備として、形態素解析を行い文章を単語毎に分ける必要がある。その時日本語辞書が必要になるが、今回はmecab-ipadic-NEologdを使うことにした。小説を読もうの文章には新しい表現が多いと思われるため、多数のWeb上の言語資源から得た新語を追加することでカスタマイズされているこの辞書が最適と判断した。

以下のコードで実際にスクレイピングし、文章を取得し形態素解析を行ってからGoogle Driveに保存する。(Google Driveをマウントする方法はこちらを参照)

import requests
import subprocess
import MeCab
from bs4 import BeautifulSoup
from time import sleep

# 本文をダウンロード
def novel_text_dler(url):
  headers = {
    'User-Agent':
    'Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko'
  }
  r = requests.get(url, headers=headers)
  r.encoding = r.apparent_encoding
  soup =  BeautifulSoup(r.text)
  honbun = soup.find_all('div', class_='novel_view')
  novel = ''
  for text in honbun:
    novel += text.text
  sleep(1)
  return novel

# 形態素解析
def keitaiso(text):
  cmd = 'echo `mecab-config --dicdir`"/mecab-ipadic-neologd"'
  path = (subprocess.Popen(cmd, stdout=subprocess.PIPE, shell=True).communicate()[0]).decode('utf-8')
  tagger = MeCab.Tagger('-d {0}'.format(path))
  tagger.parse('')
  node = tagger.parseToNode(text)
  word = ''
  pre_feature = ''
  while node:
    # 名詞、形容詞、動詞、形容動詞であるか判定
    HANTEI = "名詞" in node.feature
    HANTEI = "形容詞" in node.feature or HANTEI
    HANTEI = "動詞" in node.feature or HANTEI
    HANTEI = "形容動詞" in node.feature or HANTEI
    # 以下に該当する場合は除外(ストップワード)
    HANTEI = (not "代名詞" in node.feature) and HANTEI
    HANTEI = (not "助動詞" in node.feature) and HANTEI
    HANTEI = (not "非自立" in node.feature) and HANTEI
    HANTEI = (not "数" in node.feature) and HANTEI
    HANTEI = (not "人名" in node.feature) and HANTEI
    if HANTEI:
      if ("名詞接続" in pre_feature and "名詞" in node.feature) or ("接尾" in node.feature):
        word += '{0}'.format(node.surface)
      else:
        word += ' {0}'.format(node.surface)
      #print('{0} {1}'.format(node.surface, node.feature))
    pre_feature = node.feature
    node = node.next
  return word[1:]

# 累計ランキングTop50の1から5話を取得
def novel_total_50():
  headers = {
    'User-Agent':
    'Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko'
  }
  r = requests.get('http://yomou.syosetu.com/rank/list/type/total_total/', headers=headers)
  r.encoding = r.apparent_encoding
  soup =  BeautifulSoup(r.text)
  rank_index = soup.find_all('div', class_='rank_h')
  sleep(1)
  with open('drive/My Drive/Colab Notebooks/syosetu/novel_datas.txt', 'w') as f:
    for rank in range(50):
      link = rank_index[rank].find('a')
      url = link.get('href')
      # 後でわかりやすいようにURLと小説のタイトルを設定
      f.write(url + '\t' + link.get_text() + '\t')
      for chapter in range(5):
        f.write(keitaiso(novel_text_dler(url + str(chapter + 1) + '/')))
      f.write('\n')

novel_total_50()

学習済みモデルを作成する

準備が出来たら、Doc2Vecを実行し学習済みモデルを作成する。

from gensim.models.doc2vec import Doc2Vec, TaggedDocument

# 空白で単語を区切り、改行で文書を区切っているテキストデータ
with open('drive/My Drive/Colab Notebooks/syosetu/novel_datas.txt', 'r') as f:
  # 文書ごとに単語を分割してリストにする
  trainings = [TaggedDocument(words = data.split('\t')[2].split(), tags=[i]) for i, data in enumerate(f)]

# 学習の実行
m = Doc2Vec(documents=trainings, dm=1, vector_size=200, window=8, min_alpha=1e-4, min_count=5, sample=1e-3, workers=4, epochs=40)
# モデルを保存
m.save('drive/My Drive/Colab Notebooks/syosetu/doc2vec.model')

学習済みモデルを検証する

学習済みモデルが作成出来たのでこれを使って色々遊んでみる。

似ている小説を探す

m = Doc2Vec.load('drive/My Drive/Colab Notebooks/syosetu/doc2vec.model')
# 0番目の小説に似ている小説は?(0番目の小説のタイトルは、「転生したらスライムだった件」)
print(m.docvecs.most_similar(0))

すると以下のような結果が表示された。
[(27, 0.3229605555534363), (11, 0.27810564637184143), (42, 0.24812708795070648), (13, 0.2365787774324417), (29, 0.22865955531597137), (33, 0.21636559069156647), (22, 0.19751328229904175), (20, 0.18333640694618225), (36, 0.1763637661933899), (25, 0.1638755202293396)]

最初の3つの小説のタイトルは、以下の通り。
 27:モンスターがあふれる世界になったので、好きに生きたいと思います
 11:蜘蛛ですが、なにか?
 42:即死チートが最強すぎて、異世界のやつらがまるで相手にならないんですが。

そもそも、類似度が高くても0.32なので似ている小説はないと見た方がよさそう。
もう少し特徴のある小説を選んで検索してみる。

# 28番目の小説に似ている小説は?(28番目の小説のタイトルは、「公爵令嬢の嗜み」)
print(m.docvecs.most_similar(28))

すると今度は以下のような結果が表示された。
[(35, 0.5070601105690002), (46, 0.35368871688842773), (47, 0.34039080142974854), (15, 0.32830286026000977), (7, 0.30432310700416565), (5, 0.2750416100025177), (8, 0.24726390838623047), (38, 0.23762762546539307), (12, 0.21315187215805054), (17, 0.20340490341186523)]

最初の3つの小説のタイトルは、以下の通り。
35:乙女ゲームの破滅フラグしかない悪役令嬢に転生してしまった…
46:(´・ω・`)最強勇者はお払い箱→魔王になったらずっと俺の無双ターン
47:転生した大聖女は、聖女であることをひた隠す

今度は類似度0.5の小説が出てきたが、タイトルを見る限りかなり似ている気がする。それぞれの小説のタグも「R15 異世界転生 悪役令嬢 転生」、「異世界転生 乙女ゲーム 悪役令嬢 転生 悪役 魔法 逆ハー(性別問わず) 犬とは犬猿の仲」となっているため、小説の内容も近いものと思われる。

似ている単語を探す

# 魔法に似た単語は?
print(m.wv.most_similar('魔法'))

以下のような実行結果になった。
[(‘使える’, 0.5868769884109497), (‘基礎式’, 0.5487741827964783), (‘古代’, 0.5475476384162903), (‘全属性’, 0.5122957229614258), (‘氷’, 0.5110565423965454), (‘基礎’, 0.5063040256500244), (‘失われ’, 0.5040603280067444), (‘火炎’, 0.4972696006298065), (‘初級’, 0.4933454394340515), (‘トーチ’, 0.48877549171447754)]

類似度が大体0.5程度の単語が出て来ているが、出てきた理由が良くわからない単語も含まれている。これは魔法という単語が小説を読もう内で広範囲に使われているせいだろうか。

# スライムに似た単語は?
print(m.wv.most_similar('スライム'))

今度は以下のような実行結果になった。
[(‘マザースライム’, 0.7109331488609314), (‘消化’, 0.7005775570869446), (‘栄養’, 0.7005000114440918), (‘キャタピラー’, 0.6742416620254517), (‘魔獣’, 0.6534003019332886), (‘グリーン’, 0.6487153768539429), (‘スティッキースライム’, 0.6390945911407471), (‘野生’, 0.6230173707008362), (‘食べさせ’, 0.609244167804718), (‘分裂’, 0.5920956134796143)]

こちらはかなり良好な実行結果になっていると思われる。

さらにDoc2Vecは文字の足し算引き算をすることが出来る。

# 魔法に水を足す
print(m.wv.most_similar(positive=['魔法', '水']))
# 魔法から最強を引く
print(m.wv.most_similar(positive=['魔法'], negative=['最強']))

それぞれ、実行結果は以下の通り。
[(‘氷’, 0.6695120334625244), (‘出せる’, 0.6237022280693054), (‘火’, 0.6203511953353882), (‘風’, 0.5940117239952087), (‘水魔法’, 0.5882148146629333), (‘雷’, 0.5841056108474731), (‘風魔法’, 0.5786388516426086), (‘属性’, 0.5733253955841064), (‘SS’, 0.5640972852706909), (‘唱え’, 0.563363254070282)]

[(‘氷’, 0.5235071182250977), (‘電気’, 0.4570558965206146), (‘トーチ’, 0.4391556680202484), (‘指先’, 0.4334547519683838), (‘唱え’, 0.4241408109664917), (‘おー’, 0.40032967925071716), (‘イメージ’, 0.388439416885376), (‘驚く’, 0.3821325898170471), (‘ゴブリン・ソーサラー’, 0.3783293664455414), (‘魔炎’, 0.3754980266094208)]

魔法に水を足した場合と、最強を引いた場合の最も類似度の高い単語が同じ「氷」になり面白い結果になった。

未学習の小説のベクトル化

学習していない小説も、学習済みモデルを使用してベクトル化することが出来る。これを使うと学習済み小説の中から似ている小説を探すことが出来る。

# 本来は小説の本文を形態素解析するべきだが、あえて少ない文字で検索してみる
x = m.infer_vector(keitaiso('主人公最弱 ヒロイン最強'))
print(m.docvecs.most_similar([x]))

実行結果は以下の通り。
[(29, 0.3471960127353668), (26, 0.3468911945819855), (42, 0.34553420543670654), (48, 0.33736804127693176), (6, 0.3323182463645935), (32, 0.3290942907333374), (27, 0.3264150023460388), (38, 0.3221890926361084), (19, 0.30004316568374634), (17, 0.29801973700523376)]

最初の3つの小説のタイトルは、以下の通り。
29:望まぬ不死の冒険者
26:魔王様の街づくり!~最強のダンジョンは近代都市~
42:即死チートが最強すぎて、異世界のやつらがまるで相手にならないんですが。

「望まぬ不死の冒険者」は最初のうちは主人公が弱かったはず。5話までしかスクレイピングしていないため、このような結果になったと思われる。「魔王様の街づくり!」も成り上がり系だから同様と思われる。「即死チート」はよくわからず。さすがにサンプル不足のため、検索機能として使うには厳しい実行結果となった。もう少しサンプルを増やせば変わってくるのではないだろうか。

まとめ

今回はかなり少ない小説の数で実行したにも関わらず、それなりに面白い結果を得ることが出来た。もっとサンプル数を増やせば実行結果も安定してくるのではないだろうか。

小説を読もうの累計ランキングをDoc2Vecで解析する その2へ続く。