Lambda の関数URLで送信されたデータを扱う

前回に引き続き、Lambdaの関数URLの話です。URLでLambdaを起動できるのは便利ですが、せっかくならアクセスするときに何かしらの情報を渡して処理を変えたいということは多くあると思います。その場合に、あり得るパターンの数だけLambda関数と個別のURLを用意しておくというのは現実的ではありません。
普通は、世の中のたいていのAPIがやっているように、クエリパラメーターや、POSTされたデータに応じて挙動を変える作りにします。

受け取った値に応じて挙動を変えるっていうのは、ただのPythonのコーディングの話なので、今回の記事では、どうやってクエリパラメーターとかPOSTされたデータを受け取るかって部分を扱います。これまでAPI GatewayでAPI作ってたような人にとっては常識的なことしか書いてないと思いますがご了承ください。(僕はWebエンジニアではなく、これまでLambdaは手動かスケジュール実行で使って来たのでこの辺の挙動に詳しくないのです。)

さて、前置きが長くなって来ましたが、実はタネは非常に簡単で、Lamda関数の定義にデフォルトで入っている引数「event」、これが今回の主役です。

def lambda_handler(event, context):  # この第一引数eventに欲しいデータが渡される
    # 処理の中身

関数URLからLambdaが呼び出されたとき、lambda_handlerの第一引数には、以下のドキュメントのリクエストペイロードと呼ばれているデータが渡され、関数中ではeventという変数名で扱えます。
参考: Lambda 関数 URL の呼び出し – AWS Lambda の リクエストペイロードの形式 の部分

以下のような関数を作って実際に動かしてみるのが一番わかりやすいと思います。
テキストエリア2個と送信ボタンを持つフォームをGET/POSTでそれぞれ作成し、event変数の中身を表示します。

import pprint
def lambda_handler(event, context):
    html = """
<!DOCTYPE html>
<h2>GETのフォーム</h2>
<form action="/" method="get">
    <input type="text" name="text1"><br>
    <input type="text" name="text2"><br>
    <input type="submit">
</form>
<h2>POSTのフォーム</h2>
<form action="/" method="post">
    <input type="text" name="text3"><br>
    <input type="text" name="text4"><br>
    <input type="submit">
</form>
    """
    html +="<h2>eventの内容</h2>\n"
    html += "<pre>" + pprint.pformat(event, compact=True) + "</pre>"
    return {
        "headers": {
            "Content-Type": "text/html;charset=utf-8",
        },
        'statusCode': 200,
        "body": html,
    }

上記のコードで関数を作成し、関数URLからアクセスすると、フォーム二つとpprintで成形されたeventの辞書データが表示されます。また、それぞのフォームに値を入れて送信すると、eventのどこに結果が入るかが確認できます。
(キャプチャとか貼るとわかりやすいと思うのですが、idっぽいものが大量にあるので省略させていただきます。上のコードを試してただくのが一番良いです。)

まず、メソッド(GET/POSTなど)の区別ですが、次の部分にあります。
Pythonで言えば、 event[“requestContext”][“http”][“method”] で取得できますね。

{
    # 略
    'requestContext': {
        # 略
        'http': {
            # 略
            'method': 'GET',
        }
    }
}

それでは、 GET/ POST 順番にフォームを実行して、どのように値が取れるか見て行きましょう。
フォームには以下の値を入れて送信します。
1つ目のテキスト: あ&い=う%え,お
2つ目のテキスト: abc?123

フォームからGETで送信する場合、URLの末尾に入力内容が添付され、次のURLにアクセスされます。特殊文字はパーセントエンコーディングされていますね。

https://{url-id}.lambda-url.{region}.on.aws/?text1=あ%26い%3Dう%25え%2Cお&text2=abc%3F123

eventの中ではevent[“rawQueryString”] と event[“queryStringParameters”]の部分に現れます。
クエリ文字列がないときは、rawQueryStringは空文字””ですが、queryStringParametersの方はキー自体が存在しないので、コードで使うときは注意してください。

{
    # 略
    'queryStringParameters': {'text1': 'あ&い=う%え,お', 'text2': 'abc?123'},
    # 略
    'rawQueryString': 'text1=%E3%81%82%26%E3%81%84%3D%E3%81%86%25%E3%81%88%2C%E3%81%8A&text2=abc%3F123',
    # 略
}

上記の内容で分かる通り、rawQueryStringの方はURIエンコーディングされていますが、queryStringParametersの方は使いやすいように辞書型にパースしてURIエンコードも元に戻してくれています。こちらを使って行きましょう。
フォームに入れた値がURLに載ってしまうというのは大きなデメリットですが、ぶっちゃけると単純にテキスト等を送るフォームならPOSTよりGETの方が渡した値使いやすいな、と感じています。(Web系開発素人の発想ですが。)

続いて、POSTの場合です。個人的にはこの種のフォームは普通はPOSTで使うものだと思っています。
POSTの場合は、GETと違って少しわかりにくく、eventを表示しても送ったデータがそのままでは見つかりません。ではどこにあるのかというと、実は event[“body”]に、base64エンコードされて入ってます。これはフォームからPOSTした場合の挙動です。curl等でテキストのままポストしてあげればそのまま表示されます。
base64の判定は、event[“isBase64Encoded”] で行います。

{
    # 略
    'body': 'dGV4dDM9JUUzJTgxJTgyJTI2JUUzJTgxJTg0JTNEJUUzJTgxJTg2JTI1JUUzJTgxJTg4JTJDJUUzJTgxJThBJnRleHQ0PWFiYyUzRjEyMw==',
    # 略
    'isBase64Encoded': True,
    # 略
}

base64なので自分でこれをデコードする必要があります。
参考: PythonでBase64エンコードとデコード

該当部分のコードだけ書くとこんな感じです。

import base64
base64.b64decode(event["body"]).decode()
# text3=%E3%81%82%26%E3%81%84%3D%E3%81%86%25%E3%81%88%2C%E3%81%8A&text4=abc%3F123

