Unreal Engine 5をインストールしてCity Sampleを見てみたい

Unreal Engine 5のCity Sampleとは何か

The Matrix Awakens: An Unreal Engine 5 Experienceの技術デモはPlayStation 5 と Xbox Series X/Sでしか公開されていなかったが、Unreal Engine 5が先日リリースされ、この都市シーンをPC上で動かしてみることが出来るようになった。(ただし、Matrix関係のコンテンツは含まれない)

City Sample をインストールする

Epic Gamesから、Unreal Engine 5をダウンロードし、インストールする。(Epic Games Launcherが必要になる)

Unreal EngineのマーケットプレイスからCityサンプルを探して入手する。

ライブラリのマイダウンロードにあるCityサンプルから、プロジェクトを作成する。

Cityサンプルの容量は約93GB程あるので、ダウンロードに時間がかかる。

Unreal Engineは全く触ったことがないので、サンプル起動後Small Cityをロードしてみた。

プレビューで動かすことは出来たけど、この後パッケージするにはどうしたら良いのだろう。

※追記:パッケージ化出来るようになった。
1.勝手に日本語化されていたタイトルの2バイト文字を1バイト文字に変更(Cityサンプル→CitySample)。
2.下記から.NET Core 3.1 ランタイムをダウンロードしてインストール。
https://aka.ms/dotnet-core-applaunch?framework=Microsoft.WindowsDesktop.App&framework_version=3.1.0&arch=x64&rid=win10-x64

Spring Boot 2.6でHibernate Search 6を動かす

依存関係

implementation 'org.hibernate.search:hibernate-search-mapper-orm:6.1.4.Final'
implementation 'org.hibernate.search:hibernate-search-backend-lucene:6.1.4.Final'

実装

Entityクラスに@Indexed、メソッドに@FullTextFieldを追加する。

@Entity
@Table(name = "novel")
@Indexed
public class Novel implements Serializable {

...

    @FullTextField
    private String title;

Serviceクラスに検索処理のロジックを追加する。

@Service
public class NovelService {

...

    @Transactional
    public Stream<Novel> searchIndex(final String searchParameters) {
        SearchSession searchSession = Search.session(entityManager);

        SearchResult<Novel> result = searchSession.search(Novel.class)
                .where(f -> f.bool()
                        .must(f.match()
                                .field("title")
                                .matching("異世界")))
                .fetchAll();
        return result.hits().stream();
    }

実際に動くコードはGitHubに公開予定。公開完了したら、下記に追記する。

※4/22追記
NovelService.java – github

public Stream<Novel> searchIndex(final String searchParameters) {
    SearchSession searchSession = Search.session(entityManager);
    String operationSetExper = String.join("|", SearchOperation.SIMPLE_OPERATION_SET);
    Pattern pattern = Pattern.compile(
            "(\\p{Punct}?)(\\w+?)(" + operationSetExper + ")(\\p{Punct}?)(\\w+?)(\\p{Punct}?),",
            Pattern.UNICODE_CHARACTER_CLASS);
    Matcher matcher = pattern.matcher(searchParameters + ",");

    SearchResult<Novel> result = searchSession.search(Novel.class)
            .where(f -> f.bool(b -> {
                b.must(f.matchAll());
                while (matcher.find()) {
                    b.must(f.match().field(matcher.group(KEY))
                            .matching(matcher.group(VALUE)));
                }
            }))
            .fetchAll();

    return result.hits().stream();
}

Spring BootでLuceneAnalysisConfigurerがうまく動かなかった件

Spring Boot 2.6はHibernate Search 6に対応している。そこで、日本語の検索も出来るようにするため、lucene-analyzers-kuromojiのAnalyzerをLuceneAnalysisConfigurerで設定しようとした。しかし、公式のガイド通りやってもうまくいかなかったため、メモを残しておく。

依存関係

implementation 'org.apache.lucene:lucene-analyzers-kuromoji:8.11.1'
implementation 'org.hibernate.search:hibernate-search-mapper-orm:6.1.4.Final'
implementation 'org.hibernate.search:hibernate-search-backend-lucene:6.1.4.Final'

LuceneAnalysisConfigurerの実装

@Component("customLuceneAnalysisConfigurer")
public class CustomLuceneAnalysisConfigurer implements LuceneAnalysisConfigurer {

