Coherence(UMass)によるトピックモデルの評価

今回もトピックモデルの評価指標の話です。
前の2記事でPerplexityを扱ったので、今回は Coherence を扱います。

さて、トピックモデルの予測精度を評価していたPerplexityに対して、
Coherenceはトピックの品質を評価するものです。

人間が見て、このトピックは何の話題に言及するものだ、とわかりやすく分類できていたらCoherenceが高くなります。
そう説明すると単純なように思えるのですが、これを実現する指標を作るのはなかなか大変です。
そのため、Coherenceの定義はこれ、と明確に定まったものは無く、いろんな手法が提案されています。
gensimに定義されているものだけでも、u_mass, c_v, c_uci, c_npmi と4つがありますし、
実際に提唱されているものは人間が評価するものなどもっとあります。

これらの中で、別のコーパス(Wikipediaのデータなど)を用意しなくてよかったり、Google検索結果などを使わなくても良く、
計算速度も早い u_mass の使い方を紹介します。

提唱された論文はこちらです。
参考: Optimizing Semantic Coherence in Topic Models

どうでもいいのですが、 u_mass が何の略なのかずっと疑問でした。
論文を見ると University of Massachusetts のようですね。
Mimno(2011)のDavid Mimnoさんは Princeton University なのでなぜu_massと呼ばれているのかは謎です。

話を戻します。
論文中で提唱されているCoherenceは次の式で計算できます。

トピック$t$に対して、出現頻度の高い$M$個の単語の集合を$V^{(t)}=\{v_1^{(t)},\dots,v_M^{(t)}\}$とします。
$D(v)$を単語の出現文書数、$D(v_1,v_2)$を単語の共起文書数とするとトピック$t$のCoherenceは次の式になります。
$$
C(t; V^{(t)})=\sum_{m=2}^{M}\sum_{l=1}^{m-1}\log\frac{D(v_m^{(t)}, v_l^{(t)})+1}{D(v_l^{(t)})}.
$$

要するに、そのトピックの頻出単語たちがよく共起してるほど高くなるように作られています。
そのため、トピック数等の決定に使う場合は、Coherenceが高いものを採用します。
(Perplexityは低いものを採用するので注意です。)

定義から明らかですが、この指標は各トピックごとに計算されます。
そのため、モデルの評価として使うには各トピックごとのCoherenceの平均を使います。

前置きが長くなってきたので、サンプルコードは次の記事で書きたいと思います。
なお、 scikit-learnの方には実装がないようです。
そのため、scikit-learnでLDAを実装した場合は上の式を自分で実装する必要があります。

gensimには実装されているのでそちらを紹介予定です。

gensimのTopicモデルでPerplexityを計算する

前回、scikit-learnのトピックモデル(LDA)における評価指標として、Perplexityを算出する方法を紹介しました。
参考: トピックモデルの評価指標Perplexityの実験

今回はgensim版です。
gensimのLDAモデルには log_perplexity と言うメソッドがあるので、それを使うだけです、って話であれば前の記事とまとめてしまってよかったのですが、
話はそう単純では無いので記事を分けました。

さて、 log_perplexity ってメソッドですが、いかにも perplexity の自然対数を返してくれそうなメソッドです。
perplexity が欲しかったら、 $\exp(log\_perplexity)$ を計算すれば良さそうに見えます。
しかし、 log_perplexity は perplexity の自然対数では無いと言う事実を確認できるのが次の実験です。

前回の記事と同じく、4つのトピックにそれぞれ5単語を含む架空の言語で実験します。


import numpy as np
from gensim.corpora.dictionary import Dictionary
from gensim.models import LdaModel


word_list = [
    ["white", "black", "red", "green", "blue"],
    ["dog", "cat", "fish", "bird", "rabbit"],
    ["apple", "banana", "lemon", "orange", "melon"],
    ["Japan", "America", "China", "England", "France"],
]
corpus_list = [
    np.random.choice(word_list[topic], 100)
    for topic in range(len(word_list)) for i in range(100)
]

# 単語と単語IDを対応させる辞書の作成
dictionary = Dictionary(corpus_list)
# LdaModelが読み込めるBoW形式に変換
corpus = [dictionary.doc2bow(text) for text in corpus_list]

# トピック数4を指定して学習
lda = LdaModel(corpus, num_topics=4, id2word=dictionary)

# log_perplexity を出力
print(lda.log_perplexity(corpus))
# -2.173078593289852

出力が $-2.17\dots$です。
正常に学習できていれば、Perplexityは約5になるはずなので、$\log(5)=1.609\dots$が期待されるのに、符号から違います。

ドキュメントをよく読んでみましょう。
log_perplexity

Calculate and return per-word likelihood bound, using a chunk of documents as evaluation corpus.
Also output the calculated statistics, including the perplexity=2^(-bound), to log at INFO level.

これによると、perplexityは$2^{-bound}$だと言うことになっていて、どうやら、
log_perplexity()が返しているのは、boundに相当するようです。

計算してみましょう。