パーセントエンコードされていますね。これを以下の手順で処理する必要があります。
&で区切って、フォームの各要素ごとの値に分割する。
=で区切ってキーと値のペアに変える。
パーセントエンコードをデコードする。

自分でやるのは面倒なので、ライブラリ使いましょう。
参考: PythonでURL文字列を要素に分解する

次のようになります。

import base64
from urllib.parse import parse_qs
parse_qs(base64.b64decode(event["body"]).decode())
# {'text3': ['あ&い=う%え,お'], 'text4': ['abc?123']}

キー対値 の辞書ではなく、 キー対値の配列 の辞書が結果として得られるので注意が必要です。特殊文字たちも元の形に戻っていますね。

さて、以上で関数URLにクエリストリングに付加されたデータや、POSTされて来たデータをLambdaの関数で取り出せるようになりました。

あとはこれを受け取ってそれに応じた処理をする関数を作るだけで、柔軟な処理を行えるようになると思います。

AWS Lambda Function URLs を試してみた

発表されてから少し時間が経ってしまったので、今更感が出ていますが AWS Lambdaの新機能である Function URLs (関数URL) を試してみました。
参考: AWS Lambda Function URLs の提供開始: 単一機能のマイクロサービス向けの組み込み HTTPS エンドポイント

これ何かと言うと、AWSのLambda に API Gateway を使わずに、Lambda内部の機能だけでURLを発行して、そのURLにアクセスするだけで関数を実行できると言うものです。

ドキュメントはこちらかな。
参考: Lambda 関数 URL

ちょっとお試しで動かしてみましょう。関数URLが有効な新しい関数を作る手順は以下の通りです。

1. Lambdaのコンソールにアクセス。
2. 関数の作成をクリック。
3. 関数名/ ランタイム(NodeやPythonのバージョン)/ アーキテクチャ などを入力。
4. アクセス権限でロールの設定。
5. ▼詳細設定のメニューを開く。ここに「関数URLを有効化」が新登場しています。
6. 「関数URLを有効化」にチェックを入れる。
7. 認証タイプ を選択。お試しなので、一旦NONEにしてパブリックにしました。
8. 関数の作成をクリック。

これで関数が出来上がります。デフォルトでは以下のコードが用意されています。

import json
def lambda_handler(event, context):
    # TODO implement
    return {
        'statusCode': 200,
        'body': json.dumps('Hello from Lambda!')
    }

ここで、コードソースのウィンドウの上の、関数の概要ウィンドウの右側に、関数URLというのが作られ、以下の形式のURLが用意されているのがわかります。
この時点でURL用意されているんですね。

https://{url-id}.lambda-url.{region}.on.aws/

ドキュメントによると、url-id 部分からわかる人はその人のアカウントIDが取得できるそうです。そのため、僕のサンプルで作った関数URLがお見せできませんが、たったこれだけの手順で試せるので是非やってみてください。

このURLを開くと、”Hello from Lambda” の文字が表示されます。curlでも良いです。

$ curl https://{url-id}.lambda-url.ap-northeast-1.on.aws/
"Hello from Lambda!"

折角ブラウザで開けるので、HTMLを表示してみましょう。
ここで注意しないといけないのは、単純に”body”にHTMLの文字列を書くだけだと、JSONとして扱われて、ブラウザがHTMLソースをレンダリングせずにそのまま表示してしまいます。

それを防ぐためには、Content-Type を “text/html” に指定してあげる必要があります。
指定は、ドキュメントのこちらのページの、「レスポンスペイロードの形式」というセクションに沿って行います。
参考: Lambda 関数 URL の呼び出し – AWS Lambda

{
   "statusCode": 201,
    "headers": {
        "Content-Type": "application/json",
        "My-Custom-Header": "Custom Value"
    },
    "body": "{ \"message\": \"Hello, world!\" }",
    "cookies": [
        "Cookie_1=Value1; Expires=21 Oct 2021 07:48 GMT",
        "Cookie_2=Value2; Max-Age=78000"
    ],
    "isBase64Encoded": false
}

Content-Type の デフォルトは “application/json” ですね。

このブログへのリンクでも表示してみましょうか。
コードを以下の内容に書き換えて、 Deploy をクリックします。

def lambda_handler(event, context):
    html = """<!DOCTYPE html>
<a href="https://analytics-note.xyz/" target="brank_">分析ノート</a>"""
    return {
        "headers": {
            "Content-Type": "text/html;charset=utf-8",
        },
        "statusCode": 200,
        "body": html
    }

これで、関数URLにアクセスすると、このブログへのリンクだけの簡素なページが表示されます。

Content-Type を “text/html” ではなく、 “text/html;charset=utf-8” にしているのは、こうしないと日本語が文字化けするからです。

ここまで、新規の関数について関数URLを設定する方法を書いて来ましたが、既存の関数についても「設定」のところに関数URLの項目が追加されており、非常に簡単に浸かるようになっています。

こうしてURLを簡単に使えると、たとえばEC2の起動/停止などの処理も今までboto3のバッチでやっていましたが、ブラウザでアクセスするだけで起動できるようになります。
jupyter notebook のサーバーを操作するも一層手軽になりそうです。
(端末のコンソール起動する一手間がなくなるだけですが、地味に面倒だったので)

ただ、気をつけないといけないのはグローバルにアクセスできるので、誰でも実行できるという点ですね。もしクリティカルな処理で使う場合は、何かしらの制限をかけた方が良いでしょう。

Pandasのデータが単調増加/単調減少かどうかを判定する

最近、とあるデータを分析する前のデータの品質チェックで、ある値の列が単調増加になっているかどうかを判定する必要が発生しました。全グループ単調増加のはずのデータだったのに、そうなっていないのが見つかったため、一通りチェックすることにしたのです。

Pandasのデータだったので、普通に差分をとって全部正であることを見ればよかったのですが、もっとスマートな方法がないか探してみたところ実際に良い方法が見つかったのでその紹介です。ちなみに差分の取り方は過去に記事にしています。
参考: Dataframeの差分を取る

