Amazon Aurora Serverlessを使ってみる

ずっと前から気になっていたまま、使ったことがなかったのですが重い腰を上げて Aurora Serverless を触ってみることにしました。

用途は仕事ではなく、自分の趣味と勉強でとあるデータを蓄積するDBです。
AWSのアカウント持っていてDBが必要なら、RDSを使うのが順当なのですが、
通常のRDSは個人で使うにはちょっとお高いので、これまではEC2に導入したMySQLを使っていました。
そこにAurora の Serverless が登場ということで、自分の分析作業時のみの課金で使えるとなれば非常にお得な気がします。
完全に移行できるかとコストはトータルどうなるか、といった懸念は残りますが、まずは一度触ってみることにしました。

Aurora Serverless にはいくつか制限事項があるようです。
それを満たせるように事前に準備を進めていきます。

詳細はこちらを参照: Aurora Serverless の制約事項

1. VPC (Virtual Private Cloud)

Aurora Serverless にパブリックなIPアドレスを割り当てることはできず、おなじVPC内からしかアクセスできないそうです。
なので、ローカルの端末から踏み台等使わずに直接繋ぐことはできなさそうですね。
また、EC2インスタンスををクライアントにする場合は、おなじVPCの中に置いておく必要があります。
(僕はVPCはデフォルトの1個しか使っていないので、この制限は特に意識することはありませんでした。
VPCを複数使っている人は注意が必要です。)

2. AWS PrivateLink エンドポイント

僕がこの辺りの用語に疎いので、ドキュメントをそのまま引用します。
各 Aurora Serverless DB クラスターには、2 つの AWS PrivateLink エンドポイントが必要です。VPC 内の AWS PrivateLink エンドポイントが制限に達した場合、その VPC にそれ以上 Aurora Serverless クラスターを作成することはできません。
要は、VPCにサブネットを2つ以上用意しておく必要があります。
これも自分の場合は元々東京リージョンに3個持っていたので特に作業の必要はありませんでした。

3. セキュリティグループ

ドキュメントの通り、3306番のポートを利用するので、「インバウンド」で、3306番ポートが開通したセキュリティグループが必要になります。
セキュリティグループの設定画面で「タイプ」に「MYSQL/Aurora」を選択すると自動的に必要な設定が入るので作っておきます。

4. クライアント

実際の利用にはDBに接続するクライアントが必要です。
MySQLか、その互換のMariaDBクライアントが入ったインスタンスを同じVPC内に立てておきましょう。

さて、準備が終わった後、実際に DBを作成するのですが、これは簡単でした。
手順にすると多いですが画面に沿って進むだけなのでほぼ迷いません。

1. AWSのコンソールにログインし、RDSの管理画面に移動する。
2. [データベースの作成]をクリック
3. 標準作成を選択 (簡単作成でも良い)
4. エンジンのタイプ は [Amazon Aurora]を選択
5. エディション は [MySQLとの互換性を持つ Amazon Aurora]
6. MySQLのバージョンを選ぶ。
  これは、ドキュメントで指定されているバージョンでないと、次の選択肢からサーバレスが消えるので注意が必要です。

7. データベースの機能 は [サーバーレス]
8. DBクラスター識別子 は何か名前をつける。
9. マスターユーザー名 を指定 (デフォルトは admin)
10. パスワードの設定。 (自分はパスワードの自動作成にしました)
11. キャパシティーの設定を変更。(予算を抑えるため気持ち低めにしました。)
12. [データベースの作成]をクリック

ここまで進むとDBの作成が始まり、数分でDBが出来上がります。
出来上がったら、[認証情報の詳細を表示] から、 adminのパスワードを入手します。
また、同時に エンドポイント もわかるのでこれも記録しておきます。

DBが作成できたら、クライアントの環境から以下のコマンドで接続できます。


mysql -h {エンドポイント} -u {ユーザー名} -p

Enter password: 
と聞かれるのでパスワードを入力する。

ポートはデフォルトの3306を使っているので、 -P 3306 はつける必要ありません。(つけても大丈夫ですが。)

これで、通常のDBとして、使用できるようになりました。

もしDBが作成できているのに接続できなかった場合、セキュリティグループを見直してみてください。
(僕は用意していたセキュリティグループと違うグループが付与されていてしばらく詰まりました。)

あとは、残る課題はお値段ですね。
しばらく使ってみて、どのくらいの金額になるのか様子をみようと思います。

