Amazon Aurora Serverless v2が出たので使ってみた

以前の記事で、 Aurora Serverless を紹介しましたが、その v2 が登場していたので使用感を確かめてみました。
参考: Amazon Aurora Serverlessを使ってみる
Amazonからの発表: Amazon Aurora Serverless v2 の一般提供を開始
Amazon Aurora Serverless

かつてのAurora Serverless (要するにv1)には、ServerlessではないAuroraに比べてさまざまな制約がありましたが、v2ではそれらの制約がだいぶ改善されています。
個人的に期待しているというより嬉しいのは、グローバルデータベースが作れる点ですね。
これまでのAurora ServerlessはAWS内部からしかアクセスできなかったのでローカルからアクセスする場合はEC2などで踏み台作ることが必須でした。

注意点として、ACUあたりの料金が2倍になっています。ただし、スケーリング等がよりきめ細やかに行われるようになっているらしいので、同じ使い方した場合の請求金額がそのまま2倍になるようなことはないとも聞いています。これは実際に使ってみないとわからないですね。

クラスター接続編

さて、前置きが長くなって来たので使ってみましょう。手順は以下の通りです。

1. AWSのコンソールにログインし、RDSの管理画面に移動する。
2. [データベースの作成]をクリック
3. 標準作成を選択 (簡単作成でも良い)
4. エンジンのタイプ は [Amazon Aurora]を選択
5. エディション は [MySQLとの互換性を持つ Amazon Aurora]
6. MySQLのバージョンを選ぶ。
以前は、大量の選択肢の中からドキュメントで指定されているバージョンを自分で選んでおかないと以降の選択肢からサーバレスが消えていたのですが、今は「フィルターの表示」で、「Serverless v2 をサポートするバージョンを表示」として絞り込むことができます。(v1も同じフィルターが欲しいが無いようです。)
現時点では、「Aurora MySQL 3.02.0 (compatible with MySQL 8.0.23)」だけに絞られるのでこれを選びます。

7. テンプレートは個人利用の検証なので「開発/テスト」にしました。
8. DBクラスター識別子 は何か名前をつける。 (yutaro-aurora-serverless-v2-test)
9. マスターユーザー名 を指定 (デフォルトは admin)
10. パスワードの設定。 (自分はパスワードの自動作成にしました)
11. インスタンスの設定で、DBインスタンスクラスでサーバレスを選択。
12. キャパシティーの設定を変更。(0.5〜128で設定可能ですが、予算を抑えるため低めに。)
13. 接続のパブリックアクセスで「あり」を選択。
14. [データベースの作成]をクリック。

これで、DBの作成が始まります。
[認証情報の詳細を表示] から、 adminのパスワードを入手しておきましょう。

アクセスにはエンドポイントの名前も必要です。DBクラスター識別子とは違うので別途確認します。(DBの一覧から先ほど作ったクラスターを選択すると表示されます。)

あとは、セキュリティグループを設定して自分のPCからアクセスできるようにしたら準備完了です。

DBアクセス編

ローカル端末からPythonでアクセスしてみます。まだ空っぽのDBなので、とりあえずSHOW DATABASES文でも打ってみましょう。

import pymysql.cursors

con_args = {
    "host": "{エンドポイント名}",
    "port": 3306,
    "user": "{DBユーザー名}",
    "password": "{DBパスワード}",
    "charset": "utf8mb4",
    "cursorclass": pymysql.cursors.DictCursor,
}
# 通常は "database": "{データベース名}" も指定するが、まだ何も作ってないのでなし。

connection = pymysql.connect(**con_args)

with connection.cursor() as cursor:
    sql = "SHOW DATABASES"
    cursor.execute(sql)
    result = cursor.fetchall()
print(result)

# [{'Database': 'information_schema'}, {'Database': 'mysql'}, {'Database': 'performance_schema'}, {'Database': 'sys'}]

無事接続できたようです。

日本語対応編

ちなみに、 v1の頃は、日本語を使うために文字コードなどの設定が必要でした。
参考: Amazon RDS(Aurora)で日本語文字列や絵文字を使えるようにする

それがどうやら、v2では(というより、選択した互換MySQLのバージョンがそうだった可能性もありますが) 最初からutf8対応の設定のようです。

with connection.cursor() as cursor:
    sql = "SHOW VARIABLES LIKE 'char%'"
    cursor.execute(sql)
    result = cursor.fetchall()


for r in result:
    print(r)
