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 ライブラリを用いた変換

参考: datetime — 基本的な日付型および時間型 — Python 3.10.4 ドキュメント

まず、 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は同じカテゴリで扱われていますね。
参考: 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と同じなので略します。

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)などの属性も持っています。どんな属性があるのかの一覧はドキュメントの、class wikipedia.WikipediaPage の部分を見ていただくのが良いと思います。

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

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

Jupyterのウィジェットを使ったアノテーションツール

前回の記事で紹介したJupyter Notebookのウィジェット(ボタン)の簡単な応用事例です。
参考: Jupyter Notebook でボタンを使う

この記事でいくつか事例を紹介しようと思いますが、まずは機械学習等でテキストデータの分類モデルをつくつる時の教師データ作成(アノテーション)のツールを作ってみようと思います。
何かしらテキストデータを受け取って、事前に定義されたいくつかのカテゴリ(ラベル)の中から1個選んで、「これはカテゴリ0だ」とか「こいつはカテゴリ3だ」みたいなのを記録してくツールですね。

とりあえず、使うデータの準備と必要なライブラリを読み込んでおきます。サンプルなのでテキストの内容自体は適当です。

import pandas as pd
import ipywidgets as widgets
from IPython.display import display


# サンプルとして適当なテキストの集合を作る
df = pd.DataFrame(
    {
        "text": [f"ラベル付されるテキストその{i:03d}" for i in range(100)],
        "label": None
    }
)
print(df.head())
"""
               text label
0  ラベル付されるテキストその000  None
1  ラベル付されるテキストその001  None
2  ラベル付されるテキストその002  None
3  ラベル付されるテキストその003  None
4  ラベル付されるテキストその004  None
"""

前回の記事ではあまり名前空間のことを考えずにいろいろ書いていましたが、各種ウィジェットがグローバルスコープにあると予期せぬ挙動につながったりもするので、今回はアノテーションのクラスを定義します。
コードはやや長いですがやっていることはシンプルで、渡されたラベルの数だけボタンを作り、データフレーム内のテキストデータを1つ表示し、ボタンが押されたらボタンに対応したlabelのidをデータフレームに書き込んで、また次のテキストを表示するというそれだけです。
そして、全データ処理し終わった時に、ボタンを非活性化する処理も入れています。

今回はサンプルデータが適当で、データ中に何番目のテキストなのか入ってますが、それとは別に i 番目のデータだって表示する機能も入れています。実務では何かしら進捗がわかる機能をつけた方が良いです。どこまで進んだかわからない長いアノテーションは心が折れます。

class annotation():

    def __init__(self, df, labels):
        self.data = df
        self.i = 0  # 何番目のデータをアノテーションしているかのカウンタ
        self.buttons = [widgets.Button(description=label) for label in labels]
        self.output = widgets.Output()

        # ボタンにvalueプロパティを持たせておく
        for j in range(len(self.buttons)):
            self.buttons[j].value = j

        # ボタンにクリック寺の処理を追加
        for button in self.buttons:
            button.on_click(self.select_label)

        # ツールの初期表示実行
        self.display_tools()

    def display_tools(self):
        # ボタンや出力領域の初期表示
        self.hbox = widgets.HBox(self.buttons)
        self.output.clear_output()
        display(self.hbox, self.output)
        with self.output:
            print(f"{self.i}番目のデータ:\n")
            print(self.data.iloc[self.i]["text"])

    def select_label(self, button):
        # ボタンを押した時の処理
        # データフレームにセットされたボタンのvalueを書き込む
        self.data.iloc[self.i]["label"] = button.value
        # 次のデータに移行
        self.i += 1
        # 次のデータを表示
        self.output.clear_output(True)

        # 全データ処理し終わったら完了
        if self.i >= len(self.data):
            with self.output:
                print("完了!")
                for button in self.buttons:
                    button.disabled = True  # ボタンを非活性化
            return

        with self.output:
            print(f"{self.i}番目のデータ:\n")
            print(self.data.iloc[self.i]["text"])

上で作ったクラスは次のように使います。

labels = ["カテゴリー0", "カテゴリー1", "カテゴリー2", "カテゴリー3"]
tool = annotation(df, labels)