さて、今回見つけたのは、PandasのSeries(とindexオブジェクトも)に用意されている、以下のプロパティです。(3個目のやつは1個目のエイリアスなので実質2個ですが)
pandas.Series.is_monotonic
メリーズ おむつ テープ M(6〜11kg) 1パック(64枚入) さらさらエアスルー 花王1,740円
pandas.Series.is_monotonic_increasing (is_monotonicのエイリアス)

is_monotonic と is_monotonic_increasing はそのSeriesが単調増加ならTrue、そうでないならFalse、is_monotonic_decreasing はそのSeriesが単調現象ならTrue、そうでないならばFalseを返してくれます。

いちいち差分取って確認したりしなくても、最初からフラグを持っていたというとてもありがたい話でした。

注意しないといけなのは、count()やsum()などのメソッドではなくプロパティなので使う時にカッコは要らないのと、当然引数が渡せないので細かな調整などはできず用意された仕様でそのまま使うしかないということでしょうか。(メソッドにしていただいて、狭義/広義やNoneの扱いなどが調整できるともっと便利だったのですが。)

とりあえず使ってみましょう。サンプルは全部 is_monotonic (単調増加)でやってみます。
単調減少の場合は、is_monotonic_decreasingをつかうだけで基本的には同じです。

まず普通に単調増加の時とそうでないときで、True/ Falseが変わっているのをみます。
下の例で分かる通り、判定は広義の単調増加の場合であってもTrueです。

import pandas as pd
# (広義)単調増加の場合
sr = pd.Series([1, 1, 2, 3, 5])
print(sr.is_monotonic)
# True
# (狭義)単調増加の場合
sr = pd.Series([1, 2, 4, 9, 16])
print(sr.is_monotonic)
# True
# 単調増加ではない場合
sr = pd.Series([1, -2, 3, -4, 5])
print(sr.is_monotonic)
# False

Noneなどが入ってる場合はそれ以外の部分が単調増加でもFalseになるようです。

sr = pd.Series([None, 1, 2, 3, 5])
print(sr.is_monotonic)
# False

値が数値ではなく文字列の場合も使えます。大小が定義できるものなら良いようです。

sr = pd.Series(["abc", "lmn", "xyz"])
print(sr.is_monotonic)
# True
sr = pd.Series(["z", "y", "x"])
print(sr.is_monotonic)
# False

広義ではなく狭義単調増加の判定がしたいんだということもあると思うのですが、上の方でも書いた通り、is_monotonicはプロパティなのでそう言った細かい調整はできません。どうやったらスマートに判定できるのかなと、考えたのですが、まず is_monotonic で単調増加性を判定した後に、値のユニークカウントと値の数を比較するのがいいのではないかと思いました。狭義単調増加であれば全て異なる値になっているはずだからです。

# (広義)単調増加の場合
sr = pd.Series([1, 1, 2, 3, 5])
print(sr.is_monotonic)
# True
# 重複する値があるのでFalseになる
print(sr.nunique() == sr.count())
# False
# (狭義)単調増加の場合
sr = pd.Series([1, 2, 4, 9, 16])
print(sr.is_monotonic)
# True
# 重複する値がないのでTrueになる
print(sr.nunique() == sr.count())
# True

さて、ここまで Seriesについて書いて来ましたが、DataFrameのデータについて調べたい場合もあります。というか、単一のSeriesについて調べるのであれば差分取って調べてもそんなに手間ではなく、僕が今回この方法を調査してたのはデータフレームで大量のデータについて調査する必要があったからです。

データの持ち方について、普通のパターンがありうるのでまずは列別にその列全体が単調増加かどうかを調べる方法を紹介します。サンプルデータはこちらです。

df = pd.DataFrame(
    {
        "year": [2010, 2011, 2012, 2013, 2014],
        "col1": [1, 1, 2, 3, 5],
        "col2": [1, 2, 4, 9, 16],
        "col3": [1, -2, 3, -4, 5],
    }
)
print(df)
"""
   year  col1  col2  col3
0  2010     1     1     1
1  2011     1     2    -2
2  2012     2     4     3
3  2013     3     9    -4
4  2014     5    16     5
""" 

PandasのDataFrameにはis_monotonicなどのプロパティはありません。df.is_monotonicとかしても、各列について一括で調査したりはできないようです。
(これがメソッドであるsum()やcount()との大きな違いですね。)
for文を回して列ごとにis_monotonicをやってもいいのですが、 applyを使うのがスマートではないでしょうか。以下のようにして、col3だけ単調増加ではないことがすぐわかりました。

print(df.apply(lambda col: col.is_monotonic))
"""
year     True
col1     True
col2     True
col3    False
dtype: bool
"""

次に、別のデータの持ち方をしているDataFrameを見てみましょう。これは、値は1列に全部入ってるのですが、カテゴリーを示す列があり、カテゴリーごとに単調増加かどうかを判定したいというケースです。こういうやつです。

df = pd.DataFrame(
    {
        "type": ["type1"] * 5 + ["type2"] * 5 + ["type3"]*5,
        "year": [2010, 2011, 2012, 2013, 2014] * 3,
        "value": [1, 1, 2, 3, 5] + [1, 2, 4, 9, 16] + [1, -2, 3, -4, 5],
    }
)
print(df)
"""
type  year  value
0   type1  2010      1
1   type1  2011      1
2   type1  2012      2
3   type1  2013      3
4   type1  2014      5
5   type2  2010      1
6   type2  2011      2
7   type2  2012      4
8   type2  2013      9
9   type2  2014     16
10  type3  2010      1
11  type3  2011     -2
12  type3  2012      3
13  type3  2013     -4
14  type3  2014      5
"""

これもtypeが3種類くらいで、値も全部で15個みたいな上のサンプルのようなやつならfor文で回してもそんなに大変ではないですが、データが莫大になるとスマートな方法が欲しくなります。いろいろ考えたのですが、素直にgroupbyで分割して、applyでlambda式を当てていくのが良いと思います。何度も書いていますが、メソッドではないので、groupby(“type”).is_monotonic() のような書き方では動きません。