print(2**(-lda.log_perplexity(corpus)))
# 4.509847333880428

正解は5なので、それらしい結果が出ています。
ですがしかし、Perplexityとしてはこの値は良すぎます。
今回のダミーデータで学習している限りは5単語未満に絞り込めるはずがないのです。

実際、モデルが学習した結果を見てみましょう。


print(lda.show_topics(num_words=6))
"""
[
    (0, '0.100*"bird" + 0.098*"dog" + 0.092*"melon" + 0.092*"cat" + 0.089*"orange" + 0.089*"rabbit"'), 
    (1, '0.104*"red" + 0.104*"green" + 0.102*"white" + 0.098*"blue" + 0.092*"black" + 0.084*"fish"'),
    (2, '0.136*"lemon" + 0.134*"apple" + 0.128*"banana" + 0.117*"orange" + 0.116*"melon" + 0.045*"China"'),
    (3, '0.216*"France" + 0.191*"America" + 0.181*"Japan" + 0.172*"England" + 0.163*"China" + 0.011*"apple"')
]
"""

本来は各トピック上位の5単語を0.2ずつの出現確率と予測できていないといけないので、今 Perplexity を計算しているモデルは
そんなに精度が良くないのです。(ハイパーパラメーターのチューニングを何もしてないので。それはそれで問題ですが、今回の議題からは外します。)

おかしいので、ソースコードを眺めてみたのですが、 2を底とする対数を取ってる様子は無く、普通に自然対数が使われていました。
なので、これはドキュメントの誤りとみた方が良さそうです。(将来的に修正されると思います。)

perplexity=e^(-bound)

と考えると、辻褄があいます。


print(np.exp(-lda.log_perplexity(corpus)))
# 8.785288789149925

トピック数を 1〜4 と動かして算出してみると明らかです。
トピック数が1の時は全く絞り込めていないので元の単語数の約20,
2の時は半分に絞れるので約10,
4の時は、ちゃんと学習できたら正解の5(ただし、デフォルトのハイパーパラメーターだとそこまで成功しないのでもう少し大きい値)
が算出されるはずです。

やってみます。


for i in range(1, 7):
    # トピック数を指定してモデルを学習
    lda = LdaModel(corpus, num_topics=i, id2word=dictionary)

    print(f"トピック数: {i}, Perplexity: {np.exp(-lda.log_perplexity(corpus))}")
"""
トピック数: 1, Perplexity: 20.032145913774283
トピック数: 2, Perplexity: 11.33724134037765
トピック数: 3, Perplexity: 8.921203895821304
トピック数: 4, Perplexity: 7.436279264160588
トピック数: 5, Perplexity: 7.558708610631221
トピック数: 6, Perplexity: 5.892976661122544
"""

大体想定通りの結果は出ましたね。

さて、 log_perplexity は perplexity の対数では無く、
perplexity 対数の符号を反転させたものである、と言うのは認識しておかないと大間違いの元になります。

というのも、perplexityは小さい方が良いとされる指標です。
と考えると、log_perplexityの出力が小さいパラメーターを選んでしまいがちですがそれは誤りであることがわかります。
対数を取ってから符号を反転させているので、大きいものを採用しないといけないのですね。

(この他、必ずしもPerplexityが小さければいいモデルだとは言えない、と言う議論もあるのですが、
今日の記事の範囲は超えますので省略します。)

トピックモデルの評価指標Perplexityの実験

このブログでトピックモデルの記事を書いたことがあるのですが、
トピック数の決め方について書いてないのに気づいたので評価指標を紹介します。

参考: pythonでトピックモデル(LDA)

トピックモデルのトピック数を決めるときは、Perplexityもしくは、Coherenceと呼ばれる指標を参考にします。
今回の記事では、Perplexityを紹介します。

と言っても、数学的な定義やその意味についてはいろんな場所で紹介されているので、
この記事では趣向を変えて、架空のデータで実験して理解を深めることを目指します。

まず、 Perplexity の定義は、各単語の出現確率(尤度)の逆数の幾何平均です。
(数式はいろんなサイトに乗っているので省略します。
書籍では、奥村学さんの「トピックモデルによる統計的潜在意味解析」などに載っています。)

この定義だけでは意味がわからないのですが、
「分岐数、または選択肢の数を表している」と説明されることが多いです。

例えば、ある文章があって、単語が一つ隠されていたとします。
このとき、LDAによって、その単語の選択肢が2000まで絞り込めていたら、
そのモデルの Perplexity は 2000です。
単語を絞り込めている方が優れたモデルとされるので、この値は低い方が良いモデルです。

まだわかりにくいので、ここから実験をしていきましょう。
次のような架空の世界があったとします。

– その世界の言葉には4個の話題(トピック)がある。
– 各話題ごとに、単語は5個ある。(つまりその世界に単語は20個しか無い)
– 各文章は一つの話題のみに言及する。

(これらの条件は正確にはトピックモデルではなく、混合ユニグラムモデルですが、わかりやすさのためご容赦ください。)