こうすると次の画像のようにボタンが表示され、クリックするごとにデータフレームに値が書き込まれていきます。

適当のぽちぽちとボタンを押していくと、DataFrameに次のように値が入ります。

print(df.head())
"""
               text label
0  ラベル付されるテキストその000     1
1  ラベル付されるテキストその001     2
2  ラベル付されるテキストその002     0
3  ラベル付されるテキストその003     3
4  ラベル付されるテキストその004     0
"""

ボタンの配置をもっと整えたり、スキップボタン、戻るボタンの実装などいろいろカスタマイズは考えられますが、一旦これで最低限の役割は果たせると思います。

次にもう一つ、MeCabのユーザー辞書作成の補助ツールを作っておきます。
これは、ボタンを押したらそのボタンに紐づいたラベルが記録されるのではなく、
ドロップダウンで品詞情報を選び、さらに、原形、読み、発音を手入力で入れて、確定ボタンを押したら単語辞書作成用の配列に結果格納するというものです。

今回の記事はお試しなので、追加する単語は適当にピックアップした数個ですが本来はそのリストを作らないといけないので、こちらの記事を参考にしてください。
参考: gensimでフレーズ抽出
また、アウトプットのMeCabユーザー辞書の書式はこちらです。
参考: MeCabでユーザー辞書を作って単語を追加する

ではやってみます。今回も各種パーツを一つのクラスにまとめます。

class create_dictionaly():
    def __init__(self, new_words):
        self.data = new_words  # 辞書に追加する候補のワード
        self.i = 0  # カウンタ
        self.results = []  # 結果格納用の配列

        # 確定とスキップの2種類のボタンを用意する
        self.decision_button = widgets.Button(description="確定")
        self.decision_button.on_click(self.decision_click)
        self.skip_button = widgets.Button(description="スキップ")
        self.skip_button.on_click(self.skip_click)

        # 品詞の選択機能(サンプルコードなのでIPA辞書の品詞の一部だけ実装)
        self.pos_list = [
            "名詞,一般,*,*,*,*,*",
            "名詞,固有名詞,一般,*,*,*,*",
            "名詞,サ変接続,*,*,*,*,*",
            "名詞,ナイ形容詞語幹,*,*,*,*,*",
            "名詞,形容動詞語幹,*,*,*,*,*",
            "名詞,固有名詞,人名,一般,*,*,*",
            "名詞,固有名詞,人名,姓,*,*,*",
            "名詞,固有名詞,人名,名,*,*,*",
            "名詞,固有名詞,組織,*,*,*,*",
        ]
        self.pos_select = widgets.Dropdown(options=self.pos_list)

        # 原形, 読み, 発音を設定する項目
        self.base = widgets.Text(description="原形: ")
        self.reading = widgets.Text(description="読み: ")
        self.pronunciation = widgets.Text(description="発音: ")

        # 次の単語候補表示場所
        self.new_word = widgets.Output()

        self.display_tools()

    def display_tools(self):
        # ボタンや出力領域の初期表示
        self.text_hbox = widgets.HBox(
            [self.base, self.reading, self.pronunciation])
        self.button_hbox = widgets.HBox(
            [self.decision_button, self.skip_button])
        display(self.new_word, self.text_hbox,
                self.pos_select, self.button_hbox)
        # 最初の単語を表示しておく
        self.next_word()

    def next_word(self):
        # 次の単語の表示
        # 全データ処理し終わったら完了
        if self.i >= len(self.data):
            self.new_word.clear_output(True)
            with self.new_word:
                print("完了!")
                self.decision_button.disabled = True
                self.skip_button.disabled = True
            return

        self.word = self.data[self.i]

        self.new_word.clear_output(True)

        with self.new_word:
            print(self.word)

        self.base.value = self.word
        self.reading.value = ""
        self.pronunciation.value = ""
        self.pos_select.value = self.pos_list[0]
        self.i += 1

    def decision_click(self, button):
        # 確定ボタンクリック
        # MeCabユーザー辞書の形式のテキストを生成
        result_text = f"{self.word},,,,{self.pos_select.value},"
        result_text += f"{self.base.value},{self.reading.value},{self.pronunciation.value}"
        # 結果の一覧に格納
        self.results.append(result_text)
        # 次の単語表示
        self.next_word()

    def skip_click(self, button):
        # スキップボタンクリック
        # 次の単語表示
        self.next_word()