※ 以下、追記
2~3日ほど放置した後、様子を見たらメキメキと課金されていました。触っていない間も稼働しっぱなしだったようです。

設定項目の中に、「数分間アイドル状態のままの場合コンピューティング性能を一時停止する」
というがあり、これにチェックを入れないと、期待してた使っていない間は停止する効果が得られないようです。
てっきりデフォルトだと思っていたので危なかったです。
(請求金額アラートに救われました)

matplotlibでレーダーチャート(メモリも多角形)を描写する

Pythonでレーダーチャートを書きたくなり、matplotlibでやってみたのでそのメモです。
公式にサンプルがあるのですが、2020年09月 現在うまく動きません。
参考: api example code: radar_chart.py
実装自体も、PolarAxesクラスを継承してメソッドを書き換えるかなり仰々しいものですし、
メモリが円形のままで、僕が望む形ではなかったのでゼロベースでやってみました。

まずライブラリをインポートして、適当にデータを作っておきます。
データはレーダーチャートで可視化する値とそれぞれのラベルがあれば良いです。


import matplotlib.pyplot as plt
import numpy as np

values = np.array([31, 18, 96, 53, 68])
labels = [f"データ{i}" for i in range(1, len(values)+1)]

さて、レーダーチャートですが、見栄えにこだわりがなければ簡単に書くことができます。
matplotlibで極座標のグラフを作り、一周ぐるっとplotするだけです。
簡易版ですがそのコードを先に紹介します。


# 多角形を閉じるためにデータの最後に最初の値を追加する。
radar_values = np.concatenate([values, [values[0]]])
# プロットする角度を生成する。
angles = np.linspace(0, 2 * np.pi, len(labels) + 1, endpoint=True)

fig = plt.figure(facecolor="w")
# 極座標でaxを作成。
ax = fig.add_subplot(1, 1, 1, polar=True)
# レーダーチャートの線を引く
ax.plot(angles, radar_values)
# レーダーチャートの内側を塗りつぶす
ax.fill(angles, radar_values, alpha=0.2)
# 項目ラベルの表示
ax.set_thetagrids(angles[:-1] * 180 / np.pi, labels)

ax.set_title("レーダーチャート", pad=20)
plt.show()

出力がこちらです。

これでも最低限の要件は満たせますね。ただ、データを表してる青の線が真っ直ぐなのに、
その目安となるメモリ線が円形なのが気になります。
また、普通の曲座標と違って、ラベルを上を始点にして時計回りにしたいです。

このラベルの開始位置と回転方向を変えるのは簡単なのですが、メモリを多角形にするのはまともに取り組むと非常に大変です。
(公式サンプルの様なClassを継承しての大がかりな改修が必要になります。)

なので、アプローチを変えてみました。
matplotlibの機能で引いてくれるメモリ線は全部消してしまいます。
そして、定数値のレーダーチャートとして、灰色の線を自分で引きました。

出来上がったコードがこちらです。


# 多角形を閉じるためにデータの最後に最初の値を追加する。
radar_values = np.concatenate([values, [values[0]]])
# プロットする角度を生成する。
angles = np.linspace(0, 2 * np.pi, len(labels) + 1, endpoint=True)
# メモリ軸の生成
rgrids = [0, 20, 40, 60, 80, 100]


fig = plt.figure(facecolor="w")
# 極座標でaxを作成
ax = fig.add_subplot(1, 1, 1, polar=True)
# レーダーチャートの線を引く
ax.plot(angles, radar_values)
# レーダーチャートの内側を塗りつぶす
ax.fill(angles, radar_values, alpha=0.2)
# 項目ラベルの表示
ax.set_thetagrids(angles[:-1] * 180 / np.pi, labels)
# 円形の目盛線を消す
ax.set_rgrids([])
# 一番外側の円を消す
ax.spines['polar'].set_visible(False)
# 始点を上(北)に変更
ax.set_theta_zero_location("N")
# 時計回りに変更(デフォルトの逆回り)
ax.set_theta_direction(-1)

# 多角形の目盛線を引く
for grid_value in rgrids:
    grid_values = [grid_value] * (len(labels)+1)
    ax.plot(angles, grid_values, color="gray",  linewidth=0.5)

# メモリの値を表示する
for t in rgrids:
    # xが偏角、yが絶対値でテキストの表示場所が指定される
    ax.text(x=0, y=t, s=t)
    
# rの範囲を指定
ax.set_rlim([min(rgrids), max(rgrids)])