    /**
     * {@inheritDoc}
     */
    @Override
    public void configure(LuceneAnalysisConfigurationContext context) {
        context.analyzer("japanese").instance(new JapaneseAnalyzer());
    }
}

CustomLuceneAnalysisConfigurerの設定

公式の例では、実装したクラスを直接crawlerapi.config.CustomLuceneAnalysisConfigurerのように指定していたのだが、上記のようにComponentとして登録してから、下記のように参照するようにしないとSpring Bootの起動でハングアップした。

spring:
  jpa:
    properties:
      hibernate:
        search.backend:
          analysis.configurer: customLuceneAnalysisConfigurer

設定したAnalyzerの使用方法

先ほど定義した名前で参照できる。

@FullTextField(analyzer = "japanese")

element-ui 2からelement-plus 2にアップグレードする

今まではelement-ui 2を使用していたが、今後はVue 3に対応しているelement-plus 2を使用する。変更点のメモを残しておく。

element-ui 2の依存関係

"element-ui": "^2.15.6",

element-plus 2の依存関係

"@element-plus/icons-vue": "^1.1.4",
"element-plus": "^2.1.8",

element-ui 2の初期設定

import Vue from 'vue'
import App from './App.vue'
import ElementUI from 'element-ui'
import locale from 'element-ui/lib/locale/lang/ja'
import 'element-ui/lib/theme-chalk/index.css'

Vue.use(ElementUI, {locale})

new Vue({
  render: h => h(App)
}).$mount('#app')

element-plus 2の初期設定

import { createApp } from 'vue'
import App from './App.vue'
import ElementPlus from 'element-plus'
import locale from 'element-plus/lib/locale/lang/ja'
import 'element-plus/dist/index.css'

const app = createApp(App)

app.use(ElementPlus, {locale})

app.mount('#app')

vee-validate 3から4にアップグレードする

APIが完全に変わったため、マイグレーションガイドは存在しないらしい。変更点のメモを残しておく。

vee-validate 3の依存関係

"vee-validate": "^3.4.13",

vee-validate 4の依存関係

"@vee-validate/i18n": "^4.5.10",
"@vee-validate/rules": "^4.5.10",
"vee-validate": "^4.5.10",

vee-validate 3の初期設定

import Vue from "vue"
import { extend, localize } from "vee-validate"
import { required, max, email } from "vee-validate/dist/rules"
import en from "vee-validate/dist/locale/en.json"
import ja from "vee-validate/dist/locale/ja.json"
import enNames from '../locale/enNames.json'
import jaNames from '../locale/jaNames.json'

extend("required", required)
extend("max", max)
extend("email", email)

localize({
  en: {
    messages: en.messages,
    names: enNames
  },
  ja: {
    messages: ja.messages,
    names: jaNames
  }
})

let LOCALE = "ja"

Object.defineProperty(Vue.prototype, "locale", {
  get() {
    return LOCALE
  },
  set(val) {
    LOCALE = val
    localize(val)
  }
})

vee-validate 4の初期設定

import { defineRule, configure } from 'vee-validate';
import { required, max, email } from '@vee-validate/rules';
import { localize, setLocale } from '@vee-validate/i18n';
import en from '@vee-validate/i18n/dist/locale/en.json';
import ja from '@vee-validate/i18n/dist/locale/ja.json';
import enNames from '../locales/enNames.json'
import jaNames from '../locales/jaNames.json'

defineRule('required', required)
defineRule('max', max)
defineRule('email', email)

const customLocalize = localize({
  en: {
    messages: en.messages,
    names: enNames
  },
  ja: {
    messages: ja.messages,
    names: jaNames
  }
})

setLocale('ja')

configure({
  generateMessage: customLocalize
})

vee-validate 3の使用方法

<template>
  <ValidationObserver
    ref="observer"
    v-slot="{ passes }"
  >
    <el-form
      ref="form"
      class="login"
    >
      <h2>Sign up</h2>
      <ValidationProvider
        name="username"
        rules="required|max:16"
        v-slot="{ errors }"
      >
        <el-form-item
          :error="errors[0]"
          class="input-form-wrapper"
        >
          <el-input
            type="text"
            placeholder="Username"
            v-model="username"
          />
        </el-form-item>
      </ValidationProvider>
      <el-button
        type="primary"
        @click="passes(signup)"
      >Signup</el-button>
<script>
import { ValidationProvider, ValidationObserver } from "vee-validate"

export default {
  components: {
    ValidationProvider,
    ValidationObserver
  },
  methods: {
    signup: function () {
      const { username, password, email } = this

vee-validate 4の使用方法

※vee-validate以外にも、element-uiからelement-plusに変更している。element-plusの変更点については、こちらを参照のこと。

<template>
  <Form
    as="el-form"
    :validation-schema="schema"
    @submit="onSubmit"
  >
    <h2>Sign up</h2>
    <Field
      name="username"
      v-slot="{ value, field, errorMessage }"
    >
      <el-form-item
        :error="errorMessage"
        class="input-form-wrapper"
      >
        <el-input
          type="text"
          placeholder="Username"
          v-bind="field"
          :model-value="value"
        />
      </el-form-item>
    </Field>
<script setup>
import { Field, Form } from "vee-validate";

const schema = {
  username: 'required|max:16'
}

function onSubmit(values) {
  const { username, password, email } = values

記述量が減って良いと思う。

WordPressのパーマリンクに日付や投稿名を指定すると404になる場合の対処法

新規に環境を構築したとき、いつものようにパーマリンクを日付と投稿名にすると、リンクが404エラーになってしまうことがある。リンクに日付や投稿名を設定する機能はmod_rewriteによって実行されているため、以下の通り有効にする必要がある。

mod_rewriteの有効化

sudo a2enmod rewrite

.htaccessを使った設定の上書きを許可

httpd.conf内の該当Directory設定のAllowOverrideをallに変更する。

&lt;Directory /var/www/>
	Options Indexes FollowSymLinks
	AllowOverride all
	Require all granted
&lt;/Directory>

さらに、Wordpressをインストールしたフォルダの書き込み権限があることを確認する。Wordpressによってフォルダ下の.htaccessにrewriteの設定が自動的に作成されることになるため、書き込み権限が必要になる。

例えばパーマリンクを日付と投稿名に変更すると、下記のように.htaccessが自動生成される。

# BEGIN WordPress
# "BEGIN WordPress" から "END WordPress" までのディレクティブ (行) は
# 動的に生成され、WordPress フィルターによってのみ修正が可能です。
# これらのマーカー間にあるディレクティブへのいかなる変更も上書きされてしまいます。
&lt;IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /wordpress/
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /wordpress/index.php [L]
&lt;/IfModule>

# END WordPress

Amazon Web Services(AWS)でWordPressを動かす

1.やりたいこと

AWSの無料利用枠を利用して、Wordpress環境を構築し、インターネットに公開する。

2.AWSのアカウント作成

メールアドレスと、クレジットカードが必要になるため、事前に用意しておく。メールアドレスはフリーのアドレスで大丈夫だった。

下記の「無料アカウントを作成」をクリックし、指示通り入力を進める。
https://aws.amazon.com/jp/free/

3.Wordpressの実行に必要なもの

  • Linuxサーバー
    • apache(Wordpressをインストールする)
    • mysql(Wordpressの記事を保存する)

Linuxサーバーの構築

Amazon Elastic Compute Cloud (Amazon EC2) はアカウント登録から12か月無料でサーバーを借りることが出来る。「無料利用枠の対象」と表記のあるLinuxサーバーであればどれでもよい。

最初の設定は全てデフォルトで良いが、セキュリティグループの設定でSSHによるアクセスを許可する場合は、警告文にある通りアクセスを制限することを検討したほうが良い。

最後に画面の指示通りにSSHでアクセスするためのキーペアを作成する。これはSSHで接続するとき必要になるため、作成後必ずダウンロードしておく。

apache、phpのインストールと設定

sudo apt install apache2
sudo a2enmod rewrite
sudo a2enmod ssl
sudo a2ensite default-ssl
sudo apt install php libapache2-mod-php
sudo apt install php-fpm php-common php-mbstring php-xmlrpc php-gd php-xml php-mysql php-cli php-zip php-curl php-imagick

WordPressのパーマリンクに日付と投稿名を使用するため、.htaccessによる設定の上書きを有効にする。下記の通り、httpd.confのAllowOverrideをallに変更する。

&lt;Directory /var/www/>
	Options Indexes FollowSymLinks
	AllowOverride all
	Require all granted
&lt;/Directory>

設定を有効にするため、apacheを再起動する。

sudo service apache2 restart

DBサーバーの追加

Amazon RDSを使用する。こちらも無料利用枠があるのでMariaDBを選択した。

DBインスタンス識別子はわかりやすいように変更しておく。

追加設定を開き最初のデータベース名を入力する。これを設定しておくと環境構築時にデータベースの作成まで行ってくれる。

データベースの作成には少々時間がかかるので、気長に待つ。その間にデータベース用のセキュリティグループを作成する。

セキュリティグループの作成

セキュリティグループのインバウンドルールにMysqlの許可を追加する。外部からアクセスさせる必要はないので、ソースに先ほど作成したEC2のセキュリティグループを指定して、EC2サーバーからのみアクセス出来るように制限する。

データベースの作成とセキュリティグループの設定が終わったら、両者を忘れずに紐付る。

4.Wordpressのインストール

作成したEc2サーバーからAmazon RDSにアクセスし、Wordpressデータベースアクセス用のユーザを作成する。

export MYSQL_HOST=wordpress.作成したAmazon RDSのエンドポイント参照.rds.amazonaws.com
mysql -u ユーザー名 -p wordpress
 CREATE USER 'wordpress' IDENTIFIED BY 'パスワード';
 GRANT SELECT, UPDATE, DELETE, INSERT, CREATE, DROP, INDEX, ALTER, LOCK TABLES, EXECUTE, CREATE TEMPORARY TABLES, TRIGGER, CREATE VIEW, SHOW VIEW, EVENT ON wordpress.* TO wordpress;
 FLUSH PRIVILEGES;

WordPressはダウンロードして、解凍するだけで良い。

5.動作確認

一時的にEC2にアクセス出来るようにする。先ほど作成したEC2サーバーのセキュリティグループを選択し、インバウンドルールの編集から80ポートのアクセス許可を追加する。

アクセスに必要なパブリックIPやDNSはインスタンス概要に記載がある。

今回はここまで。Wordpressを動かすだけであれば、これだけで良いが、本番運用するには、まだいくつか設定が必要になる。

Pythonの開発環境をWSLで構築するまで

Windows の機能の有効化または無効化を開き、仮想マシンプラットフォームを有効にする。

Power Shellを管理者権限で起動する。WSL2にUbuntuの構成で良ければ以下のコマンドを実行するだけで良い。

wsl --install

Windowsの再起動後、インストールしたUbuntuを更新する。

sudo apt update
sudo apt upgrade

Pythonをインストール。

sudo apt install -y python3 python3-pip

Download Visual Studio CodeからWindows用インストーラーをダウンロードし、インストールする。

Ubuntu上で以下を実行する。

code ./<作業フォルダ>

WSLにリンクした状態でVisual Studio Codeが起動する。

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.まとめ

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