以上の設定のもとで、ランダムに100単語からなる文章をトピックごとに100個生成します。
コードを見ていただけるとわかりますが、4個の話題は色、動物、果物、国です。(なんでも良いのですが。)


import numpy as np

word_list = [
    ["white", "black", "red", "green", "blue"],
    ["dog", "cat", "fish", "bird", "rabbit"],
    ["apple", "banana", "lemon", "orange", "melon"],
    ["Japan", "America", "China", "England", "France"],
]
corpus = [
    " ".join(np.random.choice(word_list[topic], 100))
    for topic in range(len(word_list)) for i in range(100)
]

さて、あとは以前紹介したコードで、LDAモデルを作って、Perplexityを計算してみましょう。
scikit-learnの場合、ドキュメントにある通り、モデルがperplexityというメソッドを持っています。

トピック数はこの例では4が正解だとわかっているので、4を使います。

本当は、データを訓練データと評価データにわけて、評価データでperplexityを計算する必要があるのですが、
今回は実験なので訓練に使ったデータでそのまま評価します。


from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation

tf_vectorizer = CountVectorizer()
bow = tf_vectorizer.fit_transform(corpus)

# LDAのモデル作成と学習
lda = LatentDirichletAllocation(
    n_components=4,
)
lda.fit(bow)
# perplexityの計算
print(lda.perplexity(bow))
# 5.268629755256359

Perplexity は約 5.27 と、 5に近い値が出ましたね。
このLDAモデルで単語が5個くらいまで絞り込めていることがわかります。

Perplexity がトピック数の決定に使えることをみるために、他のトピック数でも計算してみましょう。


for c_num in range(1, 9):
    lda = LatentDirichletAllocation(
        n_components=c_num,
    )
    lda.fit(bow)
    print(f"トピック数: {c_num}, Perplexity: {lda.perplexity(bow)}")
"""
トピック数: 1, Perplexity: 20.033955224623902
トピック数: 2, Perplexity: 10.330848184515682
トピック数: 3, Perplexity: 7.397066706843117
トピック数: 4, Perplexity: 5.268629755256354
トピック数: 5, Perplexity: 5.305381334487885
トピック数: 6, Perplexity: 5.3074106945229875
トピック数: 7, Perplexity: 5.3206895866734305
トピック数: 8, Perplexity: 5.3529382429024315
"""

トピック数が1個の時は、全く絞り込めていないので、全単語数の20に近い値が出ています。
トピック数が2の場合は、半分に絞れているので約10ですね。
そして、トピック数が4の時に、大体5単語に絞れており、
それ以上トピック数を増やしても大きな改善はありません。
このことから、トピック数は4がベストだろうと判断することができます。

現実世界のデータで試すと、こんなに綺麗にトピック数を決めれたことが無く、
Perplexity の有効性に疑問を持っていたのですが、
理論的にはなかなか良い指標であることが確認できました。

WordPressのテーマ Twenty Seventeen のフッターを変更する

これまで、せっかく子テーマを設定したりblogの開発環境を立ち上げる方法を調べたりしたので、1箇所くらい修正してみようと思います。
修正したいのは、ブログの一番下のフッター部分です。
デフォルトでは、
「プライバシーポリシー / Proudly powered by WordPress」
となっています。
プライバシーポリシーへのリンクはそのままでいいですが、 Proudly powered by WordPress は特にいらないですね。
代わりに入れたいのはコピーライト表記です。
また、プロフィールページ(これはそのうち作りたい)がまだないので、
その代わりとして、LinkedInあたりへのリンクを作りたいと思っています。

さて、WordPressのテーマを子テーマを用いて修正する場合、
子テーマのディレクトリの配下に、親テーマと同じ配置でファイルを作ると、それが上書きされて動作します。
参考: WordPressの子テーマのページ

つまり、
(略)/themes/twentyseventeen-child/footer.php
と言うファイルを作ると、このファイルが、
(略)/themes/twentyseventeen/footer.php
を上書きして動作します。

現在利用している Twenty Seventeen の場合、 footer.phpの作りが少し特殊です。

get_template_part( 'template-parts/footer/site', 'info' );

とある通り、
(略)/themes/twentyseventeen-child/template-parts/footer/site.php
というファイルを読み込んでそれを表示しています。

開いてみると以下の内容でした。確かにこれがフッターの本体のようですね。


<?php
/**
 * Displays footer site info
 *
 * @package WordPress
 * @subpackage Twenty_Seventeen
 * @since Twenty Seventeen 1.0
 * @version 1.0
 */

?>
<div class="site-info">
        <?php
        if ( function_exists( 'the_privacy_policy_link' ) ) {
                the_privacy_policy_link( '', '<span role="separator" aria-hidden="true"></span>' );
        }
        ?>
        <a href="<?php echo esc_url( __( 'https://wordpress.org/', 'twentyseventeen' ) ); ?>" class="imprint">
                <?php
                        /* translators: %s: WordPress */
                printf( __( 'Proudly powered by %s', 'twentyseventeen' ), 'WordPress' );
                ?>
        </a>
