sshtunnel を使って踏み台サーバー経由でDB接続

以前、PyMySQLを使って、Amazon RDSを利用する方法を記事にしました。
参考: PythonでAuroraを操作する(PyMySQLを使う方法)

DBに直接接続できる場合はこれで良いのですが、場合によっては踏み台となるサーバーを経由して接続しなければならないことがあります。

僕の場合は職場ではセキュリティ上の理由から分析用のDBの一つがローカルから直接接続できないようになっていますし、プライベートではAurora Serverless v1使っているので、これはAWS内のリソース経由でしか接続できません。

ということで、Pythonで踏み台経由してAWSに接続する方法を書いていきます。
実はこれまで人からもらったコードをそのまま使っていたのですが、この記事書くために改めてsshtunnel のドキュメントを読んで仕組みを理解しました。

参考: Welcome to sshtunnel’s documentation! — sshtunnel 0.4.0 documentation

さて、さっそくやっていきましょう。セキュリティ的に接続情報はブログに書くわけにいかないので、以下の変数に入ってるものとします。

あと、サンプルなので実行したいSQL文も sql って変数に入ってるものとします。

サーバーのネットワーク設定ですが、踏み台はSSHのポート(通常は22番)、RDSはDBの接続ポート(通常は3306番)を開けておいてください。以降のコードで出てくる9999番ポートは、ローカル端末のポートなので踏み台やDBのサーバーでは開けておかなくて良いです。

# DBの接続情報 (RDSを想定)
db_host = "{DBのエンドポイント}"  # xxxx.rds.amazon.com みたいなの
db_port = 3306  # DBのポート(デフォルトから変更している場合は要修正)
db_name = "{データベース名}"
db_user = "{DBに接続するユーザー名}"
db_pass = "{DBに接続するユーザーのパスワード}"

# 踏み台サーバーの接続情報 (EC2を想定)
ssh_ip = "{サーバーのIPアドレス}"
ssh_user = "{SSH接続するユーザー名}"  # EC2のデフォルトであれば ec2-user
ssh_port = 22  # SS接続するポート(デオフォルトから変更している場合は要修正)
ssh_pkey = "{秘密鍵ファイルの配置パス}"  # .pem ファイルのパス

sql = "{実行したいSQL文}"

さて、さっそく行ってみましょう。単発で1個だけSQLを打って結果を取得したい、という場合、以下のコードで実行できます。
ローカル(手元のPCやMac)の 9999 番ポート (これは他で使ってなければ何番でもいい)への通信が、踏み台サーバーを経由してRDSに届くようになります。

from sshtunnel import SSHTunnelForwarder
from pymysql import cursors
from pymysql import connect


with SSHTunnelForwarder(
    ssh_address_or_host=(ssh_ip, ssh_port),  # 踏み台にするサーバーのIP/SSHポート
    ssh_username=ssh_user,  # SSHでログインするユーザー
    ssh_pkey=ssh_pkey,  # SSHの認証に使う秘密鍵
    remote_bind_address=(db_host, db_port),  # 踏み台を経由して接続したいDBのホスト名とポート
    local_bind_address=("localhost", 9999),  # バインドするローカル端末のホスト名とポート
) as tunnel:
    with connect(
            host="localhost",  # DBのエンドポイントではなく、ローカルの端末を指定する
            port=9999,  # これもDBのポートでは無く、バインドしたポート番号を指定する
            user=db_user,  # これ以下は普通にDB接続する場合と同じ引数
            password=db_pass,
            database=db_name,
            charset="utf8mb4",
            autocommit=True,
            cursorclass=cursors.DictCursor,
    ).cursor() as cursor:
        cursor.execute(sql)  # これでSQL実行
        rows = cursor.fetchall()  # 結果の取り出し

これで、通常はローカルからはアクセスできないDBへSQLを発行し、結果を変数rowsに取得することができました。SELECT文を打ったのであればpandasのDataFrame等に変換して使いましょう。

with文で変数をたくさん呼び出すインスタンスを使うのはコードの見栄えが非常に悪くなりますが、以下のように変数を事前に辞書にまとめておくと少しマシになります。