print(df.groupby("type").apply(lambda x: x.value.is_monotonic))
"""
type
type1     True
type2     True
type3    False
dtype: bool
"""

以上が、is_monotonic の使い方や応用例の紹介になります。単調増加とか単調減少の判定をしたいって場面は多くないかもしれませんが、いざ必要になるとこれらのプロパティは非常に便利で、元のデータがリストやNumpyのArrayだった場合はこれのためにわざわざSeriesに変換してもいいのではないかと思うレベルです。機会があればこれらの存在を思い出してください。

Pythonで時刻をUNIX時間に変換する方法やPandasのデータを使う時の注意点

UNIX時間(または、エポック秒、UNIX時刻)というのは、UTCの1970年1月1日0時0分0秒を0として、そこからの経過秒数を基準に時間を表そうという方法です。
参考: UNIX時間 – Wikipedia

普段使っているトレジャーデータがログの時刻をUNIX時間で記録しているので、僕は業務で目にすることが多いのですが、基本的にSQLのUDFで文字列に変換して抽出するようにしているので、普段のPythonのプログラムで扱うことはあまりありません。しかし、最近とあるデータをPythonで処理していた時、文字列の時刻とUNIXタイムの変換をする機会があったので方法と注意点をまとめておきます。

注意点というのは、dateteimeモジュールを使うケースと、pandasを使うケースでタイムゾーンに絡む挙動が少々違い、危うく間違った変換をしそうになったのです。

コード例ごとに時刻が違うとこの記事が読みにくくなるのでこの記事では、
日本時間の 2022-05-20 15:00:00 を使います。
時差が9時間あるので、UTCでは 2022-05-20 6:00:00 であり、
UNIX時刻は 1653026400 です。

それでは、具体的な変換方法を見ていきましょう。

標準の datetime ライブラリを用いた変換

参考: 鳩よけ 鳥よけ カラスよけ からす撃退 鳩撃退グッズ 鳥対策 スズメ撃退 鳩よけグッズ とりよけ 鳩対策グッズ からすよけ 防鳥ネット カラスよけネッ

まず、 time スタンプから時刻に変換するには fromtimestamp というメソッドを使います。

import datetime
sample_time = datetime.datetime.fromtimestamp(1653026400)
sample_time  # 2022-05-20 15:00:00 を示す datetimeオブジェクトができる。
# datetime.datetime(2022, 5, 20, 15, 0)
# printすると文字列になる
print(sample_time)
# 2022-05-20 15:00:00
# 表示形式を調整したい場合は strftime
print(sample_time.strftime("%Y年%m月%d日 %H時%M分%S秒"))
# 2022年05月20日 15時00分00秒

ここで注目すべきは、fromtimestamp が勝手に日本時間で変換してくれている点です。
ドキュメントのfromtimestampにも、「オプションの引数 tz が None であるか、指定されていない場合、タイムスタンプはプラットフォームのローカルな日付および時刻に変換され、」と書いてあります。非常にありがたいですね。

明示的に日本時間(+9時間)であることを指定するにはタイムゾーンの情報も付加します。

tzinfo=datetime.timezone(datetime.timedelta(hours=9))
datetime.datetime.fromtimestamp(1653026400, tz=tzinfo)
# datetime.datetime(2022, 5, 20, 15, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=32400)))

今度は逆に、”2022-05-20 15:00:00″ という文字列をタイムスタンプにしてみます。
これは、strptime() でdatetime型のオブジェクトに変換し、timestamp()メソッドを呼び出せば良いです。

# 指定時刻のdatetimeオブジェクトを作る
sample_time = datetime.datetime.strptime("2022-05-20 15:00:00", "%Y-%m-%d %H:%M:%S")
sample_time
# datetime.datetime(2022, 5, 20, 15, 0)
# timestamp() メソッドでUNIX時間に変換できる
print(sample_time.timestamp())
# 1653026400.0

正しく変換されましたね。

ちなみに、元のデータが文字列ではなく,datetimeメソッドで作ったdatetimeオブジェクトでも結果は同様です。ちゃんと日本時間として変換してくれます。

print(datetime.datetime(2022, 5, 20, 15, 0, 0).timestamp())
# 1653026400.0

さて、ここまでは標準のdatetimeオブジェクトにおける挙動でした。
端末の環境が日本時間なら、あまりタイムゾーンを意識しなくても正しく動きます。逆にいうと、AWSなどのクラウドサービスを海外リージョンで使っている場合などは環境の時刻設定に気を付けて使う必要があるということです。
次はPandasで見ていきます。

Pandasのデータにおける変換

データ分析の仕事しているため、一個の時刻情報を変換するということはあまりなく、たいていは大量のテーブルデータの一列を丸ごと変換する必要があります。そういう時は、datetimeオブジェクトではなく、Pandasを使います。

早速、僕がちょっとハマったところを共有します。以下のようなデータがあったとします。この時点で、time_str列は文字列です。

df = pd.DataFrame({
    "key": ["key1", "key2", "key3"],
    "time_str": ["2022-05-20 15:00:00", "2022-05-20 15:00:00", "2022-05-20 15:00:00"]
})
print(df)
"""
    key             time_str
0  key1  2022-05-20 15:00:00
1  key2  2022-05-20 15:00:00
2  key3  2022-05-20 15:00:00
"""

UNIX時刻に変換する準備として文字列を時刻(datetime)型に変換するために、pd.to_datetimeを使います。(細かく指定しなくてもいい感じに日時として解釈してくれる非常に便利な関数です。)

df["time"] = pd.to_datetime(df.time_str)
print(df["time"])
"""
0   2022-05-20 15:00:00
1   2022-05-20 15:00:00
2   2022-05-20 15:00:00
Name: time, dtype: datetime64[ns]
"""

ここから、dt.timestamp() みたいなメソッドで変換できると楽なのですが、dtにはtimestamp()がありません。しかし、datetime64の各要素はtimestamp()メソッドを持っているので一見これで変換できるように見えます。