</div><!-- .site-info -->

これを上書きするように子テーマにファイルを作成します。

なのでファイルの置き場所は以下になります。
(略)/themes/twentyseventeen-child/template-parts/footer/site-info.php

中身はこんな感じで作ってみました。
(blogのURLと名前は、bloginfo(‘url’)とbloginfo(‘name’)でそれぞれ取得できるみたいです。)


<?php
/**
 * Displays footer site info
 *
 * @package WordPress
 * @subpackage Twenty_Seventeen
 * @since Twenty Seventeen 1.0
 * @version 1.0
 */

?>
<div class="site-info">
    <?php
        if(function_exists('the_privacy_policy_link')){
            the_privacy_policy_link('', '<span role="separator" aria-hidden="true"></span>');
    }
    ?>
    <a href="https://www.linkedin.com/in/yutaro-honda/">LinkedIn</a>
    <span role="separator" aria-hidden="true"></span>
    <a href="<?php bloginfo('url');?>">
        &copy; 2019 <?php bloginfo('name');?>
    </a>
</div><!-- .site-info -->

これでフッターを編集できました。

pandasのDataFrameをある列の値が特定の区間に含まれる行のみに絞る

pandasのSeriesにbetweenという便利なメソッドが定義されていたのでその紹介です。
これを使わなくても何も難しいことのない話なのですが、コードが少し短くなってすっきりするので気に入っています。

ドキュメント: pandas.Series.between

これは、Seriesに対して、最小値、最大値を渡すと、
Seriesの各値に対して、その値が最小値と最大値の範囲に入っていればTrue, 入っていなければFalseを返すメソッドです。
これを使って、DataFrameの列の絞り込みができます。

試すために、いつものirisでDataFrameを作っておきます。


import pandas as pd
from sklearn.datasets import load_iris
# アヤメのデータを読み込んでDataFrameに整形
iris = load_iris()
columns = [c.replace(" (cm)", "") for c in iris.feature_names]
df = pd.DataFrame(iris.data, columns=columns)
df["target"] = [iris.target_names[t] for t in iris.target]
print(df.head())
"""
   sepal length  sepal width  petal length  petal width  target
0           5.1          3.5           1.4          0.2  setosa
1           4.9          3.0           1.4          0.2  setosa
2           4.7          3.2           1.3          0.2  setosa
3           4.6          3.1           1.5          0.2  setosa
4           5.0          3.6           1.4          0.2  setosa
"""

このようなデータから、たとえば、”petal length” が 4.0以上4.5以下の行を抽出したいとします。
それを単純に書くとこうなります。


df[(df["petal length"] >= 4.0) & (df["petal length"] <= 4.5)]

DataFrameの変数名を3回も書かないといけないですし、ちょっと冗長ですね。

これが betweenを使うと、次のように書けます。


df[df["petal length"].between(4.0, 4.5)]

少しだけすっきりしました。

実際に絞り込めているのを確認しておきましょう。 describe()メソッドを使って、その中から最小値と最大値だけ取ってみます。


print(df[df["petal length"].between(4.0, 4.5)].describe().loc[["min", "max"]])
"""
     sepal length  sepal width  petal length  petal width
min           4.9          2.2           4.0          1.0
max           6.7          3.4           4.5          1.7
"""

注意ないといけないのは、選択されるのは、
1つ目の引数 $<=$ 指定列の値 $<=$ 2つ目の引数 と言うふうに左右両方とも等号が入った閉区間の値であることです。 3つ目の引数、 inclusive にFalse を指定すると等号を含まなくなるのですが、これは最大最小両方とも統合を含まず、 1つ目の引数 $<$ 指定列の値 $<$ 2つ目の引数 の区間を取得するようになります。


print(df[df["petal length"].between(4.0, 4.5, False)].describe().loc[["min", "max"]])
"""
     sepal length  sepal width  petal length  petal width
min           5.5          2.3           4.1          1.0
max           6.7          3.1           4.4          1.5
"""

一番頻繁に使う、x以上、y未満、と言う形式の指定ができないのが短所ですね。

以上未満で指定したい場合は、下記のようにqueryメソッドなど別の方法も検討しましょう。


df.query("4.0 <= `petal length` < 4.5")

Googleアナリティクスの集計対象をホスト名でフィルタリングする

先日の記事で、WordPress(Lightsail)の開発環境を立てましたが、一つ問題があります。
そのページを開くとGoogleアナリティクスにアクセスログが記録されることです。

参考: LightsailのWordPressの開発環境を立てる

そんなにたくさんアクセスしたわけではないのでほとんど問題ないのですが、
分析する上でノイズになりうるのでできるだけ除外しておきたいです。

対策としては、開発環境を立ち上げたらすぐに管理画面に入ってGA関係の設定を外すこともできますし、
自宅のIPアドレスをフィルタリングしておくことも考えられます。
(そもそも、後者ができていれば今回の記事の対策は不要です。)