ax.set_title("レーダーチャート", pad=20)
plt.show()

出力がこちら。

自分がイメージしていたのに近いものが作れました。
コード中にコメントを全部入れたので、ここからさらに見た目を変える場合はすぐ改良できると思います。

バートレット検定

前回の記事の中で、SciPyにはF検定の関数が実装されていないという話を書きました。
参考: PythonでF検定を実装する

しかし、その代わりにSciPyには分散が等しいことを検定する別の方法が実装されています。
その一つが、バートレット検定です。
関数はこれです。
scipy.stats.bartlett

複数(2個だけでなく3個以上も可能)のサンプルを渡してあげると、統計量とp値を計算してくれます。

今回の記事では、使い方の前にバートレット検定の統計量を紹介します。
参考にしたのは、東京大学出版会の自然科学の統計学 (青い本) の 94ページです。

サンプルの数を$a$とし、それぞれのサンプルのサイズを$n_i \ \ (j=1,\dots, a)$とします。
また、サンプルサイズの合計を$n=\sum_{j}n_j$と置いておきます。
各サンプルの不偏分散を
$$
V_i = \sum_{j} \frac{(y_{ij}-\bar{y_i})^2}{n_i-1}
$$
として、さらにこれらを併合したものを
$$
V_e = \sum_i \frac{(n_i-1)V_i}{n-a} = \sum_i \sum_j \frac{(y_{ij}-\bar{y_i})^2}{n-a}
$$
とします。
さらに、
$$
B = (n-a)\log{V_e} -\sum_i (n_i-1)\log{V_i}
$$
と置いて、
$$
B’ = \frac{B}{
1+\frac{1}{3(a-1)}\{\sum_i \frac{1}{n_i-1} – \frac{1}{n-a}\}
}
$$
と補正すると、この$B’$は自由度$a-1$の$\chi^2$分布に近似的にしたがいます。
それを利用して、検定を行うのがバートレット検定です。

数式に沿ってNumPyで実装してみたのが次の関数です。ランダム生成した3サンプルで試してみました。


from scipy.stats import norm
from scipy.stats import chi2
import numpy as np

# スクラッチで実装したバーレット検定量の算出関数
def bartlett_value(*args):
    # サンプル数
    a = len(args)
    # 各サンプルの サンプルサイズと不偏分散
    ni = np.zeros(a)
    Vi = np.zeros(a)
    for j in range(a):
        ni[j] = len(args[j])
        Vi[j] = np.var(args[j], ddof=1)

    # サンプルサイズの合計
    N = np.sum(ni)
    # Veの計算
    Ve = np.sum((ni-1)*Vi) / (N-a)

    # 統計量の計算
    B = (N-a) * np.log(Ve) - np.sum((ni-1)*np.log(Vi))
    C = 1+1/(3*(a-1))*(np.sum(1/(ni-1))-1/(N-a))

    return B/C


# サンプルを3種類生成する
data_a = norm(loc=3, scale=5).rvs(20)
data_b = norm(loc=7, scale=8).rvs(30)
data_c = norm(loc=-5, scale=5).rvs(15)

# 統計量を算出
b_value = bartlett_value(data_a, data_b, data_c)

# p値を算出
p_value = chi2(3-1).sf(b_value)

print("B値:", b_value)
# B値: 8.26319721031961
print("p値:", p_value)
# p値: 0.016057189188779606

$\alpha=0.05$とすると帰無仮説が棄却され、分散が等しくないことが検出されていますね。

もちろん、SciPyに関数は用意されているので、普段はこの様にスクラッチで実装する必要はなく、
呼び出すだけで良いです。結果が一致することを見ておきます。


from scipy.stats import bartlett


b_value, p_value = bartlett(data_a, data_b, data_c)
print("B値:", b_value)
# B値: 8.26319721031961
print("p値:", p_value)
# p値: 0.016057189188779606

バッチリ一致しました。

PythonでF検定を実装する

最近、とある二つのサンプルの分散が異なることを確認する機会があり、F検定を行う必要がありました。
いつもみたいにSciPyですぐにできるだろうと考えていたのですが、
Statistical functions をみる限りでは、SciPyにF検定は実装されていなさそうでした。

その代わり、バートレット検定とルビーン検定が実装されているのですが。
この二つの検定については別途勉強するとして、とりあえずF検定を行いたかったので自分で実装しました。
幸い、F分布自体はSciPyにあるので簡単です。
t検定のメソッドを参考にし、サンプルを二つ渡したらF統計量とp値を返してくれる関数ように実装しました。

