小説を読もうの累計ランキングを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へ続く。

ColaboratoryにGoogle Driveをマウントする

Colaboratoryは一定時間が経過すると仮想環境と切断されてしまい保存したファイルも消えてしまう。Google Driveをマウントしておけば学習済みモデル等をそちらに保存しておくことが出来る。

1.左端のフォルダのアイコンをクリックする。

2.Google Driveのアイコンをクリックする。

3.Google Driveへのアクセスを許可する。

4.My DriveにGoogle Driveのルートフォルダがマウントされる。

5.使用方法

with open('drive/My Drive/test.txt', 'w') as f:

GoogleのTsunamiを使ってみる

1.概要

Tsunamiは、重大度の高い脆弱性を検出するための拡張可能なプラグインシステムを備えた汎用ネットワークセキュリティスキャナー。
https://opensource.googleblog.com/2020/06/tsunami-extensible-network-scanning.html

2.実行環境の準備

yum install nmap ncrack
※JDKを別途用意すること

3.ビルド&実行

mkdir tsunami
cd tsunami
bash -c "$(curl -sfL https://raw.githubusercontent.com/google/tsunami-security-scanner/master/quick_start.sh)"

ビルドに成功すると下記のような実行コマンドが表示される。後はコピペして実行するだけで/tmp/tsunami-output.jsonにレポートが出力される。

cd /home/user/tsunami && \
java -cp "tsunami-main-0.0.2-SNAPSHOT-cli.jar:/home/user/tsunami/plugins/*" \
-Dtsunami-config.location=/home/user/tsunami/tsunami.yaml \
com.google.tsunami.main.cli.TsunamiCli \
--ip-v4-target=127.0.0.1 \
--scan-results-local-output-format=JSON \
--scan-results-local-output-filename=/tmp/tsunami-output.json

機械学習の勉強

環境準備

Javaでも出来るけど、やっぱりPythonを使いたい。でも環境構築するのは面倒だから、GoogleのColaboratoryを使う。

Colaboratory(略称: Colab)では、ブラウザから Python を記述し実行できるほか、次のような特長がある。 => Colab の紹介

  • 構成が不要
  • GPU への無料アクセス
  • 簡単に共有

ゴール

YOMOU CRAWLERで収集した小説の内容を元に自分におすすめの小説を探す。

Spring Bootが2.3にバージョンアップ

いくつかのSpringプロジェクトが新しいバージョンに更新されている。

  • Spring Data Neumann
  • Spring HATEOAS 1.1
  • Spring Integration 5.3
  • Spring Kafka 2.5
  • Spring Security 5.3
  • Spring Session Dragonfruit

Validation関連のクラスを分離したようで、自分のプロジェクトでは以下の一文をbuild.gradleに追加する必要があった。

implementation 'org.springframework.boot:spring-boot-starter-validation'

JPAのパフォーマンス改善

使い方によってパフォーマンスが大きく変わる。こういう使い方が駄目というわけではなく、場合によっては別の方法を試した方が良いという例。

1.問題点

[YOMOU CRAWLER] 第2回 クラス図の作成」にある通りNovelには複数のHistoryがあって、更新のある度に追加保存している。

// 1.Novelのエンティティ
public class Novel extends BaseObject implements Serializable {