これらの方法以外にも、アクセスするときのホスト名によって集計対象とするかどうかを設定できるので、今回はそれを設定します。
要するに、
https://analytics-note.xyz/foo/bar/
と言うアクセスだけを集計し、
https://{IPアドレス}/foo/bar/
などのアクセスを集計対象から外します。

方法は非常に簡単です。

1. Googleアナリティクスの設定画面を開く。
2. 「フィルタ」を選択。
3. 「+フィルタを追加」を選択
4. 以下の内容を入力
フィルタ名 ・・・ 任意
フィルタの種類 ・・・ 定義済み、 右のみを含む、 ホスト名へのトラフィック、 等しい
ホスト名 ・・・ analytics-note.xyz
5. 「このフィルタを確認する」 を押下して設定を確認する。
6. 「保存」

これで、開発用に立ち上げた環境のアクセスログは集計されなくなりました。

LightsailのWordPressの開発環境を立てる

このBlogはWordPressの設定を最低限度にしか変更せずに運用しています。(記事執筆時時点)
ただ、それでも少々修正したい部分は発生しており、その一部はどうも管理画面では修正できず、PHPファイルを修正する必要があるようです。
(具体的に挙げと、フッターの、 「プライバシーポリシー Proudly powered by WordPress」 の部分など)

僕自身が普段はPHPを書いておらず、WordPressに不慣れなのに本番環境を直接いじって修正するのはリスクが高すぎるので、
開発環境が欲しいなと思ったので調べてみました。

このBlogはLightsailで動いているので、同じようにLightsailでWordpressインスタンスを立てて、
今入れているテーマやプラグインを追加していくのも方法の一つです。

ただ、そのようなことををしなくてもこのサーバーをそのままコピーできるらしいことがわかりました。

手順は以下の通りです。
1. Lightsail の管理画面に入り、このBlogが動いているインスタンスを選択。
2. スナップショットを選択し、手動スナップショットをとる。(自動スナップショットを取っている人はこの手順は不要)
3. 取得したスナップショットから、「新規インスタンスを作成」

これで、記事等も全部入った状態でインスタンスのコピーが立ち上がります。

あとは、
http://{IPアドレス}/
でアクセスしたら、開発環境にアクセスできて万歳、となると思っていたのですが、そうはならず、
何度試しても、本番環境(このBlog)にリダイレクトされてしまいました。

原因は以前設定したリダイレクト設定です。
参考: httpのアクセスをhttpsにリダイレクトする

開発サーバーにSSHログインし、bitnami.confに参考記事で追記した3行を消し、apacheを再起動すると、開発環境にアクセスできるようになります。

お金がかかるので、必要な修正の検討が終わったら作った開発環境とスナップショットは消しておきましょう。

Amazon Linux 2 (EC2)にphpMyAdminを導入

MySQLやその互換のDBを管理するphpMyAdminという便利なツールがあります。
僕が私用で使っている Aurora Serverless の管理もphpMyAdminで行おうとしたら、予想よりも苦戦したのでそのメモです。
参考: Amazon Aurora Serverlessを使ってみる

昔、Amazon Linux (無印)のインスタンスを建てたときは、ApachとPHPを入れて、phpMyAdminのソースファイルを配置しただけで
当時使っていた同じインスタンス内のMySQLの管理をすぐできるようになりました。
しかし当時の手順を元に、Amazon Linux 2で同様の操作を行ったら全然うまくいきませんでした。

改めて手順を探してみると、「チュートリアル: Amazon Linux 2 に LAMP ウェブサーバーをインストールする
というドキュメントが用意されており、これを参考にすることでうまくいきました。
(このチュートリアルでは、同サーバーにMariaDBを入れますが、僕はRDSのAurora Serverlessを使うので、MariaDB関係の手順は省略します。)

EC2インスタンス作成

最初にインスタンスを作ります。
DBをEC2インスタンスにインストールしている場合はそのインスタンスを使えば良いのでこの手順は不要です。

Amazon Linux 2 の AMIを選択し、通常通りインスタンスを作成します。
sshログインと、Webアクセスができるように、22番と80番のポートが開いたセキュリティグループを設定しておきます。

インスタンスができたら、sshで入って、yumをアップデートします。


$ sudo yum update -y

PHPのインストール

最初のハマりポイントがここです。
普通に、 
$ yum install -y php
とすると、バージョン 5.4.16 の非常に古いPHPが入ってしまいます。

チュートリアルに沿って、 amazon-linux-extras と言うのを使って 7系のPHPをインストールします。


$ sudo amazon-linux-extras install -y php7.2

# 入ったバージョンの確認
$ php -v
# 以下出力
# PHP 7.2.34 (cli) (built: Oct 21 2020 18:03:20) ( NTS )
# Copyright (c) 1997-2018 The PHP Group
# Zend Engine v3.2.0, Copyright (c) 1998-2018 Zend Technologies

Apacheのインストール

PHPの次は Apache のインストールと起動設定です。
Amazon Linux 2になって、無印の時とコマンドが変わっているので注意が必要です。