※F検定自体の説明は今回は省略します。
興味のあるかたには、東大出版会の統計学入門(いわゆる赤本)の244ページ、12.2.4 母分散の比の検定 あたりが参考になります。


from scipy.stats import f
import numpy as np


def ftest(a, b):
    # 統計量Fの計算
    v1 = np.var(a, ddof=1)
    v2 = np.var(b, ddof=1)
    n1 = len(a)
    n2 = len(b)
    f_value = v1/v2

    # 帰無仮説が正しい場合にFが従う確率分を生成
    f_frozen = f.freeze(dfn=n1-1, dfd=n2-1)

    # 右側
    p1 = f_frozen.sf(f_value)
    # 左側
    p2 = f_frozen.cdf(f_value)
    # 小さい方の2倍がp値
    p_value = min(p1, p2) * 2

    # 統計量Fとp値を返す
    return f_value, p_value

きちんと実装できているかどうか確認するため、統計学入門の練習問題を一つ解いておきます。
252ページの練習問題、 12.2 の iii) が分散の検定なのでやってみます。

次のコードのdata_1とdata_2の分散が等しいというのが帰無仮説で、等しくないというのが対立仮説です。


# 問題文のデータを入力
data_1 = np.array([15.4, 18.3, 16.5, 17.4, 18.9, 17.2, 15.0, 15.7, 17.9, 16.5])
data_2 = np.array([14.2, 15.9, 16.0, 14.0, 17.0, 13.8, 15.2, 14.5, 15.0, 14.4])

# F統計量とp値を計算
f_value, p_value = ftest(data_1, data_2)
print(f"F統計量: {f_value:1.3f}")
# F統計量: 1.564
print(f"p値: {p_value:1.3f}")
# p値: 0.516

F統計量は正解と一致しましたし、帰無仮説が棄却できないのも確認できました。

GCPのテキストの感情分析をPython SDKでやってみる

前回の記事ではAPIを直接叩きましたが、現実的にはSDKを使った方が手軽なのでその方法を紹介します。

準備として、 クイックスタート: クライアント ライブラリの使用 をみながら以下の内容を行っておきます。
1. サービスアカウントの作成。
2. jsonファイルのローカル保存。
3. jsonファイルのパスを環境変数 GOOGLE_APPLICATION_CREDENTIALS に設定。
4. SDK google-cloud-language のインストール

サービスアカウントを使わずに既存のAPIキーを使ってどうにかできないか方法を探してたのですがそれは無理そうですね。
諦めてサービスアカウントを作りましょう。

準備ができたら実行してみます。 ハローワールド的なコード例があるのでほとんどそのまま動かしました。


# ライブラリのインポート
from google.cloud import language
from google.cloud.language import enums
from google.cloud.language import types

# クライアントのインスタン作成
client = language.LanguageServiceClient()

# 分析対象のテキスト
text = u'Hello, world!'

document = types.Document(
    content=text,
    type=enums.Document.Type.PLAIN_TEXT
)

# テキストの感情分析
sentiment = client.analyze_sentiment(document=document).document_sentiment

print(f"Text: {text}")
print(f"Sentiment: {sentiment.score}, {sentiment.magnitude}")

"""
Text: Hello, world!
Sentiment: 0.6000000238418579, 0.6000000238418579
"""

お作法に慣れるまで少しだけかかりそうなのですが、それでも簡単に動かせましたね。
Hello, world! はポジティブな言葉のようです。

GCPのCloud Natural Language API でセンチメント分析

せっかくアカウントを作ったのでGCPでテキストのセンチメント分析(ポジネガ)を試してみます。
Python ライブラリも用意されている様なのですが、最初なのでAPIを直接叩いてみました。

こちらに、 curl コマンドを使う例があり、これを参考に、pythonで書きなおしてみます。


curl -X POST \
     -H "Authorization: Bearer "$(gcloud auth application-default print-access-token) \
     -H "Content-Type: application/json; charset=utf-8" \
     --data "{
  'encodingType': 'UTF8',
  'document': {
    'type': 'PLAIN_TEXT',
    'content': 'Enjoy your vacation!'
  }
}" "https://language.googleapis.com/v1/documents:analyzeSentiment"

今回は認証はAPIキーで行うので、URLにクエリパラメーター(key={APIキー})で流けることに注意しながら、pythonでやってみました。