実行は次のコードです。

cd_tool = create_dictionaly(["スマホ", "クラウド", "ガジェット", "インターフェース", "ブログ"])

このように、単語候補が出現し、原形(一旦そのままの値で補完)と読み、発音を入力して、品詞を選んで確定を押すと辞書用のデータが記録されていく仕組みになっています。

結果は、cd_tool.result に入ってます。

print("\n".join(cd_tool.results))
"""
スマホ,,,,名詞,一般,*,*,*,*,*,スマホ,スマホ,スマホ
クラウド,,,,名詞,一般,*,*,*,*,*,クラウド,クラウド,クラウド
ガジェット,,,,名詞,一般,*,*,*,*,*,ガジェット,ガジェット,ガジェット
インターフェース,,,,名詞,一般,*,*,*,*,*,インターフェース,インターフェース,インターフェース
ブログ,,,,名詞,一般,*,*,*,*,*,ブログ,ブログ,ブログ
"""

これをテキストファイルに書き出せば、ユーザー辞書のseedデータになります。

原形/読み/発音はテキストボックスを一つにまとめて自分でカンマを打つ方が早いかもと思ったり、全体的に配置のデザインがイケて無いなとか思うところはあるのですが、この先徐々に改良していきたいと思います。

以上の二つの例で、ボタンの結果をそのまま記録していくパターン、何かしらのウィジェットでデータを入力してボタンを押して確定するパターンの二つを紹介できたので、ラベル付のタスクであれば、これらの組み合わせで大抵は対応できるのではないでしょうか。

Jupyter Notebook でボタンを使う

以前、Jupyterでインタラクティブに関数を実行する方法を紹介しました。
参考: Jupyter Notebookでインタラクティブに関数を実行する

この時は、Jupyter Widgets のinteract というのを使って、スライドバー等を動かしたら自動的に値を変更してグラフを描く関数を実行する例を取り上げました。

今回は、値を変えるとかではなく、単純にNotebook上にボタンを表示して、そのボタンを押した瞬間に何か処理を実行するような方法をまとめます。

例えば、多くのテキストの値が格納されたDataFrameを対象に、ボタンをクリックするごとに次のテキストが読めるとか、何かしらの一連の処理を都度中断して経過を観察しながら徐々に実行していくとかそういった用途を想定してます。

早速説明に入りましょう。まずボタンの作成です。
前回の時は、 ipywidgets.interact 一つでいろんなUIが作れたのですが、ボタンはその中に含まれていません。ボタンを作りたい時は、ipywidgets.widgets.Buttonを使います。
さらに、ボタンを作っただけだと表示されないので、display します。もしくは、セルの最後の行で作ったボタンインスタンスを呼び出しても表示されます。(ただ、この方法は最後の1個しか出せないので、通常はdisplayしましょう。)

import ipywidgets as widgets
from IPython.display import display


button = widgets.Button(description="ボタンです")
display(button)

もしくは、下記の通り。

button = widgets.Button(description="ボタンです")
button

これで、「ボタンです」と表示されたボタンが作成されます。
description 以外の引数はドキュメントを参照してください。スタイルを警告に変えたり等の設定ができます。
参考: Widget List — Jupyter Widgets 8.0.0rc0 documentation

さて、ボタンを作成してもまだこのボタンには何の機能も実装されていません。ドキュメントにある通り、実行したい関数(仮にfooとする)を用意して、button.on_click(foo) と登録する必要があります。ボタンを押したら出力先にボタンが押された回数を表示するメッセージを出すようにしてみましょう。

button = widgets.Button(description="ボタンです")
output = widgets.Output()  # 出力先


i = 0  # ボタンが押された回数を記録しておく
def on_button_clicked(b):
    global i
    i += 1
    output.clear_output(True)  # 前のクリック時の出力を消す
    with output:
        print(f"{i}回ボタンを押しました。")


button.on_click(on_button_clicked)  # ボタンが押されたときに実行するメソッドをセット
display(button, output)