"""
{'Variable_name': 'character_set_client', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_connection', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_database', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_filesystem', 'Value': 'binary'}
{'Variable_name': 'character_set_results', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_server', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_system', 'Value': 'utf8'}
{'Variable_name': 'character_sets_dir', 'Value': '/rdsdbbin/oscar-8.0.mysql_aurora.3.02.0.0.12759.0/share/charsets/'}
"""

未使用時の停止について

最後に、一番気になる未使用時の自動停止について。個人利用においては正直 Aurora Serverless を使う1番の理由は使わない時は止まって料金がかからない点です。

v1 の時は、 「数分間アイドル状態のままの場合コンピューティング性能を一時停止する」という設定があり、これを設定しておくと止まってくれていたのですが、 v2 ではこれに該当する設定を見つけられていません。(キャパシティーの設定にあるはずなのですが。)

ドキュメントにある、「Aurora Serverless v2 を使用すると、SaaS ベンダーは、プロビジョニングされた容量のコストを気にすることなく、個々の顧客ごとに Aurora データベースクラスターをプロビジョニングできます。データベースが使用されていないときは自動的にシャットダウンしてコストを節約し、変化するアプリケーション要件に合わせてデータベースの容量を即座に調整します。」の部分はどこで使うのでしょう。
v1 の時、デフォルトだと思って設定し忘れていたらDBが停止しておらず、普通に料金がかかったのでここが心配です。

もし、 v2 で自動的なシャットダウンが実装されていなかったら個人開発では引き続きv1を使わないといけないですね。一旦、放置して様子を見ようと思います。

(追記)
こちらに情報がありました。
Comparison of Aurora Serverless v2 and Aurora Serverless v1

Stopping a cluster のところを見ると、v2は手動で停止しないといけなくて(You can manually stop and start the cluster by using the same cluster stop and start feature as provisioned clusters.)、v1は自動的に停止できる(The cluster pauses automatically after a timeout.)とありますね。これだとちょっと使えないので残念です。

タイムゾーンについて

もう一点1気になる問題があって、タイムゾーンの設定変更が反映されないんですよね。反映されるまでに時間がかかるのかな。DB クラスターのパラメータグループの time_zone を Asia/Tokyo に変えたのですが、 SELECT NOW() すると UTCの時刻が返って来ます。これも要調査です。

(追記)こちらはクラスターに設定した後、インスタンスを再起動したら反映されました。

sentencepieceを使ってみた その2 (model_type: word)

前回の記事に続いて、sentencepieceの話題です。
今回はmodel_type=”word”を使ってみます。

最初に、自分が勘違いしていたことについて説明します。このmodel_type=”word”ですが、自分はてっきりいわゆるsubwordのアルゴリズムが実装されているものだと勘違いしていました。あらかじめ分かち書きしたテキストを読み込み、低頻度語を洗い出して、より小さい単位、最終的には文字単位に分解して未知語を無くしてくれるのかなと。
しかし、実際の挙動は、分かち書きしたテキストデータからそのまま語彙を学習し、idへ変換する機能のようです。下記のサンプルファイルでも、charとwordは同じカテゴリで扱われていますね。
参考: sentencepiece/sentencepiece_python_module_example.ipynb at master · google/sentencepiece · GitHub

これだとあまりありがたみがなく、わざわざ記事にするほどでもなかったのですが、前回の記事で次はこれ紹介するって書いちゃったのでやってみます。

ドキュメントに、「The input sentence must be pretokenized when using word type.」と書かれている通り、model_typeでwordを指定する場合は、入力データをあらかじめ単語に区切っておく必要があります。

早速、入力データを作ってやってみましょう。前回同様ライブドアニュースコーパスのデータを使い、今回はMeCabで分かち書きしてテキストファイルに書き出しておきます。
(ちなみに、試しに分かち書きして無い状態でsentencepieceに食わせてみるのも実験しましたが、確かにすごく長い単語ばかりの変な学習結果になりました。)
pretokenized の詳細な仕様が明記されて無いですが、常識的に考えて半角スペースあたりで区切れば大丈夫です。(実際それで動きました。)

import pandas as pd
import MeCab
import subprocess


# ライブドアニュースコーパス読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# Line Separator 除去
df["text"] = df.text.str.replace("\u2028", " ")

# 辞書のディレクトリ取得
dicdir = subprocess.run(["mecab-config", "--dicdir"], capture_output=True, text=True).stdout.strip()
# 分かち書きを出力する設定でTaggerを生成
tagger = MeCab.Tagger(f"-O wakati -d {dicdir}/ipadic")