# サンプルテキスト
text = (
    "メロスは激怒した。必ず、かの邪智暴虐の王を除かなければならぬと決意した。"
    "メロスには政治がわからぬ。メロスは、村の牧人である。笛を吹き、羊と遊んで暮して来た。"
    "けれども邪悪に対しては、人一倍に敏感であった。"
    "きょう未明メロスは村を出発し、野を越え山越え、十里はなれた此のシラクスの市にやって来た。"
)

# 自分のAPIキーに置き換える
apl_key = "{自分のAPIキー}"

# APIのurl情報
url = 'https://language.googleapis.com/v1/documents:analyzeSentiment?key=' + apl_key

# ヘッダーとデータの設定
header = {'Content-Type': 'application/json'}
data = {
    "document": {
        "type": "PLAIN_TEXT",
        "content": text
    },
    "encodingType": "UTF8"
}

response = requests.post(url, headers=header, json=data).json()

# 結果表示
response
"""
{'documentSentiment': {'magnitude': 1.2, 'score': 0},
 'language': 'ja',
 'sentences': [{'text': {'content': 'メロスは激怒した。', 'beginOffset': 0},
   'sentiment': {'magnitude': 0.1, 'score': -0.1}},
  {'text': {'content': '必ず、かの邪智暴虐の王を除かなければならぬと決意した。', 'beginOffset': 27},
   'sentiment': {'magnitude': 0.3, 'score': 0.3}},
  {'text': {'content': 'メロスには政治がわからぬ。', 'beginOffset': 108},
   'sentiment': {'magnitude': 0.5, 'score': -0.5}},
  {'text': {'content': 'メロスは、村の牧人である。', 'beginOffset': 147},
   'sentiment': {'magnitude': 0, 'score': 0}},
  {'text': {'content': '笛を吹き、羊と遊んで暮して来た。', 'beginOffset': 186},
   'sentiment': {'magnitude': 0, 'score': 0}},
  {'text': {'content': 'けれども邪悪に対しては、人一倍に敏感であった。', 'beginOffset': 234},
   'sentiment': {'magnitude': 0, 'score': 0}},
  {'text': {'content': 'きょう未明メロスは村を出発し、野を越え山越え、十里はなれた此のシラクスの市にやって来た。',
    'beginOffset': 303},
   'sentiment': {'magnitude': 0, 'score': 0}}]}
"""

ドキュメント全体としての、magnitudeとscoreに加えて、文ごとにもそれぞれ表示してくれているのがありがたいですね。
(わかりにくいですが、textは全部連結されて一つの文字列になっているので、GCPが自動的に分けてくれたものになります。)

‘メロスは激怒した。’ の score -0.1 より、
‘メロスには政治がわからぬ。’ の score -0.5 の方がネガティブと判定されているのですね。

結果の解釈については、
感情分析の値の解釈
という文書があるのでこちらを読んでおきましょう。
スコアが0.0付近のものの解釈などかなり重要な情報も含まれます。

GCPの無料トライアル開始

AWSだけでなくGCPのMLサービスも自宅環境で試したくなったので、アカウント作成することにしました。

1. GCPのページにアクセス。
2. 「無料で開始」ボタンをクリック。
3. 国を選び、利用規約を読んで「続行」をクリック。 (Google アカウントはログイン済みで作業しました。)
4. お支払いプロファイルの選択。
AdSene用のプロファイルがあったのでそれを選びました。ない場合はおそらく新規に作るのだと思います。
5. アカウントの種類は個人。名前と住所は入力されていたので確認のみ。
6. 支払い方法でクレジットカード番号を入力

これで無料トライアル開始になったようです。

メッセージには次の様に出てきました。

ご登録いただきありがとうございます。無料トライアルには、12 か月間有効の $300 分のクレジットが含まれています。クレジットを使い切ってもご心配はいりません。自動請求を有効にするまで課金されることはありません。

(個人利用はAWSをメインに使ってるので、)試す分には$300を使い切ることはなさそうです。
ただ、12ヶ月経ったら有効にしないと有料のAPIは使えなくなるのかもしれませんね。
しっかり覚えておく必要がありそうです。

GCPでは利用の際に最初にプロジェクトを作ると本で読んだのですが、
「My First Project」 というプロジェクトが自動的に作成されていました。
特にプロジェクト名にこだわりはないのでしばらくこれを使っていこうと思います。

ついでに 「Cloud Natural Language API」 を有効にしたのでこれの使い方は今後の記事で書いていきます。

認証情報を取得しないとAPIを使えないので次にそれを行います。