上記のコードで、ボタンが表示され、ボタンをクックするたびに
「i回ボタンを押しました。」の文字列が更新されていきます。
outputの使い方が独特ですね。 with output: するとそのスコープ配下のprint文の出力がoutputに表示されます。詳しくはOutputに関するドキュメントをご参照ください。
参考: Output widgets: leveraging Jupyter’s display system — Jupyter Widgets 8.0.0rc0 documentation

clear_output に True を渡していますが、このTrueは次の描写対象を待って消す、という処理になります。デフォルトはFalseです。
以前の記事で紹介した、セル出力のクリアと同じですね。
参考: jupyter notebookのセルの出力をコードでクリアする

ボタンの出力をoutputに出す方法は、次のように@を使ってデコレーターとして書く方法もあります。

button = widgets.Button(description="ボタンです")
output = widgets.Output()  # 出力先


i = 0  # ボタンが押された回数を記録しておく
@output.capture()
def on_button_clicked(b):
    global i
    i += 1
    output.clear_output(True)  # 前のクリック時の出力を消す
    print(f"{i}回ボタンを押しました。")


button.on_click(on_button_clicked)  # ボタンが押されたときに実行するメソッドを記録
display(button, output)

正直どちらも慣れるまではわかりにくいです。

実は昔のバージョンのjupyterはOutputを使わないとボタンの処理でprintした文を画面に出してくれなかったという話を聞いたのですが、最近のjupyterはOutputを作ってなくてもちゃんと出力してくれます。(どのバージョンからそうなったのかは調べられていないです。)
ただ、OutputはOutputとして作っておかないと、セルの出力にボタンもボタンの処理の出力も混ぜて出してしまうと、クリアしたときにボタンも消えるという不便さがあるので、Outputは必須でなくても使った方が良いです。

一度表示したボタンを消したい場合(要は、2回押すことがないとか表示するボタンの種類が次々変わるような作業をするとかといった場合)は、button.close()でボタンを消すことができます。ドキュメントのButtonのところをどう読んでもこれについての記載がなかったので僕は結構戸惑いました。buttonに実装されているメソッド/プロパティを一通り眺めて見つけたのですが、実はドキュメントでは次のページに記載されていたようです。
参考: Simple Widget Introduction — Jupyter Widgets 8.0.0rc0 documentation

以下のコードで押すと消えるボタンが

button = widgets.Button(description="押すと消えるボタン")


def on_button_clicked(b):
    b.close()


button.on_click(on_button_clicked)
display(button)

消えなくてもいいけど押せないようにしてほしい、という場合は、button.disabled の値(通常False) にTrueを代入しましょう。(ただの代入なのでコード例省略)

さて、ボタンを複数表示して押されたボタンによって処理を変えたい、という場面は多々あると思います。ボタンの数だけ on_click に設定するメソッドを定義してそれぞれ設定しても一応動くのですがコードがカッコ悪いので嫌ですね。

Buttonに何か値を持たせて、それに応じて処理を変える、というのを考えたのですが、Button()の引数に valueに相当するものがなさそうです。さっきのページに全てのウィジェットはvalueプロパティを持っている、的なことが書いてあるのに、ボタンにはvalueがないんですよね。(AttributeError: ‘Button’ object has no attribute ‘value’ が起きます。)
引用: All of the IPython widgets share a similar naming scheme. To read the value of a widget, you can query its value property.

一応、案の一つとして、button.description でボタンに表示しているテキストを取得できます。これの値によって処理を振り分けることは可能です。

ただ、実際これを使うと不便です。画面には男性/女性と表示しつつプログラム内の値としては1/2を使いたい、みたいな場面は多々あり、毎回マッピングしないといけません。

もう一つ、buttonはvalueプロパティを持っていませんでしたが、試し見てたところ後から設定することは可能でした。これを使うと一つの関数で挙動を振り分けることもできそうです。ボタンを3個作ってどれが押されたかわかるようにしてみましょう。
(ただ押したボタン名を表示するだけだったら無理やりvalue使わなくても、descriptionでいいじゃないか、って感じの例ですみません。)

# ボタンのインスタンスを作る
button_0 = widgets.Button(description="ボタン0")
button_1 = widgets.Button(description="ボタン1")
button_2 = widgets.Button(description="ボタン2")
# valueプロパティに値を設定する
button_0.value = 0
button_1.value = 1
button_2.value = 2
output = widgets.Output()  # 出力先