ssh_args = {
    "ssh_address_or_host": (ssh_ip, ssh_port),
    "ssh_username": ssh_user,
    "ssh_pkey": ssh_pkey,
    "remote_bind_address": (db_host, db_port),
    "local_bind_address": ("localhost", 9999),
}

db_args = {
    "host": "localhost",
    "port":  9999,
    "user":  db_user,
    "password":  db_pass,
    "database":  db_name,
    "charset":  "utf8mb4",
    "autocommit":  True,
    "cursorclass":  cursors.DictCursor,
}

with SSHTunnelForwarder(**ssh_args) as tunnel:
    with connect(**db_args).cursor() as cursor:
        cursor.execute(sql)
        rows = cursor.fetchall()

以上で、一応やりたいことはできると思いますが、発行したいSQLが複数ある場合かつ途中に別の処理も含むような場合一回ごとにポートフォワードとDBの接続をやり直していたらリソースの無駄です。(といっても、最近のコンピューター環境ならこれがストレスになる程時間かかるってことはないと思いますが。)

DBヘ接続しっぱなしにしておく場合は、withを使わずに次のように書きます。引数は上のコード例で作った、ssh_args, db_args をそのまま使います。

server = SSHTunnelForwarder(**ssh_args)
server.start()  # ポートフォワード開始

connection = connect(**db_args)  # DB接続
# 以上の3行で DBに接続した状態になる。

# 以下のようにして接続を使ってSQLを実行する。
with connection.cursor() as cursor:
    cursor.execute(sql)
    rows = cursor.fetchall()

# サンプルコードなのでSQLを1回しかやってないけど、続けて複数実行できる。

# 終わったらDB接続とポートフォワードをそれぞれ閉じる
connection.close()
server.stop()

これで、一つの接続を使い回すこともできるようになりました。

ちなみにですが、このsshtunnelで作ったポートフォワードの設定は端末単位で有効です。どういうことかというと、複数のPythonプロセス(例えば別々のJupyter notebook)間で、共有することができます。というより、Pythonに限らず他のプログラムからも使えます。
コンソールで、以下のコマンド使ってポートの動きを見ながら試すとよくわかります。

# 9999番ポートの利用状況を確認する
$ sudo lsof -i:9999

普段は何も結果が返ってこないか、ここまでのプログラムを実行してたらいろんな情報と共に(CLOSED)が返ってくると思いますが、ポートフォワードしている最中はESTABLISHEDになっていて、pythonが使っていることが確認できます。

特にPythonでDB操作したいという場合に限って言えば、別々のnotebookで操作するメリットなんて無いのですが、全く別の用途でポートフォワードだけPythonでやっておきたい、ということはあるかもしれないので、覚えておくと使う機会があるかもしれません。

コマンドやライブラリでHTTPアクセスした場合のUser Agentの初期値を調べた

curl コマンドでも Pythonの requests でもそうなのですが、使用するときにUser Agentを指定することができます。

では、もし何も指定しなかったらどんな値が設定されているんだろうかと気になったのですが、意外に情報が出てこなかったものが多いのであれこれ調べてみました。ついでに他の手段についても調べています。

調査方法について

やり方としては、AWS Lambda の関数URLで、User Agentを表示するHTMLを作ってそこにアクセスします。
参考: Lambda の関数URLで送信されたデータを扱う

Pythonの場合、リクエスト元のUser Agentは、以下のコードで取れます。

event["requestContext"]["http"]["userAgent"]

Pandasのread_htmlも試したかったので、こんな感じの Lambda 関数を作ってTableで返すようにしてみました。

def lambda_handler(event, context):
    html = """<!DOCTYPE html>
<table>
    <tr><th>User Agent
    <tr><td>{user_agent}
</table>""".format(user_agent=event["requestContext"]["http"]["userAgent"])
    
    return {
        "headers": {
            "Content-Type": "text/html;charset=utf-8",
        },
        'statusCode': 200,
        "body": html,
    }

ブラウザでアクセスすると、以下の結果が得られます。
まぁ、普通にMac Book Proの Chrome ブラウザですね。なぜか Safari の文字列も入っています。