1. 「APIの概要」に移動。
2. 左ペインの「認証情報」をクリック。
3. 「認証情報を作成」を選択。
4. APIキーを作成。

おそらくこれで使えるはず。

Amazon Comprehend でエンティティ認識

Amazon Comprehend シリーズの3記事目です。今回はエンティテイ認識をやってみます。
キーフレーズ抽出とかなりかぶるのですが、抽出した要素に対して人物なのか場所なのか時間なのかのフラグがつく点がメリットと言えるでしょう。

使うboto3のメソッドは、対象の文章が1つなら、detect_entities() で、
複数(ただし25個まで)なら batch_detect_entities()です。

比較のために前回の記事と全く同じテキストに対して実行してみました。


# サンプルテキスト。
# これで括弧内のテキストが連結されて1つの文字列になる。
text= (
    "メロスは激怒した。必ず、かの邪智暴虐の王を除かなければならぬと決意した。"
    "メロスには政治がわからぬ。メロスは、村の牧人である。笛を吹き、羊と遊んで暮して来た。"
    "けれども邪悪に対しては、人一倍に敏感であった。"
    "きょう未明メロスは村を出発し、野を越え山越え、十里はなれた此のシラクスの市にやって来た。"
)

import boto3
comprehend = boto3.client("comprehend")
result = comprehend.detect_key_phrases(Text=text, LanguageCode="ja")

import boto3
comprehend = boto3.client("comprehend")

entities = comprehend.detect_entities(Text=text, LanguageCode="ja")
for entity in entities["Entities"]:
    print(entity)

"""
{'Score': 0.9998800754547119, 'Type': 'PERSON', 'Text': 'メロス', 'BeginOffset': 0, 'EndOffset': 3}
{'Score': 0.9998433589935303, 'Type': 'PERSON', 'Text': 'メロス', 'BeginOffset': 36, 'EndOffset': 39}
{'Score': 0.9998514652252197, 'Type': 'PERSON', 'Text': 'メロス', 'BeginOffset': 49, 'EndOffset': 52}
{'Score': 0.7808283567428589, 'Type': 'QUANTITY', 'Text': '人一倍', 'BeginOffset': 90, 'EndOffset': 93}
{'Score': 0.6928854584693909, 'Type': 'DATE', 'Text': '未明', 'BeginOffset': 104, 'EndOffset': 106}
{'Score': 0.9995416402816772, 'Type': 'PERSON', 'Text': 'メロス', 'BeginOffset': 106, 'EndOffset': 109}
{'Score': 0.506858766078949, 'Type': 'LOCATION', 'Text': '十里', 'BeginOffset': 124, 'EndOffset': 126}
{'Score': 0.9970796704292297, 'Type': 'LOCATION', 'Text': 'シラクス', 'BeginOffset': 132, 'EndOffset': 136}
{'Score': 0.6032651662826538, 'Type': 'LOCATION', 'Text': '市', 'BeginOffset': 137, 'EndOffset': 138}
"""

キーフレーズ抽出に比べて抽出された単語は少ないですが、 PERSON とか、 LOCATION といったTypeが付与されています。
ドキュメントによると、Typeは次の値を取りうる様です。

– ‘PERSON’
– ‘LOCATION’
– ‘ORGANIZATION’
– ‘COMMERCIAL_ITEM’
– ‘EVENT’
– ‘DATE’
– ‘QUANTITY’
– ‘TITLE’
– ‘OTHER’,

Amazon Comprehend でキーフレーズ抽出

前回の記事に続いて Amazon Comprehend の話です。
今度はキーフレーズ抽出をやってみます。

ドキュメントは同じところを参照します。
Comprehend — Boto3 Docs 1.14.32 documentation

Amazon Comprehend の特徴ページによると、
キーフレーズ抽出 API は、キーフレーズまたは会話のポイント、およびそれがキーフレーズであることを裏付ける信頼性スコアを返します。
とのことです。

早速やってみましょう。使うboto3のメソッドは、
detect_key_phrases() か、batch_detect_key_phrases()です。
それぞれ、単一テキストを対象とするか、テキストのリストを対象とするかの違いです。
ほとんど同じなので、今回は1テキストだけやってみることにしました。
サンプルにはいつのもメロスの最初の方の文章を使います。(全テキストで実行したら5000byteの制限によりエラーになりました。)