# 分かち書き
df["tokens"] = df["text"].apply(tagger.parse)
# 末尾に改行コードがついているので取り除く
df["tokens"] = df["tokens"].str.strip()

# ファイル書き出し
with open('livedoor_tokenized_corpus.txt', 'w') as w:
    for text_line in df["tokens"]:
        w.write(text_line + "\n")

データができたので、前回の別のモデルと同様に、語彙を学習させます。

import sentencepiece as spm


spm.SentencePieceTrainer.train(
    input="livedoor_tokenized_corpus.txt",  # コーパスファイル
    model_type="word",  # デフォルト
    model_prefix='livedoor_word',  # 出力されるモデルのファイル名に使われる
    vocab_size=4000,  # 語彙数
)

さて、保存されたモデルファイルを読み込んで使ってみましょう。

# モデルの読み込み
sp = spm.SentencePieceProcessor(model_file='./livedoor_word.model')

# サンプルの文章
sample_text = "これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?"
# model_type="word"の場合は、入力データも分かち書きして渡す必要がある
sample_tokens = tagger.parse(sample_text).strip()
print(sample_tokens)
# これから の 年度 末 に 向け て 、 引越し を 考え て いる 人 も 多い の で は ない だろ う か ?


# 単語のid列への分割
print(sp.encode(sample_tokens))
# [823, 4, 1977, 1233, 7, 209, 10, 3, 0, 6, 353, 10, 23, 47, 18, 223, 4, 12, 9, 24, 95, 53, 33, 52]
# 文字列への分割
print(sp.encode_as_pieces(sample_tokens))
# ['▁これから', '▁の', '▁年度', '▁末', '▁に', '▁向け', '▁て', '▁、', '▁引越し', '▁を', '▁考え',
# '▁て', '▁いる', '▁人', '▁も', '▁多い', '▁の', '▁で', '▁は', '▁ない', '▁だろ', '▁う', '▁か', '▁?']

特殊文字 “▁”が各単語の前についていますが、encode_as_piecesによる区切り自体は、元のMeCabの区切り位置から何も変わっていないのがわかりますね。これは基本的には、分かち書きされたトークン列をid列に変換してくれているだけだとわかります。また、よくみるとid列の中に0 (対応する単語は”引越し”)が出ているのがわかります。これが語彙数の上限4000から溢れた未知語です。id列を文字列に復元するとよくわかります。

# id列を文章に戻す
print(sp.decode_ids([823, 4, 1977, 1233, 7, 209, 10, 3, 0, 6, 353, 10, 23, 47, 18, 223, 4, 12, 9, 24, 95, 53, 33, 52]))
# これから の 年度 末 に 向け て 、 ⁇  を 考え て いる 人 も 多い の で は ない だろ う か ?

「引越し」を引/越/しに分割して未知語をなくしてくれてるいわゆるsubword処理をやってくれると嬉しかったのですが、冒頭に書いた通りそれは単なる僕の勘違いでした。
低頻度語は未知語としてそのまま捨てられちゃいます。

前回と今回の記事をまとめると、sentencepieceは原則、model_typeは unigramかbpeで使うもののようですね。この二つのアルゴリズムはなかなか便利ですよ。

sentencepieceを使ってみた(model_type: unigram / bpe / char)

このブログではテキストを単語に分割するときは概ねMeCabを使っていますが、実はMeCab以外にもテキストを分割する方法はいろいろあります。その中の一つであるsentencepieceを試してみたので紹介します。ちなみにこのsentencepiece、開発者はMeCabと同じ工藤拓さんです。
参考: SentencePiece: A simple and language independent subword tokenizer and detokenizer for Neural Text Processing

sentencepieceとMeCabの違いとしては、MeCabは文章を文法的な意味を持つ最小単位の単語(形態素)に分割する形態素解析エンジンなのに対して、sentencepieceは特に文法的な意味を考慮せずに分割するということがあります。
MeCabは辞書をもとに文章を分割しますが、sentencepiece(unigram/ bpe)はそうではなく生の文章から自動的に分割する単位を学習し、語彙を習得します。
unigram とか bpe というのはそのときのアルゴリズムにつけられた名前です。
この他、model_type=”word”ってのがありますが、これは特殊で、あらかじめ単語に分割されたデータから低頻出語をさらに分割することで学習します。(学習データの準備が違うので次の記事で紹介します。)
これだけだと何もメリットなさそうなのですが、sentencepieceでは語彙の数を事前にパラメーターで指定することができ、例えば語彙を8000語に収めたいなら8000語で学習するといったことができます。BoWのような表現をする場合はもちろんですが、RNNやTransformer系の機械学習モデルへの前処理として使う場合、埋め込み層の語彙数を事前に指定しないといけないので非常に便利な特徴ですね。また、コーパス全体で1,2回しか出てこないような低頻度語を防げるのもありがたいです。