df.time.apply(lambda t: t.timestamp())
"""
0    1.653059e+09
1    1.653059e+09
2    1.653059e+09
Name: time, dtype: float64
"""

floatになるので、整数への変換もやりましょう。

df.time.apply(lambda t: int(t.timestamp()))
"""
0    1653058800
1    1653058800
2    1653058800
Name: time, dtype: int64
"""

はい、できました、と思ってしまいますがよく見ると結果が違いますよね。

$1653058800 – 1653026400 = 9 * 60 * 60$ なので、ちょうど9時間分ずれた結果になってしまいました。要するに、pd.to_datetime は 与えられた時刻をUTCで解釈しているわけです。

以下の二つが違う結果になるのってちょっとビックリませんか。

print(datetime.datetime.strptime("2022-05-20 15:00:00", "%Y-%m-%d %H:%M:%S").timestamp())
# 1653026400.0
print(pd.to_datetime("2022-05-20 15:00:00").timestamp())
# 1653058800.0

この問題を解消し、日本時間として解釈してUNIX時間に変換するには、結果から 9時間分 = 32400 秒 引いてあげても良いです。ただ、コードの可読性的にいまいちなので、タイムゾーンを設定してあげるのが良いと思います。それには、 tz_localize というのを使います。
参考: pandas.Series.tz_localize — pandas 1.4.2 documentation
(tz_convert という似てるけど用途が違うものもあるので注意してください。)

df["time"] = pd.to_datetime(df.time_str)
print(df.time)
"""
0   2022-05-20 15:00:00
1   2022-05-20 15:00:00
2   2022-05-20 15:00:00
Name: time, dtype: datetime64[ns]
"""
# タイムゾーンを Asia/Tokyo にローカライズする
df.time = df.time.dt.tz_localize('Asia/Tokyo')
print(df.time)
"""
0   2022-05-20 15:00:00+09:00
1   2022-05-20 15:00:00+09:00
2   2022-05-20 15:00:00+09:00
Name: time, dtype: datetime64[ns, Asia/Tokyo]
"""
# タイムスタンプに変換
print(df.time.apply(lambda t: int(t.timestamp())))
"""
0    1653026400
1    1653026400
2    1653026400
Name: time, dtype: int64
"""

今度は正しく、 1653026400 になりました。
これで、 to_datetimeを使って生成された時刻データもUNIX時間に変換できましたね。

ちなみに、逆にPandasのデータでUNIX時間の列があった場合にそれを時刻に変換したい場合は、もうdatetimeライブラリに頼った方がいいと思います。

sr = pd.Series([1653026400, 1653026400, 1653026400])
print(sr.apply(datetime.datetime.fromtimestamp))
"""
0   2022-05-20 15:00:00
1   2022-05-20 15:00:00
2   2022-05-20 15:00:00
dtype: datetime64[ns]
"""

以上で、標準ライブラリを使う場合とPandasを使う場合で、時刻とUNIX時刻の相互変換ができるようになりました。

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は同じカテゴリで扱われていますね。
参考: DR.VAPE ドクターベイプ クラシックスモーク for Model 2 [ どくたーべいぷ ニコチン タール なし]

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

ドキュメントに、「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と同じなので略します。

WordCloudをHTML出力し、各単語にリンクを設定する

このブログで何度かワードクラウドを作って来ましたが、ちょっとした興味からそれを画像ではなくHTML出力したいと思いやってみました。
そして、単にHTML出力をするだけでなく、ワードクラウド中の単語をクリックしたら単語ごとに指定したURLにジャンプできるようにします。

この記事のサンプルではその単語でのGoogle検索結果へアクセスできるようにします。ワードクラウド自体はいつものライブドアニュースコーパスのデータで作ります。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