# サンプルテキスト。
# これで括弧内のテキストが連結されて1つの文字列になる。
text= (
    "メロスは激怒した。必ず、かの邪智暴虐の王を除かなければならぬと決意した。"
    "メロスには政治がわからぬ。メロスは、村の牧人である。笛を吹き、羊と遊んで暮して来た。"
    "けれども邪悪に対しては、人一倍に敏感であった。"
    "きょう未明メロスは村を出発し、野を越え山越え、十里はなれた此のシラクスの市にやって来た。"
)

import boto3
comprehend = boto3.client("comprehend")
result = comprehend.detect_key_phrases(Text=text, LanguageCode="ja")

for key_phrase in result["KeyPhrases"]:
    print(key_phrase)

"""
{'Score': 0.9999992847442627, 'Text': 'メロス', 'BeginOffset': 0, 'EndOffset': 3}
{'Score': 0.5104176998138428, 'Text': 'かの', 'BeginOffset': 12, 'EndOffset': 14}
{'Score': 0.9680508971214294, 'Text': '邪智暴虐の王', 'BeginOffset': 14, 'EndOffset': 20}
{'Score': 0.9999995231628418, 'Text': 'メロス', 'BeginOffset': 36, 'EndOffset': 39}
{'Score': 0.9999402761459351, 'Text': '政治', 'BeginOffset': 41, 'EndOffset': 43}
{'Score': 0.9999988079071045, 'Text': 'メロス', 'BeginOffset': 49, 'EndOffset': 52}
{'Score': 0.9999657869338989, 'Text': '村の牧人', 'BeginOffset': 54, 'EndOffset': 58}
{'Score': 0.9999977350234985, 'Text': '笛', 'BeginOffset': 62, 'EndOffset': 63}
{'Score': 0.9999823570251465, 'Text': '羊', 'BeginOffset': 67, 'EndOffset': 68}
{'Score': 0.9975282549858093, 'Text': '人一倍', 'BeginOffset': 90, 'EndOffset': 93}
{'Score': 0.8310525417327881, 'Text': 'きょう未明', 'BeginOffset': 101, 'EndOffset': 106}
{'Score': 0.9997578263282776, 'Text': 'メロス', 'BeginOffset': 106, 'EndOffset': 109}
{'Score': 0.9997960925102234, 'Text': '村', 'BeginOffset': 110, 'EndOffset': 111}
{'Score': 0.9999336004257202, 'Text': '野', 'BeginOffset': 116, 'EndOffset': 117}
{'Score': 0.9991182088851929, 'Text': '十里', 'BeginOffset': 124, 'EndOffset': 126}
{'Score': 0.9819957613945007, 'Text': '此のシラクスの市', 'BeginOffset': 130, 'EndOffset': 138}
"""

ものすごく簡単に使えましたね。
一応重要な名詞は拾えてる様な気がしますし、王様は「邪智暴虐の王」として抽出できています。
ただ抽出したテキストだけなど意味がわからないので使い道に悩むところです。
BeginOffset と、 EndOffset は、その該当テキストを切り出すスライスに使える様です。

こんな感じです。


print(text[14: 20])
# 邪智暴虐の王

print(text[54: 58])
# 村の牧人

Amazon Comprehend でテキストのセンチメント分析

いつのまにか Amazon Comprehend の感情分析が日本語に対応しているのを見つけたので試してみました。

Amazon Comprehend というのは AWSが提供している自然言語処理の機械学習サービスです。
すでに学習済みのモデルが用意されており、利用者はデータを渡すだけで、感情分析(ポジネガ)や、キーフレーズの抽出ができます。

少し前までは日本語は言語判別のみ対応していて、センチメント分析するには一度対応してる言語に翻訳する必要があったはずなのですが、今は日本語も使えます。

ということで、Pythonからこれを動かしてみましょう。

・準備
AWSのPython SDKである boto3が動くように環境をつくっておきます。
主な作業は、 Comprehend の権限を持ったIAMの作成と、そのAccess Key と Secret Access Keyの設定でしょうか。
(僕は環境変数に入れました。)

これで動くはずなのでやってみます。
ドキュメントはこちらです。
Comprehend — Boto3 Docs 1.14.32 documentation

テキストは青空文庫の走れメロスを拝借しました。
まず、1文のポジネガを判定します。使うメソッドは、 detect_sentimentです。
本当に簡単に使えます。


import boto3

# 試すテキストを準備する
text1 = "メロスは激怒した。必ず、かの邪智暴虐の王を除かなければならぬと決意した。"
text2 = "メロスは、村の牧人である。笛を吹き、羊と遊んで暮して来た。"