# Apache のインストール
$ sudo yum install -y httpd
# 起動
$ sudo systemctl start httpd
# サーバー起動時に自動的に起動するようにする
$ sudo systemctl enable httpd

以前は、起動は
$ service httpd start
で、自動起動は、
$ chkconfig httpd on
でしたね。

この時点でWebサーバーは立ち上がっているので、ブラウザを起動し、
http://{サーバーのIPアドレス}
にアクセスできることを確認します。

PHPの動作確認

ApacheでPHPが動くことを確認します。

昔は、Apacheの設定ファイル( /etc/httpd/conf/httpd.conf ) に
<FilesMatch \.php$>
SetHandler applocation/x-httpd-php
</FilesMatch>
など書いて設定しないと動かなかった覚えがあるのですが、それをしなくても動きます。

さて、動作確認に進みましょう。
ドキュメントルート(つまり、 /var/www/html)に移動し、phpinfo.phpというテキストファイルを生成します。


$ cd /var/www/html
$ vim phpinfo.php

# phpinfo.php に以下の内容を書き込む。
<?php phpinfo(); ?>

ブラウザから
http://{サーバーのIPアドレス}/phpinfo.php
にアクセスし、PHPのバージョンやシステム情報などのテーブルが表示されることが確認できたらPHPは動作しています。

phpMyAdminのインストール

ここから、phpMyAdmin本体のインストールです。

まず、phpの依存モジュールをインストールします。
(これはyumでいいらしいです。不思議です。)


# 必要な依存ファイルをインストール
$ sudo yum install php-mbstring -y
# Apache を再起動
$ sudo systemctl restart httpd
# php-fpm を再起動
$ sudo systemctl restart php-fpm

次に、phpMyAdminの本体ファイルをダウンロードして配置します。
ec2-userはドキュメントルート配下に書き込み権限を持っていないと思うので、
権限設定を適切にするか、面倒であればルートになって作業しましょう。


# ドキュメントルートに移動
$ cd /var/www/html
# phpMyAdminのファイルを取得する。
$ wget https://www.phpmyadmin.net/downloads/phpMyAdmin-latest-all-languages.tar.gz
# ファイル展開
$ mkdir phpMyAdmin && tar -xvzf phpMyAdmin-latest-all-languages.tar.gz -C phpMyAdmin --strip-components 1
# 不要ファイル削除
$ rm phpMyAdmin-latest-all-languages.tar.gz

以前は、 phpMyAdminのダウンロードページに訪問して、
最新バージョンを調べて、
wget https://files.phpmyadmin.net/phpMyAdmin/5.0.4/phpMyAdmin-5.0.4-all-languages.tar.gz
みたいに、バージョン指定して落としていたのですが、 latest で取れたんですね。
これは知りませんでした。

あとはブラウザで、
http://{IPアドレス}/phpMyAdmin/
にアクセスし、ログイン画面が表示されればインストールできています。

同サーバー内のDBの管理をするのであればこのまま使えます。

RDSへの接続を設定する

さて、冒頭の通り、僕が管理したいサーバーはAurora Serverlessなので、その設定を行います。
方法は設定ファイルにエンドポイントを指定するだけです。
まず、設定ファイルを作成します。
デフォルトでは設定ファイルは存在しておらず、config.sample.inc.phpと言うテンプレートを、
config.inc.php という有効なファイル名にコピーして使います。


# デフォルトの設定ファイルをコピーして設定ファイルを生成する
cd /var/www/html/phpMyAdmin
cp config.sample.inc.php config.inc.php

/var/www/html/phpmyadmin/config.inc.php の以下の行を書き換える
# 元の記述
$cfg['Servers'][$i]['host'] = 'localhost';
# 修正後の記述
$cfg['Servers'][$i]['host'] = '{RDSのエンドポイント}';

ログイン確認

以上の操作が全て終わったら、実際にphpMyAdminにログインして確認します。

ユーザーとパスワードは RDSのものを使います。
Aurora Serverless なので、停止状態の場合は起動に少し時間がかかりますが、数分程度待てば無事にログインできます。

gensimのモデルを保存するときのフォーマットを調べてみた

※ この記事を書いたときの僕の環境のgensimのバージョンは 3.8.0 です。

gensimでword2vecなり、トピックモデル(LDA)なり、そのための辞書なりを学習したとき、
saveメソッドで学習したモデルを保存し、同様にloadメソッドで読み込んで使うことができます。
このとき、ファイルに保存するので当然ファイル名を決めないといけないのですが、何形式で保存しているのかよくわからず拡張子に悩んでいました。

以前書いた、gensimでword2vec という記事のサンプルコードでは、ファイル形式がわからないのでとりあえず .model としています。
公式サイトの、Usage examples もそうなってるのですよ。

LDAの方は、
gensim.models.ldamodel.LdaModel.save
の Note を読むと、どうやら、pickleを使ってるように見えます。

ソースコードを確認してみましょう。
https://github.com/RaRe-Technologies/gensim/blob/master/gensim/models/ldamodel.py

def save(~略~):
のところを見ていくと、
salf.state ってのを使って、saveしていますね。