実はいつも使っているワードクラウドのライブラリ(wordcloud)には、to_html()というメソッドが形だけあって、中身が実装されていないという状態でした。僕はこれが実装されるのを待っていたのですが、残念ながら最近の更新でこのメソッド自体が消されてしまいました。
参考: remove empty to_html in favor of to_svg (#607) · amueller/word_cloud@be9bb5e · GitHub

HTML出力機能はもう実装されないんだと思い、wordcloudのオブジェクトが持っているlayout_プロパティ(単語や配置場所、色などがまとまった配列)を読み解いて、自分でHTMLを作るコードを書くしかないと思ってコーディングに着手していました。
しかし改めてライブラリのコードをよく見るとto_svg()なるメソッドを持っているではないですか。これを使えばSVGで書き出せるので、簡単にHTMLに埋め込めます。
参考: wordcloud.WordCloud — wordcloud 1.8.1 documentation

SVGで書き出せたらあとはリンクを貼るだけなので簡単です。textタグたちをaタグで囲むだけです。ただ、今回はJavaScriptの addEventListener で、click したら指定のURLを開く関数をセットしました。

前置きが長くなって来たので、ここからやってみます。まずは普通にワードクラウドを作ります。

import re
import subprocess
import unicodedata
import pandas as pd
import MeCab
from wordcloud import WordCloud
# データの読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# ユニコード正規化とアルファベットの小文字統一
df.text = df.text.str.normalize("NFKC").str.lower()
# 改行コードを取り除く
df.text = df.text.str.replace("\n", " ")
# エラーになる文字があるので取り除く (ライブドアニュースコーパス使う場合だけの処理。普通は不要)
df.text = df.text.str.replace("\u2028", "")
# 辞書のパス取得
dicdir = subprocess.run(["mecab-config", "--dicdir"], capture_output=True, text=True).stdout.strip()
tagger = MeCab.Tagger(f"-d {dicdir}/ipadic")
# ひらがなのみの文字列にマッチする正規表現
kana_re = re.compile("^[ぁ-ゖ]+$")
# 分かち書きして、ひらがな以外を含む 名詞/動詞/形容詞 を返す関数
def mecab_tokenizer(text):
    # 分かち書き
    parsed_lines = tagger.parse(text).splitlines()[:-1]
    surfaces = [l.split('\t')[0] for l in parsed_lines]
    features = [l.split('\t')[1] for l in parsed_lines]
    # 原型を取得
    bases = [f.split(',')[6] for f in features]
    # 品詞を取得
    pos = [f.split(',')[0] for f in features]
    # 各単語を原型に変換する
    token_list = [b if b != '*' else s for s, b in zip(surfaces, bases)]
    # 名詞/動詞/形容詞に絞り込み
    target_pos = ["名詞", "動詞", "形容詞"]
    token_list = [t for t, p in zip(token_list, pos) if (p in target_pos)]
    # ひらがなのみの単語を除く
    token_list = [t for t in token_list if not kana_re.match(t)]
    # アルファベットを小文字に統一
    token_list = [t.lower() for t in token_list]
    # 半角スペースを挟んで結合する。
    result = " ".join(token_list)
    # 念のためもう一度ユニコード正規化
    result = unicodedata.normalize("NFKC", result)
    return result
# 形態素解析
df["tokens"] = df.text.apply(mecab_tokenizer)
# wordcloud入力データ用に連結する
text_data = " ".join(df["tokens"])
# ワードクラウドのオブジェクト生成
wc = WordCloud(
        font_path="/Library/Fonts/ipaexg.ttf",  # 日本語フォントファイル
        width=600,  # 幅
        height=400,  # 高さ
        prefer_horizontal=0.9,  # 横書きで配置することを試す確率 (デフォルト0.9)
        background_color='white',  # 背景色
        include_numbers=False,  # 数値だけの単語を含まない
        colormap='tab20',  # 文字色のカラーマップ指定
        regexp=r"\w{2,}",  # 2文字以上の単語のみ含む
        relative_scaling=1,  # 頻度のみで文字サイズを決める
        collocations=False,  # bi-gramを考慮しない
        max_font_size=60,  # 最大フォントサイズ
        random_state=42,  # 乱数の初期値
    ).generate(text_data)
# この時点で作成できたwordcloudを確認する場合は以下の関数を実行。
wc.to_image()
# 出力省略

さて、これでワードクラウドができました。これを to_svg() で SVGに出力するとどうなるか見てみましょう。

# to_svg() で svgの文字列を生成できる
print(wc.to_svg())
"""
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="400">
<style>text{font-family:'IPAexGothic';font-weight:normal;font-style:normal;}</style>
<rect width="100%" height="100%" style="fill:white"></rect>
<text transform="translate(342,63)" font-size="60" style="fill:rgb(127, 127, 127)">思う</text>
<text transform="translate(391,134)" font-size="44" style="fill:rgb(127, 127, 127)">映画</text>
<text transform="translate(239,359)" font-size="41" style="fill:rgb(174, 199, 232)">自分</text>
<text transform="translate(458,55)" font-size="39" style="fill:rgb(174, 199, 232)">記事</text>
 # 中略
<text transform="translate(411,86)" font-size="12" style="fill:rgb(158, 218, 229)">一番</text>
<text transform="translate(408,50)" font-size="12" style="fill:rgb(23, 190, 207)">転職</text>
<text transform="translate(58,277)" font-size="12" style="fill:rgb(255, 152, 150)">得る</text>
</svg>
"""

引数にファイル名の指定などはできず、SVGタグで囲まれた文字列を返してくれるようです。
これを .svg の拡張子で保存すると、ワードクラウドのSVGファイルが出来上がります。
先頭に、”<!DOCTYPE HTML>”をくっつけて、.htmlファイルで保存してもよいでしょう。
文字列にリンクを設定するなどの要件が無いのであれば、これをファイルに保存して完成です。

ここからリンクを設定していきます。リンク先は今回はお試しなので、Google検索結果にします。URLは、 https://www.google.com/search?q={検索キーワード} です。

前の方でも書きましたが、リンクっていう観点だとaタグで囲むのがセオリーですし、テキストを加工するスクリプトを描くのもそんなに難しくありません。ただ、文字列の加工の総量を少なめにしたいのと、イベントリスナーを使う方法の方がページ遷移させる以外の用途への応用も多いと思うので、こちらを採用しました。

Google検索結果のURLの検索キーワードはパーセントエンコーディングする必要があるので、JavaScriptのencodeURI関数を使います。僕はあまりJavaScript得意ではないので自信がないですが、次のようなコードでいかがでしょうか。
(無理矢理PythonでJavaScriptを書き出してますが、一旦SVGを書き出してテキストエディタでスクリプトを書き込んでも良いと思います。)

# textタグたちに、クリック時にGoogle検索結果を開くイベントリスナーを追加するJavaScript
link_script = """
<script>
    svg = document.getElementsByTagName("svg")[0];
    text_tags =  svg.getElementsByTagName("text")
    for(var i=0; i<text_tags.length; i++){
        text_tags[i].addEventListener(
            "click",
            function(){
                word = this.textContent;
                word_uri = encodeURI(word);
                url = "https://www.google.com/search?q=" + word_uri;
                window.open(url, "_bkank");
            }
        )
    }
</script>"""
# HTMLファイルに書き出し
with open("word_cloud.html", "w") as f:
    f.write("<!DOCTYPE HTML>\n")
    f.write(wc.to_svg())
    f.write(link_script)

出力されるhtmlファイルが次です。

ブログに公開できるデータということで、無難なデータと無難なリンク先で作っていますが、これはデータと使い方によっては結構面白いものを作れる可能性を感じませんか?

pyvisでネットワーク可視化

このブログではネットワーク(グラフ)の可視化にはNetworkX(+Matplotlib)やGraphvizを使って来たのですが、最近pyvisというライブラリを気に入っていてよく使うようになりました。
訪問者の方にもぜひ触ってみていただきたいので軽く紹介していきます。

まず、どんなものが描けるのかの例です。このpyvisはvis.jsというJavaScriptのライブラリのPythonラッパーになっています。 そのため、vis.jsのドキュメントの examples ページを見るとさまざまな可視化例が載っています。こんなのが作れるんだ!ってモチベーションが上がるので一度見ていただけると幸いです。(実はこのvis.jsはネットワークの可視化以外の機能も持っているのですが、もっぱら僕の関心はネットワークの可視化にあります。)
参考: Vis Network Examples

各サンプルなのですが、それぞれのサンプル個別のページのアクセスして触っていただけると分かる通り、このライブラリで描いたネットワーク図はノードをマウスでドラッグして動かせます。これが1番のお勧めポイントです。また、単一のHTMLファイルで結果が出力されるので仕事で可視化した場合は結果は他の人に共有しやすいというのもいいですね。

それでは早速、使ってみましょう。pyvis 自体のドキュメントはこちらですが、母体のvis.jsのドキュメントや、Examplesのソースコードなども目を通されると理解が深まると思います。

本当に適当に作ってみました。インスタンス作って、ノードとエッジを追加して、最後HTML出力のメソッドを呼び出して完成です。

from pyvis.network import Network
# ネットワークのインスタンス生成
network = Network(
    height="300px",  # デフォルト "500px"
    width="400px",  # デフォルト "500px"
    notebook=True,  # これをTrueにしておくとjupyter上で結果が見れる
    bgcolor='#ffffff',  # 背景色。デフォルト "#ffffff"
    directed=True,  # Trueにすると有向グラフ。デフォルトはFalseで無向グラフ
)
# add_node でノードを追加
# label は省略可能。省略するとidと同じになる。
network.add_node(n_id=1, label=1, shape="circle")
network.add_node(n_id=2, label=2, shape="box", color="green")
network.add_node(n_id=3, label=3, shape="triangle")
network.add_node(n_id=4, label=4)  # shape を省略すると、 shape="dot"と同じになる
# add_edge でエッジを追加
network.add_edge(1, 2,)
network.add_edge(2, 4, width=2)  # width で太さを変えられる
network.add_edge(3, 4, smooth="dynamic")  # smooth を指定することで、エッジを曲線にできる
network.add_edge(4, 3, smooth="dynamic")  # エッジを曲線にすると双方向のエッジを別の線にできる。(直線だと重なる)
# 指定したファイル名でHTMLを出力。
network.show("pyvis_sample1.html")

出来上がりはこんな感じです。

動かせるっていいですね。

ソースコード中にコメントをいろいろ書いてますが、ノードもエッジも細かい設定がたくさんできます。pyvisのドキュメントだとこのページが該当するのですが、そこまで丁寧ではないので、vis.jsの方のドキュメントを読み込んで、そこから類推して使った方が理解が早いかもしれません。(場合によっては出力されたHTMLファイルを直接編集するとか。)
vis.js の方のドキュメントでは以下のページに設定可能な項目がまとまっています。
vis.js – Nodes documentation.
vis.js – Edges documentation.

もう一つ、HTML出力前に network.show_buttons() ってメソッドを実行しておくと、大量のコントロールボタンがHTMLに出力されます。(jupyterでは表示されないので、別途ブラウザでHTMLファイルを開いてください。)
これを使うと、ノードやエッジのオプションやノード配置の計算に使われている力学の設定などをインタラクティブに変更できます。

# ネットワークの作成までのコードは共通
network.show_buttons()  # 各種ボタンの表示
network.show("pyvis_sample2.html")

これも楽しいので試してみてください。(ただ、楽しいだけで今のところ有効な使い方を見出せずすみません。) ボタン多すぎ、って場合は、ドキュメントにある通り、filter_ って引数で絞り込めます。

最後に、vis.js から pyvis に移行した人への注意が一つあります。(僕はこのパターン。当初直接HTMLでvis.jsファイル書いてました。)
それは、 node の shape のデフォルト値で、 HTML/JavaScriptで書いてる場合、shapeを何も指定しなかった場合の挙動は”ellipse”です。しかし、pyvis は add_nodeでshape 引数を省略すると、出力されるJavaScirptに勝手に “shape”: “dot” をつけます。

何も指定せずに超単純なネットワークを描いたときの挙動が違うので僕は結構戸惑いました。
(ellipseは図の内側にラベルが入り、dotはラベルが外に出るという違いがちょっと気に入らなかった。その辺の話も、上記の Nodes documentation に書いてあります。)

PythonでWikipediaの情報を取得する

仕事で諸事情あり、Wikipediaの情報を取得したいことがあったのでその方法を紹介します。
最初はrequestsなどのHTTPクライアントでデータを取ってきて頑張ってHTMLをパースしようかと思っていたのですが、実はPythonでWikipediaを利用するには専用のライブラリが公開されています。それがこちらの wikipedia です。 PyWikipediaとかじゃなくて そのままの名前なのですね。
参考: PyPIのページ wikipedia · PyPI
ドキュメント Wikipedia — wikipedia 0.9 documentation

どうやら、Wikipedia本体がそのそも、Media Wiki API というAPIを公開してくれていて、それをラップしているようです。

APIにはかなり多様な機能が実装されているようですが、いったん僕の用途としては用語の検索と、該当ページのコンテンツの取得の二つができれば良いのでその点に絞って使い方を紹介していきます。

一番シンプルな使い方は Quickstart ページを見るのが良いでしょう。
とりあえず、適当な単語で検索してみます。僕たちは日本語を使うので、最初に言語を設定します。(設定しない場合はデフォルトの言語は英語で、日本語の単語を検索したとしても英語版Wikipedia内で検索されます。)

import wikipedia
wikipedia.set_lang("ja")
print(wikipedia.search("データ分析"))
"""
['データ解析',
 'データ',
 '慶應義塾大学パネルデータ設計・解析センター',
 'データベース',
 'データ・クラスタリング',
 'ビッグデータ',
 '主成分分析',
 '分散分析',
 'NTTデータ',
 '精神分析学']
"""

言語を設定した後、「データ分析」で検索してマッチしたページのタイトルの一覧が取得できましたね。(実際のWikipediaで検索すると、一番目の候補のデータ解析のページにリダイレクトされます。)

set_lang() で設定できる略号(上の例で言えばja)と言語(上の例では日本語)の対応は、languages()ってメソッドで一覧が取れます

print(wikipedia.languages())
"""
{'aa': 'Qafár af',
 'ab': 'аԥсшәа',
 'abs': 'bahasa ambon',
 'ace': 'Acèh',
 'ady': 'адыгабзэ',
 'ady-cyrl': 'адыгабзэ',
 'aeb': 'تونسي/Tûnsî',
 'aeb-arab': 'تونسي',
 # 以下略
"""

さて、先ほどのsearchの結果、「データ解析」ってページがあることがわかったので具体的にそのページのコンテンツを取得してみましょう。それには、page というメソッドを使います。結果は、WikipediaPageというオブジェクトで取得でき、タイトルや中身をその属性として持ちます。とりあえず、タイトルとサマリーを表示してみます。

wp = wikipedia.page("データ解析")
print(wp.title)
# データ解析
print(wp.summary)
"""
データ解析(データかいせき、英: data analysis)は、データ分析(データぶんせき)とも呼ばれ、
有用な情報を発見し、結論を報告し、意思決定を支援することを目的として、データを検査し、
クリーニングや変換を経て、モデル化する一連のプロセスである。データ解析には多数の側面とアプローチがあり、
色々な名称のもとで多様な手法を包含し、ビジネス、科学、社会科学のさまざまな領域で用いられている。
今日のビジネス界において、データ解析は、より科学的な意思決定を行い、ビジネスの効率的な運営に貢献する役割を担っている。
データマイニングは、(純粋な記述的な目的ではなく)予測的な目的で統計的モデリングと
知識獲得に重点を置いた固有のデータ解析技術である。
これに対し、ビジネスインテリジェンスは、主にビジネス情報に重点を置いて、集計に大きく依存するデータ解析を対象としている。
統計学的な用途では、データ解析は記述統計学 (en:英語版) 、探査的データ解析(EDA)、確認的データ解析(仮説検定)(CDA)に分けられる。
EDAはデータの新たな特徴を発見することに重点を置き、CDAは既存の仮説の確認または反証に焦点を当てる。
予測分析は、予測的な発生予報あるいは分類のための統計モデルの応用に重点を置き、
テキスト分析は、統計的、言語的、および構造的な手法を用いて、非構造化データの一種であるテキストデータから情報を抽出し知識の発見や分類を行う。
上記はどれも、データ解析の一種である。
データ統合はデータ解析の前段階であり、データ可視化およびデータ配布はデータ解析と密接に関連している。
"""

ページのコンテンツ全体は、content プロパティに持っていて、またhtml()メソッドを使うと、そのページのHTML全体を取得することもできます。

wp.content  # コンテンツ全体
wp.html()  # HTMLが取得できる

どちらも結果が非常に大きいので出力は省略します。

このほか、そのページのurl (wp.url)やリンクされている他の単語の一覧(wp.links)などの属性も持っています。どんな属性があるのかの一覧はドキュメントの、引っ越し 挨拶 品物 ギフト ねこのお引越し 引越し 引っ越し挨拶品 引越しの挨拶 手土産 おしゃれ nyagomi (リニューアル新発売) の部分を見ていただくのが良いと思います。

上記では、まず、wikipedia.page() で ページオブジェクトを取得して、その後そこからsummaryを取得しましたが、実は summaryを直接取得することもできます。それは単に、 wikipedia.summary()を使うだけです。(contentにはこれはないんですよね。summary専用の機能と考えて良さそうです。)

# summaryは直接取得できる
print(wikipedia.summary("データ解析"))
"""
データ解析(データかいせき、英: data analysis)は、 # 長いので以下略
"""
# 実はデータ分析、で検索しても勝手にリダイレクトしてデータ解析の結果を返してくれる
print(wikipedia.summary("データ分析"))
"""
データ解析(データかいせき、英: data analysis)は、 # 長いので以下略
"""

これで非常に簡単にWikipediaのデータが使えるようになりました。
ただ、一点気をつけないといけないことがあります。それは、検索すると曖昧さ回避のページに飛ぶような単語で検索する場合です。例えば、地名、人名、企業名、等々で使われる「豊田」でやってみると、DisambiguationError という例外が発生します。ライブラリが例外オブジェクトを作ってくれているのでそれでキャッチできます。

try:
    wp = wikipedia.page("豊田")
except wikipedia.exceptions.DisambiguationError as e:
    print(e)
"""
"豊田" may refer to:
豊田市
豊田町 (曖昧さ回避)
豊田町 (山口県)
豊田町 (静岡県)
豊田村 (曖昧さ回避)
豊田郡 (曖昧さ回避)
豊田郡
豊田 (名古屋市)
豊田地区
豊田 (日野市)
豊田 (紀の川市)
豊田鎮 (通遼市)
豊田鎮 (南靖県)
豊田郷 (彰武県)
豊田郷 (新寧県)
フォンディエン県 (トゥアティエン=フエ省)
フォンディエン県 (カントー)
豊田駅
豊田駅 (北海道)
豊田駅 (花蓮県)
新豊田駅
新豊田駅
三河豊田駅
豊田市駅
上豊田駅
肥後豊田駅
豊田町駅
豊田本町駅
トヨタグループ
トヨタ自動車
豊田自動織機
トヨタ紡織
豊田通商
豊田中央研究所
豊田合成
豊田鉄工
豊田スタジアム
豊田 (飲食業)
豊田エリー
豊田清
豊田順子
豊田孝治
豊田真由子
豊田萌絵
豊田泰光
豊田穣
豊田陽平
豊田ルナ
豊田佐吉
豊田喜一郎
豊田英二
豊田章男
豊田インターチェンジ
豊田市#道路
豊田 (小惑星)
豊田館跡
豊田ナンバー
豊田工業大学
^
「豊田」で始まるページの一覧
"""

用途が多様な単語で使う場合は気をつけるようにしましょう。