comprehend = boto3.client("comprehend")
for t in [text1, text2]:
    comprehend_result = comprehend.detect_sentiment(Text=t, LanguageCode="ja")
    print(t)
    print(comprehend_result["Sentiment"])
    for k, v in comprehend_result["SentimentScore"].items():
        print(f"    {k}: {v}")
    print()

# 以下出力結果
"""
メロスは激怒した。必ず、かの邪智暴虐の王を除かなければならぬと決意した。
NEGATIVE
    Positive: 0.00021381396800279617
    Negative: 0.8228716850280762
    Neutral: 0.17690636217594147
    Mixed: 8.087589776550885e-06

メロスは、村の牧人である。笛を吹き、羊と遊んで暮して来た。
NEUTRAL
    Positive: 0.0011265947250649333
    Negative: 6.331767508527264e-05
    Neutral: 0.9988085031509399
    Mixed: 1.5915205722194514e-06
"""

みて分かる通り、 Positive(肯定的)/ Negative(否定的)/ Neutral(中立的)/ Mixed(混在) の4つの感情の割合を返してくれます。

複数のテキストをまとめて判定する関数も用意されています。
それが、batch_detect_sentimentです。

A list containing the text of the input documents. The list can contain a maximum of 25 documents. Each document must contain fewer that 5,000 bytes of UTF-8 encoded characters.

とある通り、 25個までのテキスト(それぞれ5000バイト未満)を順番に判定してくれます。

走れメロスの中に登場する会話文を順番に判定かけてみましょう。

準備として、以下のコードでメロスの会話文の一覧を取得します。


import requests
from bs4 import BeautifulSoup
import re

# 青空文庫 走れメロスのURL
url = "https://www.aozora.gr.jp/cards/000035/files/1567_14913.html"
response = requests.get(url)
# 文字化け対応
response.encoding = response.apparent_encoding
html = response.text

soup = BeautifulSoup(html)

# ルビを取り除く
for tag in soup.findAll(["rt", "rp"]):
    # タグとその内容の削除
    tag.decompose()

# ルビを取り除いたテキストを取得
text = soup.find(class_="main_text").get_text()

# 改行を消す。
text = text.replace("\r\n", "")
text = text.replace("\n", "")
# 全角スペースを消す
text = text.replace("\u3000", "")

# カッコの内側の文字列を抽出する正規表現パターン
speech_pattern = re.compile("「([^「」]+)」")

# カッコの内側の文字列取得
speech_texts = speech_pattern.findall(text)

全部で62文あるので3回に分けて取得します。
結果は扱いやすい様にPandasのDataFrameに入れておきましょう。


import pandas as pd

sentiment_list = []
positive_list = []
negative_list = []
neutral_list = []
mixed_list = []

for i in range(3):
    target_texts = speech_texts[25*i: 25*(i+1)]
    comprehend_result = comprehend.batch_detect_sentiment(
        TextList=target_texts,
        LanguageCode="ja"
    )
    result_list = comprehend_result["ResultList"]

    for r in result_list:
        sentiment_list.append(r["Sentiment"])

        positive_list.append(r["SentimentScore"]["Positive"])
        negative_list.append(r["SentimentScore"]["Negative"])
        neutral_list.append(r["SentimentScore"]["Neutral"])
        mixed_list.append(r["SentimentScore"]["Mixed"])

# 結果をDataFrameに変換
df = pd.DataFrame(
    {
        "text": speech_texts,
        "sentiment": sentiment_list,
        "positive": positive_list,
        "negative": negative_list,
        "neutral": neutral_list,
        "mixed": mixed_list,
    }
)

こうして、走れメロスの全台詞に対してポジネガのフラグをつけることができました。
ちなみに最もポジティブだったセリフはこちらです。

text ありがとう、友よ。
sentiment POSITIVE
positive 0.998629
negative 1.88228e-05
neutral 0.00135026
mixed 1.96162e-06

そして、最もネガティブだったのがこれ。

text ‘もう、駄目でございます。むだでございます。走るのは、やめて下さい。もう、あの方をお助けになることは出来ません。
sentiment NEGATIVE
positive 0.000276001
negative 0.996889
neutral 0.00283329
mixed 1.40147e-06
Name: 46, dtype: object

全テキストの結果を見ていくと流石に「ん?」と思う様なものもあるのですが、
概ねしっかりと感情分析ができてる様に思います。