<!DOCTYPE html>
<table>
    <tr><th>User Agent
    <tr><td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36
</table>

ここから、色々試していきます。物によってはHTMLタグ部分省略してるのもありますが、LambdaはHTMLで結果返してきてます。

Curlコマンド編

まず、Curlコマンドです。 普通にさっきの lambda の 関数URLを叩きます。

$ curl https://{url-id}.lambda-url.{region}.on.aws/
<!DOCTYPE html>
<table>
    <tr><th>User Agent
    <tr><td>curl/7.79.1
</table>

# 参考 curl のバージョン情報
$ curl --version
curl 7.79.1 (x86_64-apple-darwin21.0) libcurl/7.79.1 (SecureTransport) LibreSSL/3.3.6 zlib/1.2.11 nghttp2/1.45.1
Release-Date: 2021-09-22
Protocols: dict file ftp ftps gopher gophers http https imap imaps ldap ldaps mqtt pop3 pop3s rtsp smb smbs smtp smtps telnet tftp
Features: alt-svc AsynchDNS GSS-API HSTS HTTP2 HTTPS-proxy IPv6 Kerberos Largefile libz MultiSSL NTLM NTLM_WB SPNEGO SSL UnixSockets

「curl/7.79.1」だけでしたね。超シンプルです。OSの情報とかゴテゴテついてくるかと思ってました。

この記事の本題からは外れますが、Curlは-A か -H オプションでUser Agentを指定できます。書き方が、違うので注意してください。-Aの方が簡単ですが、-Hの方が User Agent以外の設定とも統一的な書き方ができます。

$ curl https://{url-id}.lambda-url.{region}.on.aws/ -A "Chrome"
<!DOCTYPE html>
<table>
    <tr><th>User Agent
    <tr><td>Chrome
</table>

$ curl ≈ -H "User-Agent: Chrome"
<!DOCTYPE html>
<table>
    <tr><th>User Agent
    <tr><td>Chrome
</table>

設定できましたね。

ここからPythonのライブラリを見ていきます。

requests 編

ここからずっと同じURLを使うので、url って変数に入れておきます。

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

では、requestsでgetしてみましょう。

import requests


r = requests.get(url)
print(r.text)
"""
<!DOCTYPE html>
<table>
    <tr><th>User Agent
    <tr><td>python-requests/2.27.1
</table>
"""

これもまた随分シンプルなUser Agentでした。python-ってつくんですね。
2.27.1 は ライブラリのバージョンです。

$ pip freeze | grep requests
requests==2.27.1

別の値を設定したい場合は、headers 引数に curl の -H オプションのイメージで設定します。

headers = {'User-Agent': "Chrome"}
r = requests.get(url, headers=headers)
print(r.text)
"""
    # 必要な行だけ抜粋
    <tr><td>Chrome
"""

urllib 編

次はPython標準ライブラリのurllibです。

正直、requests に比べてはるかに使用頻度低いので軽く試すだけにします。

import urllib.request
 
urllib_response = urllib.request.urlopen(url)
urllib_html = urllib_response.read().decode("utf-8")  # バイナリなのでデコードが必要
print(urllib_html)
"""
    # 必要な行だけ抜粋
    <tr><td>Python-urllib/3.9
"""

Python-urllib/3.9 と、これもまたかなりシンプルでした。特徴的なのは、 3.9 とPythonのバージョン番号がついてきましたね。さすが標準ライブラリです。

Pandasのread_html 編

最後に、PandasでHTMLからテーブルを抽出できるread_htmlメソッドについて実験します。これはかなり便利なメソッドなのでいずれ専用記事で紹介したいですね。HTMLのテキストだけでなくURLを渡すこともでき、そのURLにアクセスしてHTML中のテーブルをデータフレームの配列で返してくれます。データフレームの配列で返してくるので、以下のコードでは[0]で最初の要素を取り出しています。

import pandas as pd


df = pd.read_html(url)[0]
print(df)
"""
          User Agent
0  Python-urllib/3.9
"""

結果はご覧の通り、Python-urllib/3.9 でした。先ほどの標準ライブラリの urllibが内部で動いているようです。Pandasっぽい値が設定されることなく、そのままurllibの情報が出てきました。