def on_button_clicked(b):
    output.clear_output(True)  # 前のクリック時の出力を消す
    with output:
        print(f"{b.value}番のボタンが押されました")


button_0.on_click(on_button_clicked)
button_1.on_click(on_button_clicked)
button_2.on_click(on_button_clicked)
hbox = widgets.HBox([button_0, button_1, button_2])  # HBoxを使うと横に並べることができる
display(hbox, output)

以上のコードで、ボタンが3個表示され、押したボタンによって異なる結果がアウトプット出力されます。

単純にボタンを並べると縦に並んでしまうので、HBoxというのを使って横向きに並べました。このようなレイアウト関連のツールはこちらのページにまとまっています。
参考: Layout and Styling of Jupyter widgets — Jupyter Widgets 8.0.0rc0 documentation

想定外に記事が長くなってきたので一旦今回の更新はここまでにします。
(OutputとかHBoxとかButton以外の紹介も必要でしたし。)

本当はこのButtonを使ったアノテーションのコードとか紹介したかったので次の記事でそれを書こうと思います。

Pandas.DataFrameの表示設定を変更する

以前調べたのですがしばらく使わないうちにど忘れしてしまっていたので、改めてPandasのDataFrameの表示設定を変更する方法についてまとめていきます。

ちなみにドキュメントはこちらです。
参考: Options and settings — pandas 1.4.1 documentation

(ターミナルで動かしている時も実は事情は同じなのですが、)特にJupyter NotebookでPandasを使ってデータ分析をしている時など、頻繁にデータフレームの中身を表示して中身を確認します。その場合、デフォルトの表示設定では不便な思いをすることがよくあるので、設定の変更方法を知っておくと役に立ちます。

一番頻繁に使うのは表示行数の上限設定でしょうか。
Pandasのデフォルトでは、60行をこえるDataFrameを表示する時、中間のデータが省略されて先頭と末尾のそれぞれ数行だけが表示されます。
例えば以下のようにです。

import pandas as pd


df = pd.DataFrame(range(61), columns=["value"])
print(df)
"""
    value
0       0
1       1
2       2
3       3
4       4
..    ...
56     56
57     57
58     58
59     59
60     60

[61 rows x 1 columns]
"""

中身をもっとみたい時は、head(60), tail(60), sample(60)やその他ilocなどのスライスを使って対象行数を60行以内に抑えるか、for文で回して中身をprintするなどの対応をとることが多いですが、実はこの60行の上限はPandasが内部の値として持っているもので、これ子を書き換えることが可能です。

# pd.options.display.max_rows が表示される上限の行数
print(pd.options.display.max_rows)
# 60

# 値を代入すると設定が変わる
pd.options.display.max_rows = 100

0~61の連番を表示してみてもこの記事のスペースを圧迫するだけなので設定変更した結果表示できるようになったDataFrameの例は載せませんが、上記のコードの最後の行のようにして、max_rowsに大きめの値を入れると、もっと多くの行数を表示できるようになります。
Noneにすると上限がなくなりますが、巨大なDataFrameを表示しようとしてしまった場合にブラウザが固まることがあるなどデメリットもあるので気をつけてください。

設定の確認と値の変更は、上記のようにpd.options.display以下のプロパティを見ていくほか、get_option/ set_option というメソッドで取得/設定することも可能です。これは、この後見ていく他のオプションでも同様です。

# 先ほど設定した 100 表示される
print(pd.get_option("display.max_rows"))
# 100

# set_option で値を設定できる
pd.set_option("display.max_rows", 120)

さて、表示量の上限は行数だけでなく、列数や、1行内の文字数にも設定されています。
それぞれ、20列と80文字が上限です。そのため、それを超えるDataFrameをprintすると、列が省略されたり改行されたりします。

# 横に表示される文字数の上限は80文字
print(pd.options.display.width)
# 80

# 同時に表示される列数は20列まで
print(pd.options.display.max_columns)
# 20

# 21列あるテーブルを表示すると、 途中の列が ... で省略される
# さらに、80文字を超えたので改行された
print(pd.DataFrame([range(21)]*5))