GitHubのPythonラッパーのサンプルコードを見ながら動かしてみましょう。
参考: sentencepiece/python at master · google/sentencepiece · GitHub

サンプルに使うデータは例によってライブドアニュースコーパスです。ニュース記事本文を使います。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

ちょっと使い方が独特で、まずモデルを学習するためのコーパスをテキストファイルで準備する必要があります。上記の記事で作ったライブドアニュースコーパスのCSVを、1記事1行のテキストに加工して保存しておきます。

import pandas as pd

# ライブドアニュースコーパスの本文を、1記事1行のテキストファイルとして書き出し
df = pd.read_csv("./livedoor_news_corpus.csv")
# 改行除去
df["text"] = df.text.str.replace("\n", " ")
# 全角空白除去 (blog記事上だと分かりにくいですが、全角スペースを半角スペースにしてます。)
df["text"] = df.text.str.replace(" ", " ")
# Line Separator 除去
df["text"] = df.text.str.replace("\u2028", " ")
# 前後の空白除去
df["text"] = df.text.str.strip()

# ファイル書き出し
with open('livedoor_corpus.txt', 'w') as w:
    for text_line in df["text"]:
        w.write(text_line + "\n")

これでコーパスができました。
このあと、sentencepieceのモデルを学習します。これもかなり使い方が特殊で、先ほど作ったテキストファイルと各種オプションを指定して学習を実行すると、モデルのバイナリ(.model)と、語彙(.vocab)の二つのファイルが出来上がります。ファイル名の拡張子以前の部分は model_prefix 引数で指定した文字列です。では早速、デフォルトの unigramモデルでやってみましょう。語彙数は適当に4000としました。

import sentencepiece as spm


spm.SentencePieceTrainer.train(
    input="livedoor_corpus.txt",  # コーパスファイル
    model_type="unigram",  # デフォルト
    model_prefix='livedoor_unigram',  # 出力されるモデルのファイル名に使われる
    vocab_size=4000,  # 語彙数
)

これで以下のように、livedoor_unigram.model/ livedoor_unigram.vocab ファイルが出来上がります。


$ ls
livedoor_corpus.txt
livedoor_unigram.model
livedoor_news_corpus.csv
livedoor_unigram.vocab

livedoor_unigram.vocabを開くと学習した語彙が見れます。