Pandasのドキュメントを見る限りでは、read_htmlで情報を取るときにUser Agentを指定する方法は用意されてないようです。

以上で、 curl / requests / urllib / pandas.read_html のデフォルトの User Agent がわかりました。分かったからといって特に使い道が思いついているわけではないですが、気になったことが調べられてよかったです。

Pythonのrequestsでログインが必要なページにアクセスする

タイトルにはログインと書きましたが要するにPythonのrequestsライブラリでセッションを扱う方法を書きます。

PythonでWebアクセスする場合、普通にURLにGETでアクセスして必要な情報が取れる場合だけでなく、事前にログインが必要なページの情報を取りたい場合があります。
requestsライブラリを使えばそれも比較的容易に実現できました。

基本的に、どのサイトでも同じような方法で対応可能ですが、例として今回はSBI証券のバックアップサイトで保有証券の一覧あたりを取って見ましょう。
バックアップサイトを使うのはHTMLがシンプルで見やすいからです。
ログイン画面のURLは https://k.sbisec.co.jp/bsite/visitor/top.do で、
ログイン後に取得したい保有証券一覧ページは、https://k.sbisec.co.jp/bsite/member/acc/holdStockList.do です。

通常、ログイン等のセッションはCookieを使って維持されます。
そのため、素直に考えれば以下の手順でログイン済みのページにアクセスすることができます。

– ログインフォームのPOST先URL(htmlのformタグでaction属性で指定されているURL)に、フォームの入力内容のデータ(ユーザーIDやパスワード)をPOSTする。
– レスポンスのCookieを取得する。
– そのCookie情報をヘッダーに付与した状態でアクセスしたかったページにアクセスする。

あとは、アクセスするたびに、Cookieを付与してGETなりPOSTなりのリクエストを送れば、ブラウザで操作するのと同じように、ログイン後の画面にアクセスできます。

ただ、非常にありがたいことに、requestsライブラリには、Sessionっていう専用のクラスがあって、これを使うと逐一レスポンスからCookieを取り出したり、ヘッダーに付与したりするすることを意識せずにセッションを維持できます。

参考: Advanced Usage – Session Objects

使ってみる前に、POST先の情報や、フォームの各要素のnameを把握しておく必要があるので、まずはログインしたいページのフォームのHTMLを見ておきます。

import requests
from bs4 import BeautifulSoup


response_0 = requests.get("https://k.sbisec.co.jp/bsite/visitor/top.do")
soup_login = BeautifulSoup(response_0.text)
print(soup_login.find("form"))
"""
<form action="https://k.sbisec.co.jp/bsite/visitor/loginUserCheck.do" method="POST" name="form1">
<b>ユーザネーム</b>:
                <div style="margin-top:5px;">
<input istyle="3" maxlength="32" name="username" size="15" style="width:115px" type="text" value=""/>
</div>
<br/>
<b>パスワード</b>:
                <div style="margin-top:5px;">
<input class="bsite_pass" istyle="3" maxlength="30" name="password" size="15" type="password" value=""/>
</div>
<br/>
<div style="margin-top:5px;">
<a href="/bsite/info/policyList.do?list=attention">お取引注意事項</a>に同意の上、ログインして下さい
                </div>
<br/>
<div align="center">
<input name="login" type="submit" value=" ログイン "/>   
                <input name="cancel" onclick="dataclear()" type="button" value="キャンセル"/>
</div>
<br/>
</form>
"""

input タグのnameを見ると、username と password で良いようですね。

Sessionクラスを使う方法

さて、POST先ですが、actionに https://k.sbisec.co.jp/bsite/visitor/loginUserCheck.do と絶対URLで入ってるので、これをそのまま使えば良いでしょう。サイトやフォームによっては相対URLなので注意してください。
ではいよいよやってみましょう。

username と passwordは自分のを使います。

username = "{ユーザーネーム}"
password = "{パスワード}"
login_url = "https://k.sbisec.co.jp/bsite/visitor/loginUserCheck.do"

# セッションのインスタンスを作成する。
session = requests.Session()
response_1 = session.post(
        url=login_url,
        data={
            "username": username,
            "password": password,
        }
    )