def __init__(~略~):
のところを確認すると、salf.state には、 LdaState というクラスのインスタンスが格納されており、
LdaStateは、
class LdaState(utils.SaveLoad):
とある通り、utils.SaveLoadを継承しています。
どうやらこれが保存と読み込みの本体のようです。

そのソースコードがこちらです。
https://github.com/RaRe-Technologies/gensim/blob/master/gensim/utils.py

このファイル内の、
class SaveLoad:
のところを見ていくと、普通にpickleを使ってファイルに書き出したり読み込んだ理されていることがわかりました。

LDAの次はWord2Vecです。
こちらは話が単純です。

ソースコードを見てみます。
https://github.com/RaRe-Technologies/gensim/blob/master/gensim/models/word2vec.py

class Word2Vec(utils.SaveLoad):

というふうに、モデル自体が、utils.SaveLoadを継承して作られており、先ほどのLDAと同様に保存と読み込みにはpickleが使われています。

Dictionary も同様です。
https://github.com/RaRe-Technologies/gensim/blob/master/gensim/corpora/dictionary.py

pickleは、このブログでも以前記事にしたことがあるようにPythonのオブジェクトを手軽にファイルに書き出せる形式です。
参考: pickleを使ってpythonのオブジェクトをファイルに保存する
ただ、これを使うとなると、いちいちwith openとかいろいろ書かないといけなくてややこしいので、
saveやloadなどわかりやすいメソッド名でラッピングしてもらえているのはありがたいですね。

さて、冒頭に挙げた問題の拡張子名ですが、.pickle あたりを使えば良さそうです。

gensimのDictionaryオブジェクトに含まれれる単語を出現頻度で絞り込む

最近久々にgensimのトピックモデルを使う機会がありました。
そのとき、出現する単語を頻度で絞り込みたったので方法を調べました。

トピックモデルの方法自体は、既に記事を書いてますのでこちらをご参照ください。
参考: gensimでトピックモデル(LDA)をやってみる

さて、gensimのLDAは、学習するコーパスを(単語, 出現回数) というタプルの配列に変換して読み込ませる必要があり、
その形への変換に、gensim.corpora.dictionary.Dictionaryを使います。
この辞書は、何も指定しないと、1回以上出現した単語を全部学習してしまいます。
それを、Scikit-learnのCountVectorizerで、min_dfを指定したときみたいに、n回以上出現した単語のみ、と足切りしたいというのが今回の記事の目的です。

Dictionaryの語彙学習時に指定できる引数の中に、CountVectorizerのmin_dfに相当するものがなかったので、てっきり指定できないのかと思っていたのですが、
じつは、学習した後に、語彙を絞り込む関数である、filter_extremesが用意されていることがわかりました。

使いかたを説明するために、まず適当な単語の羅列でコーパスを作って、辞書を学習しておきます。


import numpy as np
from gensim.corpora.dictionary import Dictionary

# 単語リスト作成
words = [
    "White",
    "Black",
    "Grey",
    "Red",
    "Orange",
    "Yellow",
    "Green",
    "Purple",
    "Blue",
    "Cyan",
    "Magenta",
]

# 再現性のためシードを固定する
np.random.seed(2)
# 単語を適当に選んで文章データを生成
documents = [
    np.random.choice(words, size=np.random.randint(3, 7)).tolist() for _ in range(10)
]

print(documents)
"""
[['Blue', 'Green', 'Grey'],
 ['Blue', 'Purple', 'Grey', 'Black', 'Yellow', 'Magenta'],
 ['Orange', 'Yellow', 'Purple'],
 ['Green', 'Orange', 'Magenta', 'Red', 'Purple', 'Green'],
 ['Black', 'Magenta', 'Red', 'Yellow', 'Blue'],
 ['Green', 'Red', 'Cyan'],
 ['Grey', 'White', 'Orange', 'Grey', 'Orange', 'Magenta'],
 ['Black', 'Purple', 'Blue', 'Grey', 'Magenta', 'Cyan'],
 ['Blue', 'Purple', 'Black', 'Green'],
 ['Magenta', 'Yellow', 'Cyan']]
"""

# 辞書の作成
dictionary = Dictionary(documents)

# 学習した単語リスト
for word, id_ in dictionary.token2id.items():
    print(f"id: {id_}, 単語: {word}, 出現ドキュメント数: {dictionary.dfs[id_]}, 出現回数: {dictionary.cfs[id_]}")

"""
id: 0, 単語:  Blue,    出現ドキュメント数: 5, 出現回数: 5
id: 1, 単語:  Green,   出現ドキュメント数: 4, 出現回数: 5
id: 2, 単語:  Grey,    出現ドキュメント数: 4, 出現回数: 5
id: 3, 単語:  Black,   出現ドキュメント数: 4, 出現回数: 4
id: 4, 単語:  Magenta, 出現ドキュメント数: 6, 出現回数: 6
id: 5, 単語:  Purple,  出現ドキュメント数: 5, 出現回数: 5
id: 6, 単語:  Yellow,  出現ドキュメント数: 4, 出現回数: 4
id: 7, 単語:  Orange,  出現ドキュメント数: 3, 出現回数: 4
id: 8, 単語:  Red,     出現ドキュメント数: 3, 出現回数: 3
id: 9, 単語:  Cyan,    出現ドキュメント数: 3, 出現回数: 3
id: 10, 単語: White,   出現ドキュメント数: 1, 出現回数: 1
"""