    /** 小説の更新履歴セット */
    @OneToMany(fetch = FetchType.LAZY, mappedBy = "novel", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<NovelHistory> novelHistories = new HashSet<>();

    public void addNovelHistory(NovelHistory novelHistory) {
        novelHistories.add(novelHistory);
        novelHistory.setNovel(this);
    }
// 2.Novelの更新履歴のエンティティ
public class NovelHistory extends BaseObject implements Serializable {

    /** 小説 */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "novel_id")
    private Novel novel;
// 3.使用箇所
// Novelオブジェクトは永続化済み
if (novelHistory != null) {
    // 小説の更新履歴が作成された場合
    novel.addNovelHistory(novelHistory);
}

このように永続化された状態のオブジェクトにAddするだけで自動的にInsert文が発行されるため、Javaのビジネスロジック開発に集中することが出来る。通常はこれで問題ないのだが、NovelHistoryに既に大量のデータが保存されているとパフォーマンスが問題になる。

FetchType.LAZYを指定しているため、novelHistories.add(novelHistory)を実行するときに関連するHistoryがデータベースからSelectされ、novelHistories変数に格納される。上記の例ではInsert前に以下のようなSQLが実行されている。

select 省略 from novel_history where novel_id = ?;

つまり、1件追加したいだけなのに関連する全てのHistoryをSelectしてしまっている。何千件もHistoryがあればそれだけでパフォーマンスが悪化する。

2.回避策

今回は以下の様に修正してこの問題を回避した。

// 3’.使用箇所
if (novelHistory != null) {
    // 小説の更新履歴が作成された場合
    novelHistory.setNovel(novel);
}

novelHistoryは永続化されていないため、適切な箇所でsaveする必要があるが、こうすれば単純にInsert文のみ発行されるようになる。

StandardPasswordEncoderは非推奨

Deprecated.
Digest based password encoding is not considered secure. Instead use an adaptive one way function like BCryptPasswordEncoder, Pbkdf2PasswordEncoder, or SCryptPasswordEncoder. Even better use DelegatingPasswordEncoder which supports password upgrades. There are no plans to remove this support. It is deprecated to indicate that this is a legacy implementation and using it is considered insecure.

DelegatingPasswordEncoderを使用する場合は、下記の通り。

@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class WebSecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        // デフォルトはbcrypt
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

又は、

<bean id="passwordEncoder" class="org.springframework.security.crypto.factory.PasswordEncoderFactories" factory-method="createDelegatingPasswordEncoder" />

Tempus Dominus Bootstrap Ver.5とFont Awesome Ver.5を同時に使用する

Font Awesome Ver.5を使用している場合、Tempus Dominus Bootstrapのdatetimepickerの時間アイコンが表示されない。表示されない理由はVer.4以前で使用していたアイコン「fa-clock-o」がVer.5から「fa-clock」に変わっているため。

そこで、以下の通り新しいアイコンを直接指定するように修正する。

$('#accountExpiredDatePicker').datetimepicker({
  icons: {
    time: 'fas fa-clock',
    date: 'fas fa-calendar',
    up: 'fas fa-arrow-up',
    down: 'fas fa-arrow-down'
  }
})
<div class="input-group date" id="accountExpiredDatePicker" data-target-input="nearest">
  <input type="text" class="form-control datetimepicker-input" th:classappend="${#fields.hasErrors('accountExpiredDate')} ? is-invalid" th:field="*{accountExpiredDate}" th:placeholder="#{datetimepicker.format}" data-target="#accountExpiredDatePicker" />
  <div class="input-group-append" data-target="#accountExpiredDatePicker" data-toggle="datetimepicker">
    <div class="input-group-text"><em class="fas fa-calendar"></em></div>
  </div>
</div>

HibernateでMultipleBagFetchExceptionが発生する

org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags

MultipleBagFetchExceptionがHibernateによってスローされる理由は、重複が発生する可能性があり、Hibernate用語でBagと呼ばれる順序付けられていないListが重複を削除することを想定していないため。

@Entity
@Table(name = "app_user")
public class User implements Serializable {

    @OneToMany(fetch = FetchType.LAZY, mappedBy="user", cascade = CascadeType.ALL)
    private List<UserNovelInfo> userNovelInfos = new ArrayList<>();
@Entity
@Table(name = "user_novel_info")
public class UserNovelInfo extends BaseObject implements Serializable {

    /** ユーザー */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "username")
    private User user;

Listを型に使用すると内部的には大体Bagが使用される。
重複が発生する可能性を除去出来ないのであれば、Setを使用するように変更することで例外は発生しなくなる。

    @OneToMany(fetch = FetchType.LAZY, mappedBy="user", cascade = CascadeType.ALL)
    private Set<UserNovelInfo> userNovelInfos = new HashSet<>();

JPA応用編:列挙型とN:Nで紐付ける

以下の3点を通常のカラムのアノテーションの他に追加する必要がある。
@CollectionTableを使って関連テーブルとカラムを指定する。
@Enumeratedで永続化フィールドを列挙型として指定する。
@ElementCollectionでEntityではないクラスも使用できるようにする。

@Entity
@Table(name = "app_user")
public class User implements Serializable {

    /** 権限 */
    @Column(nullable = false)
    @CollectionTable(name = "app_user_roles", joinColumns = @JoinColumn(name = "username"))
    @Enumerated(EnumType.STRING)
    @ElementCollection(targetClass = Role.class, fetch = FetchType.EAGER)
    private List<Role> roles;
/**
 * 権限
 */
public enum Role implements Serializable {

    /** 管理者 */
    ROLE_ADMIN,

    /** 一般 */
    ROLE_USER;
}
create table app_user (
    username varchar(16) not null,
    primary key (username)
);

create table app_user_roles (
    username varchar(16) not null,
    roles varchar(16) not null,
    primary key (username, roles)
);