print(response_1.text)
# 長いので出力略
# ログイン後に表示される機能メニューのHTMLが得られている。

はい、この段階でログインができました。そして、このsessionオブジェクトを使うと、目当ての保有証券一覧ページにアクセスできます。

response_2 = session.get(
        url="https://k.sbisec.co.jp/bsite/member/acc/holdStockList.do"
    )
print(response_2.text)
# 長いのでこれも出力略

自分が持ってる株を公開するつもりもないので、出力完全に省略させていただきましたが、このページのHTMLをパースすると保有中の証券の一覧が得られます。

やっていることといえば、requests.get や requests.post の代わりに、最初にSessionオブジェクトを作って、それのgetやpostメソッドを使うというだけです。非常に手軽ですね。

おまけ: Sessionクラスを使わない場合どうするか

冒頭でCookieを付与して云々という手順を書いていますが、もちろんそのやり方でログインすることもできます。Sessionクラスを使うデメリットとか得なくて、自分でCookieの操作を実装する必要性無いと思うのですが、実は実験したのでそれ書いておきます。
次のようにすると、Session使わずにログイン後のページにアクセスできます。

# ログインフォームにデータをポストしログインする。
response_3 = requests.post(
        url=login_url,
        data={
            "username": username,
            "password": password,
        }
    )
# cookieを取り出す。
cookies = response_3.cookies

# cookieをヘッダーに付与してgetする。
response_4 = requests.get(
        url="https://k.sbisec.co.jp/bsite/member/acc/holdStockList.do",
        cookies=cookies,
    )

# 保有証券一覧ページのHTMLが得られる
print(response_4.text)

うまくいかなかった場合に検証すること

サイトによっては、ここまでの方法をそのまま適用してもうまくいかないことがあります。
だいたいその原因は何かしらのセキュリティ対策がなされているためです。
ユーザーエージェントの問題の場合もあると思いますし、サイトによっては、ユーザーIDやパスワードを入力する画面の表示時点でCookieを発行していて、それと整合性が取れない場合、不正な画面遷移としてログインさせてくれないこともあります。その場合は、Sessionのインスタンスを作って、そのインスタンスで一度ログイン画面をgetしてCookieを得ておく必要があるでしょう。

また、ログインフォームにhidden属性で非表示のinputタグを作っておき、そこにトークンのようなものを埋めておいてそれが無いとログインできないというケースもあります。
その場合は、HTMLから情報取り出して、それらのトークン情報をデータに加えてPOSTするなどの対応が必要になります。

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

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

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

さて、今回見つけたのは、PandasのSeries(とindexオブジェクトも)に用意されている、以下のプロパティです。(3個目のやつは1個目のエイリアスなので実質2個ですが)
pandas.Series.is_monotonic
pandas.Series.is_monotonic_decreasing
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

2022/07/05 追記
このユニークの判定について、コメントで教えていたのですが、is_unique というそのものズバリな属性が存在していました。nunique()とcount() を比較するより、以下のようにis_unique見た方がずっとスマートです。

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

さて、ここまで 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に変換してもいいのではないかと思うレベルです。機会があればこれらの存在を思い出してください。

2022/07/05 追記
これもコメントで教えていただきましたが、lambdaを使わない書き方ができます。Groupbyした後に、列名を角括弧(ブラケット)で指定すると、is_monotonic_decreasing やis_monotonic_increasing が使えます。SeriesGroupBy オブジェクトが量プロパティを持っていたようです。 (なぜか is_monotonic は無いのでエラーになります。)

print(df.groupby("type")["value"].is_monotonic_increasing)
"""
type
type1     True
type2     True
type3    False
Name: value, dtype: bool
"""

print(df.groupby("type")["value"].is_monotonic_decreasing)
"""
type
type1    False
type2    False
type3    False
Name: value, dtype: bool
"""

# is_monotonic は動かない。
try:
    print(df.groupby("type")["value"].is_monotonic)
except Exception as e:
    print(e)
    
# 'SeriesGroupBy' object has no attribute 'is_monotonic'

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時刻の相互変換ができるようになりました。

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ファイルが次です。

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

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