これを4個以上の文章に登場した単語だけに絞りこみたいとすると、
filter_extremes(no_below=4)
を実行すれば良いよいうに思えます。
それでやってみたのがこちら。


dictionary.filter_extremes(no_below=4)
for word, id_ in dictionary.token2id.items():
    print(f"単語: {word}, 出現ドキュメント数: {dictionary.dfs[id_]}")
"""
単語: Blue, 出現ドキュメント数: 5
単語: Green, 出現ドキュメント数: 4
単語: Grey, 出現ドキュメント数: 4
単語: Black, 出現ドキュメント数: 4
単語: Purple, 出現ドキュメント数: 5
単語: Yellow, 出現ドキュメント数: 4
"""

Orange/Red/Cyan/White が消えましたね。Orangeは出現回数自体は4でしたが、ドキュメント数が3だったので消えています。
ここで注意なのが、出現ドキュメント数が6だった、Magentaも消えていることです。

これは、filter_extremesのデフォルトの引数が、(no_below=5, no_above=0.5, keep_n=100000, keep_tokens=None) と、
no_above=0.5 も指定されていることに起因します。
つまり、全体の0.5=50%よりも多く出現している単語も一緒に消してしまうわけです。
逆に、no_above だけ指定しても、no_belowは5扱いなので、4文書以下にしか登場しない単語は足切りされます。

この挙動が困る場合は、忘れないように、no_belowとno_aboveを両方指定する必要があります。


# もう一度辞書の作成
dictionary = Dictionary(documents)
dictionary.filter_extremes(no_below=4, no_above=1)
for word, id_ in dictionary.token2id.items():
    print(f"単語: {word}, 出現ドキュメント数: {dictionary.dfs[id_]}")
"""
単語: Blue, 出現ドキュメント数: 5
単語: Green, 出現ドキュメント数: 4
単語: Grey, 出現ドキュメント数: 4
単語: Black, 出現ドキュメント数: 4
単語: Magenta, 出現ドキュメント数: 6
単語: Purple, 出現ドキュメント数: 5
単語: Yellow, 出現ドキュメント数: 4
"""

出現回数で足切りするのではなく、残す単語数を指定したい場合は、keep_n を使えます。
(これにもデフォルト引数が入ってるので気をつけてください。元の単語数が100000を超えていたら、意図せず動作します)

5単語に絞り込むコードはこうなります。
単語は出現頻度が高い順に選ばれます。
no_below や no_aboveも同時に作用するので、これらの設定次第では、keep_nで指定したよりも少ない単語しか残らないことがあります。


# もう一度辞書の作成
dictionary = Dictionary(documents)
dictionary.filter_extremes(no_below=1, no_above=1, keep_n=5)
for word, id_ in dictionary.token2id.items():
    print(f"単語: {word}, 出現ドキュメント数: {dictionary.dfs[id_]}")
"""
単語: Blue, 出現ドキュメント数: 5
単語: Green, 出現ドキュメント数: 4
単語: Grey, 出現ドキュメント数: 4
単語: Magenta, 出現ドキュメント数: 6
単語: Purple, 出現ドキュメント数: 5
"""

あとは、あまり使わなさそうですが、 keep_tokens に単語を指定することで、no_belowや、no_aboveに関係なく、
その単語を残すことができます。


# もう一度辞書の作成
dictionary = Dictionary(documents)
dictionary.filter_extremes(no_below=5, no_above=1, keep_tokens=["White"])
for word, id_ in dictionary.token2id.items():
    print(f"単語: {word}, 出現ドキュメント数: {dictionary.dfs[id_]}")
"""
単語: Blue, 出現ドキュメント数: 5
単語: Magenta, 出現ドキュメント数: 6
単語: Purple, 出現ドキュメント数: 5
単語: White, 出現ドキュメント数: 1
"""

小ネタですが、Dictionaryオブジェクトは、各単語が出現したドキュメント数をdfs, 出現した回数をcfsという変数に保有しています。
filter_extremes を実行すると、dfsの方は単語が絞り込まれた上でidも振り直されるのですが、
cfsは単語が絞り込まれるだけで、idが振り直されません。
(なぜこんな仕様になっているのかは謎です。将来的に修正されるような気がします。)
直前のサンプルコードを動かした時点で、 dfsとcfs の中身を見たものがこちらです。
単語数が4個に減っているのは共通ですが、cfsの方はidが5とか10とか、元のままであることがわかります。


print(dictionary.dfs)
# {0: 5, 2: 5, 1: 6, 3: 1}
print(dictionary.cfs)
# {0: 5, 5: 5, 4: 6, 10: 1}