$ head -n 20 livedoor_unigram.vocab
<unk>	0
<s>	0
</s>	0
の	-3.17217
、	-3.41012
。	-3.81098
▁	-3.85549
を	-4.01854
が	-4.10413
に	-4.21314
は	-4.29892
で	-4.61122
と	-4.6203
」	-4.65722
「	-4.80934
も	-5.02828
な	-5.19927
)	-5.20129
(	-5.30642
い	-5.32545

先頭部分が1文字ばっかりで分かりにくいですが、ファイルの途中見ていくと、「する」とか「という」とかの単語もちゃんと出て来ます。<unk>,<s>,</s>の3単語がデフォルトで予約語とされていますが、この3語を含めて4000語です。control_symbolsやuser_defined_symbols という引数を使って、自分で定義したシンボルを入れることもできます。
参考: sentencepiece/special_symbols.md at master · google/sentencepiece · GitHub

さて、モデルが学習できたのでこれ使ってみましょう。出来上がったモデルファイルを読み込んで、それを使って文章をトークン化します。

# モデルの読み込み
sp = spm.SentencePieceProcessor(model_file='./livedoor_unigram.model')

# サンプルの文章
sample_text = "これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?"

# 単語のid列への分割
print(sp.encode(sample_text))
[6, 1974, 3, 44, 230, 961, 9, 529, 53, 4, 893, 1612, 22, 7, 614, 134, 69, 2203, 3, 663, 1029, 88]
# 文字列への分割
print(sp.encode_as_pieces(sample_text))
# ['▁', 'これから', 'の', '年', '度', '末', 'に', '向け', 'て', '、', '引', '越', 'し', 'を', '考え', 'ている', '人', 'も多い', 'の', 'ではない', 'だろうか', '?']

# id列を文章に戻す
print(sp.decode_ids([6, 1974, 3, 44, 230, 961, 9, 529, 53, 4, 893, 1612, 22, 7, 614, 134, 69, 2203, 3, 663, 1029, 88]))
# これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?

これで、テキストをトークン化とその逆変換ができましたね。

モデルを学習したときの、model_type=”unigram” の部分を model_type=”bpe” とすることでもう一つのByte pair encodingアルゴリズムも試すことができます。

spm.SentencePieceTrainer.train(
    input="livedoor_corpus.txt",
    model_type="bpe",
    model_prefix='livedoor_bpe',
    vocab_size=4000,
)

unigramとbpe、そんなに大きな違いないんじゃ無いかなという予想に反して、出来上がった語彙ファイルを見ると全然違います。

$ head -n 20 livedoor_bpe.vocab
<unk>	0
<s>	0
</s>	0
てい	-0
した	-1
った	-2
する	-3
▁・	-4
して	-5
ない	-6
ている	-7
から	-8
こと	-9
って	-10
os	-11
とい	-12
ます	-13
され	-14
です	-15
ック	-16

最初っから2文字ペアの単語がたくさん出て来ますね。
学習したモデルを読み込んで使う方法は同じです。語彙が違うので微妙に結果が変わります。
たった一文の比較で優劣つけるわけにはいきませんが、この例文だとなかなかどちらがいいとも言い難いです。

# モデルの読み込み
sp_bpe = spm.SentencePieceProcessor(model_file='./livedoor_bpe.model')

# サンプルの文章
sample_text = "これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?"

# 単語のid列への分割
print(sp_bpe.encode(sample_text))
[1173, 596, 1561, 1747, 1915, 1334, 292, 1465, 1910, 2395, 1472, 1477, 808, 10, 710, 293, 1279, 579, 1609]
# 文字列への分割
print(sp_bpe.encode_as_pieces(sample_text))
# ['▁これ', 'からの', '年', '度', '末', 'に向', 'けて', '、', '引', '越', 'し', 'を', '考え', 'ている', '人も', '多い', 'のではない', 'だろうか', '?']

# id列を文章に戻す
print(sp_bpe.decode_ids([1173, 596, 1561, 1747, 1915, 1334, 292, 1465, 1910, 2395, 1472, 1477, 808, 10, 710, 293, 1279, 579, 1609]))
# これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?

「実践・自然言語処理シリーズ2 形態素解析の理論と実装 (近代科学社/ 工藤拓(著))」に少しだけsentencepieceについての記述もあり、unigramとbpeの違いが少し記載されています。
そのまま引用します。

BPEは、ニューラル翻訳に標準的に用いられている手法であり、1文字1語彙から開始し、連結した際に最も頻度が高くなる二つの語彙を選び新たな語彙とする手続きを決められた語彙サイズに達するまで繰り返すことで語彙結合ルールを学習します。
(中略)
ユニグラム言語モデルは、テキストを符号化するときの符号長が最小となるように、分割モデルをEM法を用いて学習します。

なるほど、って感じですね。個人的にはBPEの方が具体的なアルゴリズムがわかりやすいです。

ちなみに、ユニグラム言語モデルは符号化したときの符号長を最小にするように分割するって書いてあるので、ユニグラムモデルの方が少ない単語数になるのかと思って、学習したテキストで試したのですが結果は逆でした。不思議です。

print(df.text.apply(lambda x: len(sp.encode(x))).mean())
# 814.231301751052

print(df.text.apply(lambda x: len(sp_bpe.encode(x))).mean())
# 811.67992398534

今回の記事の主題はほぼここまでなのですが、model_typeには次の記事で取り上げるwordの他にもcharってのがあるので、一応これも紹介しておきます。

これは非常に単純なやつでして、単純にテキストを文字単位に分割します。学習するのはコーパスに登場した文字の一覧だけです。

spm.SentencePieceTrainer.train(
    input="livedoor_corpus.txt",
    model_type="char",
    model_prefix='livedoor_char',
    vocab_size=4000,
)

学習結果の語彙も予約語以外は文字単体だけです。4000種類もなかったので行数も少なくなりました。

$ head -n 20 livedoor_char.vocab
<unk>	0
<s>	0
</s>	0
の	-3.64481
▁	-3.64584
、	-3.80562
い	-3.93749
ー	-4.02605
に	-4.07836
る	-4.1384
と	-4.15518
で	-4.20032
し	-4.23772
な	-4.24788
。	-4.24846
た	-4.2548
て	-4.2787
を	-4.28831
が	-4.29299
は	-4.38982

モデルを読み込んで使う方法はunigramやbpeと同じなので略します。