"""
   0   1   2   3   4   5   6   7   8   9   ...  11  12  13  14  15  16  17  \
0   0   1   2   3   4   5   6   7   8   9  ...  11  12  13  14  15  16  17   
1   0   1   2   3   4   5   6   7   8   9  ...  11  12  13  14  15  16  17   
2   0   1   2   3   4   5   6   7   8   9  ...  11  12  13  14  15  16  17   
3   0   1   2   3   4   5   6   7   8   9  ...  11  12  13  14  15  16  17   
4   0   1   2   3   4   5   6   7   8   9  ...  11  12  13  14  15  16  17   

   18  19  20  
0  18  19  20  
1  18  19  20  
2  18  19  20  
3  18  19  20  
4  18  19  20  

[5 rows x 21 columns]
"""

これも、設定を変更すると改善します。例えば、40列、160文字までOKにすると次のようになります。

pd.options.display.width = 160
pd.options.display.max_columns = 40

print(pd.DataFrame([range(21)]*5))
"""
   0   1   2   3   4   5   6   7   8   9   10  11  12  13  14  15  16  17  18  19  20
0   0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15  16  17  18  19  20
1   0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15  16  17  18  19  20
2   0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15  16  17  18  19  20
3   0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15  16  17  18  19  20
4   0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15  16  17  18  19  20
"""

先ほど設定を変更した文字数の上限は、行トータルの文字数です。
1つのセル内の文字数には、max_colwidth というまた別の設定があります。

# デフォルトは50文字
print(pd.options.display.max_colwidth)
# 50

# 51文字以上のテキストは ... で省略される
print(pd.DataFrame(["a"*51]))
"""
                                                   0
0  aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...
"""

# 100文字に増やしてみる
pd.options.display.max_colwidth = 100
# 省略されずに表示される
print(pd.DataFrame(["a"*51]))
"""
                                                     0
0  aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
"""

文字数についての表示のほか、数値については小数を何桁で四捨五入して表示するかといったことも設定可能です。実はこの設定についてはこのブログでも何度か使ったことがあります。
そのうち1記事がこちらです。
参考: 文書をTfidfVectorizerでベクトル化したときの正規化について

この記事では、 pd.options.display.precision = 3 として、小数点以下3位までにヒョ時を制限しています。

import numpy as np


# 乱数でデータ生成
df = pd.DataFrame(np.random.random(size=(3, 3)))
# デフォルトは6桁
print(pd.options.display.precision)
# 6

print(df)
"""
          0         1         2
0  0.222825  0.134517  0.054559
1  0.286993  0.400856  0.117309
2  0.960378  0.022352  0.855942
"""

# 3桁までに設定すると小数点以下3桁しか表示されない
pd.options.display.precision = 3
print(df)
"""
       0      1      2
0  0.223  0.135  0.055
1  0.287  0.401  0.117
2  0.960  0.022  0.856
"""

細かい話なのですが、この時の丸め方は通常の四捨五入ではないのでご注意ください。ほとんどの値については四捨五入と同じ挙動になるのですが、丸められる桁の値がピッタリ5の場合、その上の桁が偶数の方に丸められることがあります。言葉で書くより、実例を見ていただいた方がわかりやすいと思うのでやってみます。以下の例は明らかに不自然な挙動をしているのが伝わると思います。

pd.options.display.precision = 3
print(pd.DataFrame([1.0015, 1.0025, 1.0035, 1.0045]))
"""
       0
0  1.002
1  1.002
2  1.004
3  1.004
"""

# 桁数によっては、偶数への丸めにならないこともある。
pd.options.display.precision = 4
print(pd.DataFrame([1.00015, 1.00025, 1.00035, 1.00045]))
"""
        0
0  1.0002
1  1.0003
2  1.0004
3  1.0005
"""

通常の四捨五入をしてほしい、という場合は、displayの設定変更ではなく専用のメソッドを使って四捨五入しましょう。

さて、ここまで自分がよく使うやつを中心に挙げてきましたが、他にも設定可能な項目はたくさんあります。公式ドキュメントにまとまっているので、一度目を通されることをお勧めします。
再掲: Options and settings — pandas 1.4.1 documentation