2023年のご挨拶

新年明けましておめでとうございます。本年もこのブログをよろしくお願いします。

早速ですが今年のこのブログの更新方針を決めました。昨年同様に今年もしっかりインプットの時間を確保し、ブログへのアウトプットは少なめに週1回の更新を目指していきたいと思っています。

今年は新年早々から統計数理研究所の講座受講も2件決まっていたり、参加したいセミナーやミートアップも既にいくつかあるので積極的に動いていきたいです。昨年からすうがくぶんか社のセミナーも受講していますが、今年も何か面白そうなのを探して受講しようと思います。また、書籍についても昨年後半出た本が複数あり、まだ追いついていないので順次読んでいきます。

昨年はインプットを増やすと言って一番増えたのがビジネス系Youtubeの視聴時間だったので、今年はちゃんと読書時間を増やしたいです。Youtubeは最初は良かったのですが、冷静に見ると似たようなネタの繰り返しが多くてそろそろ減らしていいかなと思ってます。

このブログはネタ帳を用意していてそこに常時数十個のテーマを列挙しており、そこからその日の気分でピックアップして書いています。大体その執筆時点で新しく知ったばかりのことを優先的に選んで書いてるのですが、そうやって場当たり的に書いていると、タイミングを逸していつか書きたいと思ったまま放置状態になってしまっているテーマがたくさん残ってしまいました。この点は反省していて、そのうち書こうと思っていたけど放置してた系の記事をもっと書くようにしたいなと思っています。

例えば以下のような内容がいつか書かねばと思って放置された状態です。物によってはブログ開設前(2018年)にリストアップしてその時からずっと放置しています。全部書けるかというと難しそうなのですが少なくとも半分程度はクリアしたい。
– グラフのコミュニティー検出
– AWSの各サービスについて(DynamoDB/ personalize/ Forecast/ SageMaker など)
– opencv
– 生存分析(カプラン・マイヤー法やCOX回帰など)
– node2vec
– scikit-learn等のライブラリの最近の新機能
– 因果探索(LiNGAMなど)/因果推論
– 時系列データの異常検知や変化検知
– 状態空間モデル(カルマンフィルター)
– JavaScriptのデータ可視化関数(特にワードクラウド)
– Word Mover’s Distance などの自然言語処理の小ネタ
– jupyter lab
– J-Quants API
その他、numpy, scipy, pandas, matplotlib, tableauなどの小ネタなどが多数。

今年は今年で新ネタは出ると思いますし、更新回数が50回程度と考えるともう1年分のネタは確実に確保できそうです。あとは実際に執筆する時間とモチベーションを維持できるかという点が問題ですね。(何せ、書ける状態なのに書かなかったネタたちなので1つ1つがちょっと重い。)
できる範囲で頑張って書いていこうと思いますのでよろしくお願いします。

この他、昨年目標に入れていてあまり手をつけなかったこのブログ自体のメンテナンスもやらなければなりません。PHPやバージョンアップとか。これLightsail使ってるとすごく面倒なんですよね。
また、この記事を書いてる時点で海外から攻撃を受けているようでして、どこかの誰かが執拗にLoginを試みていてそのアクセスでCPUリソースが枯渇しているようです。
ここがそんなハッキングする価値のあるブログだとは思えなのですが、攻撃してくる人がいる以上はセキュリティ面の強化等も進めなければなりませんので、何かやったら記事にしていこうと思います。
アクセスが重くなっていることがあり、訪問者の方にはご不便をおかけします。

訪問者の方にはあまり関係ないことなのですが、Google Analyticsの旧バージョン、ユニバーサル アナリティクスが今年終了するというのもブログ関係では大きなイベントですね。
後継のGA4をしっかり学んで、継続して分析ができるようにしたいと思います。
(ただ、現時点のGA4は明らかにUAに劣るように感じているので、他の分析ツールへの乗り換えも視野に入れたい。これから改善するといいのですが。)

ブログ以外では、昨年からやっている投資ツール開発の個人プロジェクトももっと進めていきます。プログラムはほぼ動くものが揃ってきているのであとは手動で実行から自動実行への切り替えとか自動実行に伴うエラー通知の仕組み構築とかが残課題です。

以上のような方針で今年も頑張っていこうと思いますので、本年もよろしくお願いいたします。

2022年のまとめ

今日は2022年最後の月曜なので、この記事が2022年最後の記事です。1年間毎週の更新を継続できてほっとしています。

今年は年初に書いた方針通り、昨年に比べて更新頻度を半分に落としました。しかしそれでも昨年以上に多くの方に訪問していただけました。昨年も書いていますが、休日も夜間も継続的にアクセスがあり、いつも自分以外にも、どこかで技術的な調査や勉強に取り組んでいる人がいると実感できることは自分自身にとっても励みになりました。また、TwitterなどのSNSや他のブログ等で引用されていることを見かけることも多く、自分が書いた記事が誰かの役に立っていると実感でき、そのおかげでアウトプットを続けてくることができました。

まとめの記事なので、今年も1年間の振り返りをやります。本年までの累積の記事数および、年間のアクセス数は次のようになりました。

– 累計記事数 566記事 (この記事含む。昨年時点 514記事)
– 訪問ユーザー数 272,075人 (昨年実績 200,661人)
– ページビュー 476,587回 (昨年実績 348,595回)

更新数落として昨年比で+33%の訪問者数というのは本当に嬉しいです。最近では平日は1日1800人ものかたに訪問していただいています。

現行のGoogleアナリティクス(UA)が来年6月まででサービス終了してしまうので、来年はこの集計をどうするか考えないといけないですね。

今年もよく読まれた記事ランキングを見ていきましょう。以前は半年おきにやっていたのですが、今年は更新数を減らしたのもあって上期にやらなかったので1年ぶりです。
2022年1年間でのPV数によるランキングは次のようになりました。

1. Pythonで日付の加算、特にnヶ月後やn年後の日付を求める方法 (New)
2. matplotlibのグラフを高解像度で保存する (昨年1位)
3. matplotlibでグラフ枠から見た指定の位置にテキストを挿入する (昨年8位)
4. Pythonのリストをn個ずつに分割する (New)
5. matplotlibのdpiとfigsizeの正確な意味を調べてみた (New)
6. Pythonで連続した日付のリストを作る (昨年3位)
7. globでサブフォルダを含めて再帰的にファイルを探索する (New)
8. PythonでBase64エンコードとデコード (New)
9. Pandasで欠損のある列の文字列型の数値を数値型に変換する (New)
10. PythonでMeCabを動かそうとしたらmecabrc ファイルが無いというエラーが出たので原因を調べた (New)

今年新規にランクインした記事が7記事となりました。matplotlibのグラフの解像度を設定する話は長いことこのブログの一番人気だったのですがついに入れ替わりましたね。(データサイエンス要素は薄いのでこれが人気というのは若干複雑な気持ちです。)
ただこの中で、今年書いた記事って10位のmecabrcの記事だけのような。まぁ、古い記事が強いというのは長期にわたってニーズがある記事を書けているということでもあるので、今年書いた記事たちも来年以降に期待しましょう。

1年間の終わりなので、年初に立てた方針の振り返りもやっておきます。
参考: 2022年のご挨拶と今年の方針

まず、アウトプットは減らしてDSに限らず幅広い範囲のインプットを重視したいという話については、ある程度達成できたが、思っていたのとは違う形になったというのが正直なところです。データ分析の分野では、有償の講座受講などを増やし今までと違った形での学習機会を得ることができました。また、データサイエンス系の書籍の読書量は減らしたとはいえゼロにはしておらず、一定量の継続もできています。

また、仕事に関係ないところでもいつか読みたいと思っていた漫画のシリーズをいくつも読破できましたし、都内各地のいつか行ってみたいと思っていたところへ観光に行くこともできました。特に、上野の国立科学博物館は行ってよかったですね。次は特別展も見てみたいです。

若干想定外だったのは、今年1年間、Youtubeの視聴時間が急激に伸びたことです。人材業界で働いているので転職や就職などのキャリア関係のチャンネルをよく見ました。他にもエンジニア教育、数学を中心とした科学など幅広く見ています。近年Youtuberが増えて配信してる人は収益化が大変だという話を耳にしますが、視聴者としては良質なコンテンツも増えており大変勉強になります。書籍に比べてダラダラ見ることもできるのもいいですね。これは年初は全く想定してなかった変化でしたが良い結果になったかなと思います。

一方で、Youtubeの視聴時間の増加の割をくった形になったのが、データサイエンス以外の分野のビジネス書を読む時間で、これは計画の半分くらいしか進まなかったなと思います。来年改めて取り組みたいです。

このブログ自体のメンテナスをやるぞ、という目標もあったのですがこれが全然進みませんでした。リンクやカテゴリの見直しなどはまだいいとして、PHPのバージョンが古いとか流石に放置しておくのは良くない問題も出ているのでこれは来年対応したいです。

目標には入っていませんでしたが、今年やった取り組みとしてGithubにプライベートリポジトリを立てて、自分一人のプロジェクトを始めたというのもあります。実は17年ほど投資をやっていてExcel VBAで自作したツール群を使っていたのですが、これらをAWSとPythonで書き直していきました。いつかAWSに移行したいと7年くらい前から思ってたのになかなか着手できなかったプロジェクトを進めることができたのは自分にとっては大きかったです。このプロジェクトはこれからも続けていきたいですね。

来年のこのブログをどうするかは、仕事以外も含めて一通り目標を立ててその中でしっかり決めていきたいと思います。来年は2日かその翌週9日かが最初の記事になると思いますが、それまでに方針固めます。

それではみなさま、今年も1年間ありがとうございました。また来年もよろしくお願いいたします。

トレジャーデータ(Presto)でアクセスログをセッションごとにまとめる方法

前回の記事で紹介したテクニックの応用として、最後の方にちょろっとユーザーのアクセスログデータをセッションごとにまとめたりもできるって話を書きました。
参考: DataFrameを特定列の値が連続してる行ごとにグルーピングする方法

ただ、僕は普段アクセスを分析するときは、Pythonでななくて、トレジャーデータからデータを取ってくる時点でセッションIDを振っているので、自分がいつもやっている方法を紹介しておこうという記事です。トレジャーデータのウィンドウ関数をまとめて紹介したことがあったのでこれも紹介したつもりになってましたがまだでしたね。

使う関数は、 TD_SESSIONIZE_WINDOW というUDFです。名前がTD_で始まっていることから分かる通り、トレジャーデータ専用の関数です。
ドキュメント: Supported Presto and TD Functions – Product Documentation – Treasure Data Product Documentation

例がわかりやすいので、そのまま引用します。アクセスログにタイムスタンプ(time列)とIPアドレス(ip_address列)、アクセスされたパス(path列)があるデータに対して、IPアドレスごとに分けて、60分(=3600秒)間隔が空いてたら別セッションとしてセッションidをふるって操作をやりたい場合次のクエリになります。

SELECT
    TD_SESSIONIZE_WINDOW(time, 3600)
        OVER (PARTITION BY ip_address ORDER BY time)
    AS session_id,
    time,
    ip_address,
    path
FROM
    web_logs

TD_SESSIONIZE_WINDOW 関数に直接渡す引数は、セッションを区切るtimeスタンプの列(トレジャーデータなのでほぼ確実にtime列を使うことになると思います)と、セッションを区切る時間です。そして、ウィンドウ関数なので、OVERを使って、区切りやソート順を指定できます。区切りはIPアドレスだけでなくユーザーIDやデバイス情報はど複数指定することもできます。ソート順はほぼ自動的にtimeを使うことになるでしょうね。

結果として振られるsession_idはUUIDになるので、実行するたびに結果がわかります。ちょっとVALUEを使ってダミーデータ作ってお見せします。
結果がタイムスタンプになると説明しにくかったので、time_formatとして読めるようにした時刻列持つかしました。

-- 実行したクエリ
SELECT
    TD_SESSIONIZE_WINDOW(time, 3600)
        OVER (PARTITION BY ip_address ORDER BY time)
    AS session_id,
    time,
    TD_TIME_FORMAT(time, 'yyyy-MM-dd HH:mm:ss', 'JST') AS time_format,
    ip_address,
    path
FROM
-- 以下ダミーデータ
    (
        VALUES
            (TD_TIME_PARSE('2022-12-12 12:00:00', 'JST'), '127.0.0.x', './hoge1.html'),
            (TD_TIME_PARSE('2022-12-12 12:30:00', 'JST'), '127.0.0.x', './hoge2.html'),
            (TD_TIME_PARSE('2022-12-12 13:30:00', 'JST'), '127.0.0.x', './hoge3.html'),
            (TD_TIME_PARSE('2022-12-12 12:10:00', 'JST'), '127.0.0.y', './hoge1.html'),
            (TD_TIME_PARSE('2022-12-12 12:20:00', 'JST'), '127.0.0.y', './hoge2.html'),
            (TD_TIME_PARSE('2022-12-12 13:19:59', 'JST'), '127.0.0.y', './hoge3.html')
  ) AS t(time, ip_address, path)


-- 以下が出力
f6d83ca3-6f3b-4af8-be10-197a38074cd7	1670814600	2022-12-12 12:10:00	127.0.0.y	./hoge1.html
f6d83ca3-6f3b-4af8-be10-197a38074cd7	1670815200	2022-12-12 12:20:00	127.0.0.y	./hoge2.html
f6d83ca3-6f3b-4af8-be10-197a38074cd7	1670818799	2022-12-12 13:19:59	127.0.0.y	./hoge3.html
7c9f176f-950c-4b5e-a997-eaa0d8ed77ec	1670814000	2022-12-12 12:00:00	127.0.0.x	./hoge1.html
7c9f176f-950c-4b5e-a997-eaa0d8ed77ec	1670815800	2022-12-12 12:30:00	127.0.0.x	./hoge2.html
fa9cb3f0-0c3f-4dbd-9976-b10ea12d653e	1670819400	2022-12-12 13:30:00	127.0.0.x	./hoge3.html

まず、127.0.0.xからのアクセスと127.0.0.yからのアクセスには別のid振られていまね。yの方は間隔が最大でも3599秒しか離れていないので3アクセスが1セッションとして同じIDになっています。
一方で、xの方は、2回目と3回目のアクセスが3600秒離れているのでこれは別セッションとして扱われて、idが2種類になっています。
細かいですがこれは結構重要で、引数で指定した3600ってのは、3600未満までの感覚までしか同一セッションとは見なさないということになります。

さて、ここから応用です。

これ、Webページの個々のアクセスのような動作をセッションかする関数なのですが、少し工夫したら前回の記事で書いたようなタイムスタンプに限らないただの連番とか、あと、日付が連続してるかどうかによるグルーピングとかもできます。

ある特定のユーザーが何日連続で訪問してくれたかって集計とか、特定のコンテンツが何日連続で掲載されていたかといった集計にも使えますね。

例えば、WITH句か何かでユーザーさんがアクセスしてくれた日付のデータを作って、それをTD_TIME_PARSEでタイムスタンプに戻し、60*60*24+1=86401 を区切りにするとできます。

例えばこんな感じです。

-- 実行したクエリ
SELECT
    TD_SESSIONIZE_WINDOW(TD_TIME_PARSE(date), 86401)
        OVER (PARTITION BY user_id ORDER BY date)
    AS session_id,
    date,
    user_id
FROM
    (
        VALUES
            ('2022-12-04', 1),
            ('2022-12-05', 1),
            ('2022-12-06', 1),
            ('2022-12-08', 1),
            ('2022-12-09', 1),
            ('2022-12-05', 2),
            ('2022-12-06', 2)
  ) AS t(date, user_id)

-- 以下出力
321b325b-36eb-43c1-afcd-155cfe7fff8d	2022-12-05	2
321b325b-36eb-43c1-afcd-155cfe7fff8d	2022-12-06	2
2738c31e-79ca-4830-b20f-c48a1b14ef72	2022-12-04	1
2738c31e-79ca-4830-b20f-c48a1b14ef72	2022-12-05	1
2738c31e-79ca-4830-b20f-c48a1b14ef72	2022-12-06	1
5447acfb-0718-43a4-9d0a-4d714b79a7d1	2022-12-08	1
5447acfb-0718-43a4-9d0a-4d714b79a7d1	2022-12-09	1

ユーザーidが1の方を見ると、4,5,6日と8,9日で別のidが振られていますね。
86401 が重要で、ここを86400にすると全部バラバラのidになるので注意してください。

この、TD_SESSIONIZE_WINDOWを通常のWebアクセスのセッション化意外に使う使い方をトレジャーデータさんがどの程度想定してるのかが不明なので、なかなか推奨しにくいところではあるのですが、知っておくと便利な場面は結構あるので頭の片隅にでも置いといてください。

DataFrameを特定列の値が連続してる行ごとにグルーピングする方法

このブログでは何度も使っているのでお馴染みですが、pandasのDataFrameはgroupbyというメソッドを持っていて、特定列の値を基準にグループ化して各種集計を行えます。
今回はこれを、特定の列の値が等しいではなく、連続する整数によってグループ化したかったのでその方法を考えました。

具体的にいうと、例えば、[2, 3, 4, 6, 9, 10, 15, 16, 17, 18] というデータがあったときに、
[2, 3, 4], [6], [9, 10], [15, 16, 17, 18] というようにグループに分けたいわけです。

やり方はいろいろあると思いますし、自分も昔はfor文で上から順番にデータをみて2以上値が離れてたらそこで切る、みたいなやり方をしていましたが今回いい感じの方法を見つけたので紹介します。

サンプルとして次のようなDataFrameを作っておきます。(“foo”って列はただのダミーです。1列だけだとDataFrame感がなかったのでつけました。)

import pandas as pd


df = pd.DataFrame({
    "foo": ["bar"]*10,
    "values": [2, 3, 4, 6, 9, 10, 15, 16, 17, 18],
})

print(df)
"""
   foo  values
0  bar       2
1  bar       3
2  bar       4
3  bar       6
4  bar       9
5  bar      10
6  bar      15
7  bar      16
8  bar      17
9  bar      18
"""

これの、valuesの値が変わったところで切りたいのですが、次のようにしてshiftとcumsum(累積和)を使ってgroupごとにidを振ることができました。

df["group_id"] = (df["values"] != df["values"].shift()+1).cumsum()

print(df)
"""
   foo  values  group_id
0  bar       2         1
1  bar       3         1
2  bar       4         1
3  bar       6         2
4  bar       9         3
5  bar      10         3
6  bar      15         4
7  bar      16         4
8  bar      17         4
9  bar      18         4
"""

あとはこのgroup_id 列を使って groupby することで、連番をひとまとまりにした集計ができます。実務で遭遇した事例ではこの連番を使ってグルーピングしたあと、別の列が集計対象だったのですが今回のサンプルではとりあえずグルーピングしたvalues列でも集計して、最小値、最大値、件数、でも表示しておきましょう。

print(df.groupby("group_id")["values"].agg(["min", "max", "count"]))
"""
          min  max  count
group_id                 
1           2    4      3
2           6    6      1
3           9   10      2
4          15   18      4
"""

2~4とか15~18がグループになってるのがわかりますね。

これの少し応用で、値が3以上飛んだら別グループとして扱う、って感じのグループ化の閾値を変えることも簡単にできます。

df["group_id"] = (df["values"] - df["values"].shift() >= 3).cumsum()

print(df)
"""
   foo  values  group_id
0  bar       2         0
1  bar       3         0
2  bar       4         0
3  bar       6         0
4  bar       9         1
5  bar      10         1
6  bar      15         2
7  bar      16         2
8  bar      17         2
9  bar      18         2
"""

これを数値ではなくタイムスタンプで行うと、ユーザーのアクセスログデータに対して30分以内で連続したアクセスをひとまとまりとして扱う、といったセッション化のような集計を実装することもできます。意外と応用の幅が広いテクニックなので、機会があれば使ってみてください。

numpyのtileとついでにrepeatを紹介

numpyのarrayを繰り返して並べることによって新しいarrayを生成するnumpy.tileって関数があるのでその紹介です。また、名前が紛らわしいのですが全く違う挙動をするnumpy.repeatって関数もあるのでついでにそれも紹介します。

tileの方は、先日時系列データの季節分解のアルゴリズムを紹介した記事の中でこっそり使いました。
参考: statsmodelsの季節分解で実装されているアルゴリズム

それぞれの関数のドキュメントは以下です。
numpy.tile — NumPy v1.23 Manual
numpy.repeat — NumPy v1.23 Manual

さて、何か元になる配列があってそれを繰り返して何か新しい配列を作ると言う操作はnumpyのarrayよりPythonの標準のlistの方がやりやすいと言う珍しい操作になります。とりあえずリストでの挙動見ておきましょうかね。一方で同じ実装をnumpyでやると挙動が変わってしまうことも。

import numpy as np


list_sample = [0, 1, 2]
# list は * (積)で繰り返しを作れる
print(list_sample * 3)
# [0, 1, 2, 0, 1, 2, 0, 1, 2]

# 縦に繰り返したい場合 [] で囲んでから3倍
print([list_sample] * 3)
# [[0, 1, 2], [0, 1, 2], [0, 1, 2]]

# mumpyでやると要素への積になってしまう。
ary = np.array([0, 1, 2])
print(ary * 3)
# [0 3 6]

arrayは積で連結できないとはいえ、listメソッドでarrayをlistに変換しちゃったら済む話なので、何がなんでもnumpyのメソッドでやらなきゃいけないってことはないのですが、せっかく用意されているのがあるので使い方を覚えておくと便利です。

そして、それを実装するnumpyの関数ですが、僕は完全にnp.repeatがそれだと勘違いしていました。しかしこのrepeat、要素をそれぞれ繰り返す、という挙動をするので期待してたのと全く違う動きするのですよね。ただ、こう言うメソッドがあるんだと知っていれば使える場面もあるかもしれないので先に見ておきます。

ary = np.array([0, 1, 2])
# 元のarrayと繰り返したい回数を渡す。
print(np.repeat(ary, 3))
# [0 0 0 1 1 1 2 2 2]

いかがでしょう。大体上記の例でイメージ掴めたでしょうか。

このrepeatは2次元以上のarrayに対しても使えます。その際、axisという引数で繰り返し方を指定できるのでちょっと見ていきますね。

ary_2d = np.array([[0, 1, 2], [3, 4, 5]])
print(ary_2d)  # 元のデータを表示しておく
"""
[[0 1 2]
 [3 4 5]]
"""
print(np.repeat(ary_2d, 2))  # axis指定無しだと1次元に変換してから要素を繰り返す
"""
[0 0 1 1 2 2 3 3 4 4 5 5]
"""

print(np.repeat(ary_2d, 2, axis=0))
"""
[[0 1 2]
 [0 1 2]
 [3 4 5]
 [3 4 5]]
"""

print(np.repeat(ary_2d, 2, axis=1))
"""
[[0 0 1 1 2 2]
 [3 3 4 4 5 5]]
"""

axis を省略した場合(Noneを渡すと同じ)の場合と、axis=0の場合で結果が違うのも要注意ですね。axisに渡した値と結果の関係がイメージつきにくいですが、元のshapeが(2, 3)だったのが、axis=0だと(4, 3)に、axis=1だと(2, 6)にと、axisで指定した次元が繰り返し回数倍になると考えるとわかりやすいです。

さて、repeatが要素の繰り返しであって配列の繰り返しではない、と言うのをここまでみてきました。

では配列の繰り返しはどうやるのかとなったときに使えるのがtileです。これがlistへの整数の掛け算と同じような挙動をしてくれます。これ繰り返し回数を整数ではなくタプルで指定することで別次元への繰り返しもできます。

# tile で 指定回数arrayを繰り返したarrayを生成できる
print(np.tile(ary, 3))
# [0 1 2 0 1 2 0 1 2]

# 繰り返し回数はタプルでも指定でき、新しい軸方向への繰り返しもできる。
print(np.tile(ary, (3, 1)))
"""
[[0 1 2]
 [0 1 2]
 [0 1 2]]
"""

# タプルで指定する例2つ目
print(np.tile(ary, (2, 3)))
"""
[[0 1 2 0 1 2 0 1 2]
 [0 1 2 0 1 2 0 1 2]]
"""

2次元以上のarrayに対しても使えます。名前通りタイル貼りのような動きをするのでこちらの方がイメージしやすいかもしれませんね。ちなみに画像データに対してこれを使うと元の画像を繰り返す画像が作れたりします。

print(ary_2d)  # 元のデータ
"""
[[0 1 2]
 [3 4 5]]
"""

# 整数で繰り返しを指定した場合
print(np.tile(ary_2d, 3))
"""
[[0 1 2 0 1 2 0 1 2]
 [3 4 5 3 4 5 3 4 5]]
"""

# タプルで指定した場合
print(np.tile(ary_2d, (3, 1)))
"""
[[0 1 2]
 [3 4 5]
 [0 1 2]
 [3 4 5]
 [0 1 2]
 [3 4 5]]
"""

# タプルで指定した場合その2。タプル(1, 3)と整数で3と指定するのが同じ挙動
print(np.tile(ary_2d, (1, 3)))
"""
[[0 1 2 0 1 2 0 1 2]
 [3 4 5 3 4 5 3 4 5]]
"""

# もちろん、タプルでは1以外の数値も使える
print(np.tile(ary_2d, (2, 3)))
"""
[[0 1 2 0 1 2 0 1 2]
 [3 4 5 3 4 5 3 4 5]
 [0 1 2 0 1 2 0 1 2]
 [3 4 5 3 4 5 3 4 5]]
"""

以上で、repeatとtileの紹介を終えます。
どちらを使うかであったり、繰り返し方向の指定などを間違えがちだと思うので、よく確認しながら使いましょう。

ipywidgetsのDropdownやSliderで値を変えたときに関数を実行する

jupyterでウィジェット(ipywidgets)を使う記事の4記事目くらいです。1個は実例紹介みたいなやつなので使い方の記事としては3記事目になります。
1記事目: Jupyter Notebookでインタラクティブに関数を実行する
2記事目: Jupyter Notebook でボタンを使う

ボタンの使い方紹介したし他のUIも似たような感じで使えるやろって思い込んで放置していたのと、Sider等でぐりぐり操作したい場合は1記事目のinteractで十分なケースが多かったので触れてきませんでしたが、最近ある用途でipywidgets.IntSliderを使ったとき、思ったような動きをせずに苦戦しました。

先に結論を書いておくと、SliderやDropdownをインタラクティブに使いたいならobserveってメソッドに実行したい関数をセットし、names引数に”value”を渡して値の変更だけ監視するようにします。この記事ではDropdownとSlider (IntSlider/ FloatSlider) を例に取り上げますが、他のトグルボタンとかテキストボックス等でも事情は同じです。

さて、結論先に書いちゃいましたが自分が何に苦戦したのかを書いておきます。まず、Buttonを使うときは、インスタンスのon_clickメソッドにクリックしたときに実行したいメソッドを渡せば動作がセットされて、押すたびにそれが実行されるのでした。
なので、どうせSliderにはon_changeみたいなメソッドがあるんだろ、ってことで探すとon_trait_changeってメソッドが見つかります。で、これをやるとDeprecationWarningが出ます。今はobserveを使えということらしいです。

from ipywidgets import IntSlider
from IPython.display import display


def print_value():
    print(int_slider.value)


int_slider = IntSlider(min=0, max=100, step=10, value=50)
int_slider.on_trait_change(print_value)
display(int_slider)

# 以下出力される警告文
"""
/var/folders/g1/l4hsxb_54gsc0zgyczfb_xvm0000gn/T/ipykernel_1150/2385673427.py:9: DeprecationWarning: on_trait_change is deprecated in traitlets 4.1: use observe instead
  int_slider.on_trait_change(print_value)
"""

じゃぁ、observeを使うとどうなるかというと、次はスライダーを動かしたときにエラーが出ます。

def print_value():
    print(int_slider.value)


int_slider = IntSlider(min=0, max=100, step=10, value=50)
int_slider.observe(print_value)
display(int_slider)

# これでIntSliderは表示されるが、動かすと以下のエラーが出る
"""
TypeError: print_value() takes 0 positional arguments but 1 was given
"""

observeに渡すメソッドは引数を一個受け取るようです。ドキュメントを見ると変更に関する情報を関数に渡してくれるようですね。ありがたい。ちょっとその引数で渡される情報をprintするようにしてみましょう。

def print_value(change):
    print(change)


int_slider = IntSlider(min=0, max=100, step=10, value=50)
int_slider.observe(print_value)
display(int_slider)

これでSliderが表示されるのですが、値をちょっと変えると、なんかセットした関数(print_value)が3回実行されるのですよ。

ただ、chengeって引数にoldとnewってキーで新旧の値が入るのは便利ですね。ドキュメントを見ると、値が変わったときに一回だけ動かしたいなら、names=’value’って指定すると良いようです。上の画像で言うところの’name’: ‘_property_lock’ の変更はこれで出てこなくなります。

また、Sliderのような連続的に値を変えるUIは、例えば50から100へ値を変えようとすると途中の60,70,80なども通過します。ここで全部発火すると大変だ、最後に止まったところでだけ動いたらいい、と言う場合は、ウィジェットのインスタンス作るときにcontinuous_update=Falseを指定すると良いです。
結果コードは以下のようになります。

def print_value(change):
    print(change["old"], "から", change["new"], "に変化しました。")


int_slider = IntSlider(min=0, max=100, step=10, value=50, continuous_update=False)
int_slider.observe(print_value, names="value")
display(int_slider)

結果は省略しますが、Dropdownなどの他のウィジェットも同じようにして値の変化を検知できます。

from ipywidgets import Dropdown


drop_down = Dropdown(options=["high", "middle", "low"])
drop_down.observe(print_value, names="value")
display(drop_down)

Dropdownはvalueだけなく、labelやindexも変化するので、names=”value”を指定しない場合は、5回メソッドが実行されますね。用途によってはnames=”index”とか”label”などの方が使いやすい場面もあると思いますので確認しながら使ってみてください。

statsmodelsの季節分解で実装されているアルゴリズム

前回に続いて、statsmodelsの季節分解の話です。
参考(前回の記事): statsmodelsを利用した時系列データの季節分解のやり方

前回の記事は使い方でしたが、今回はソースコードを参照しながらどのような計算方法で季節分解が実装されているのかを見ていきます。
ちなみに、僕の環境のバージョンは以下の通りです。将来のバージョンでは仕様が変わる可能性もあるのでご注意ください。

$ pip freeze | grep statsmodels
> statsmodels==0.13.2

ドキュメントにソースコードが載ってるページがあるので、そこを参照します。
ソース: statsmodels.tsa.seasonal — statsmodels

ソースの先頭に以下のようにコメントが書かれている通り、移動平均を使って実装されています。

"""
Seasonal Decomposition by Moving Averages
"""

利用するデータですが、前回の記事と同じ、このブログのPVです。

# データ件数
print(len(df))
# 140

# 2週間分のデータ
print(df.tail(14))
"""
              pv
date
2022-10-24  2022
2022-10-25  2140
2022-10-26  2150
2022-10-27  1983
2022-10-28  1783
2022-10-29   847
2022-10-30   793
2022-10-31  1991
2022-11-01  2104
2022-11-02  1939
2022-11-03  1022
2022-11-04  1788
2022-11-05   830
2022-11-06   910
"""

それでは順番にやっていきましょう。モデルは加法モデルの方を取り上げます。乗法モデルもトレンド成分を抽出するところまでは一致していますし、その後の処理から異なりますが加法と乗法の違いを考えれば普通に理解できると思います。

そして、ここが重要なのですが、まず周期が奇数の場合を見ていきます。今回は1週間である7日です。なんでこれが重要かというと、偶数の場合は少し挙動が特殊だからです。

季節分解では、元の時系列データからトレンド成分と季節成分を取り出します(残りが残差)が、処理の順番もこの順番になっていて、最初にトレンド成分、次に季節成分を取り出すようになっています。これ順番逆にすると結果変わりますし、それぞれメリットデメリットあるのですがトレンド成分を先にFIXすることを選ばれているようです。

では、トレンド成分の抽出を見ていきましょう。これ、”by Moving Averages”のコメント通り、移動平均です。two_sided=True (デフォルト)の設定場の場合、その対象日を挟むようにして、前後の期間から取った移動平均をその日のトレンド成分の値とします。

7日だったら、3日前,2日前,1日前,当日,1日後,2日後,3日後 の平均を使います。

pandasで計算するなら、rolling()で移動平均取って、shift()でずらすと同じ値が得られます。
モデルで取り出したトレンド成分と、pandasで自分で計算した値見てみましょう。

# モデルで計算
from statsmodels import api as sm


decompose_result = sm.tsa.seasonal_decompose(
    df["pv"],
    period=7,
)
print(decompose_result.trend)
"""
date
2022-06-20            NaN
2022-06-21            NaN
2022-06-22            NaN
2022-06-23    1409.857143
2022-06-24    1408.714286
                 ...     
2022-11-02    1495.285714
2022-11-03    1512.000000
2022-11-04            NaN
2022-11-05            NaN
2022-11-06            NaN
Name: trend, Length: 140, dtype: float64
"""

# pandasで計算。7日移動平均をとって、3日シフトする
print(df["pv"].rolling(7).mean().shift(-3))
"""
date
2022-06-20            NaN
2022-06-21            NaN
2022-06-22            NaN
2022-06-23    1409.857143
2022-06-24    1408.714286
                 ...     
2022-11-02    1495.285714
2022-11-03    1512.000000
2022-11-04            NaN
2022-11-05            NaN
2022-11-06            NaN
Name: pv, Length: 140, dtype: float64
"""

データの大部分…て略されてますが、これらは一致します。(極小値の誤差はあり得ますが)

two_sided = False とすると、前後のデータではなくその日含めた過去のデータだけ使われるようになるので、6日前〜当日のデータでの移動平均になります。

比較できるように、最初の2個の値が見れるようにprintしました。先頭のNaNの数が6個になってますね。表示てませんが、代わりに末尾のNaNは無くなります。

decompose_result = sm.tsa.seasonal_decompose(
    df["pv"],
    period=7,
    two_sided=False
)
print(decompose_result.trend.iloc[: 8])
"""
date
2022-06-20            NaN
2022-06-21            NaN
2022-06-22            NaN
2022-06-23            NaN
2022-06-24            NaN
2022-06-25            NaN
2022-06-26    1409.857143
2022-06-27    1408.714286
Name: trend, dtype: float64
"""

さて、トレンド成分の計算方法はわかったので、季節成分の話に戻ります。モデルも two_sided=Trueの最初の例の方に話戻してこちらで進めます。(この記事真似して再現する人は上のtwo_sided=Falseのコードブロックをスキップして実行してください)

季節成分は、トレンド成分を取り除き終わったデータから計算します。(最初と最後の数件のデータはNaNになっているのでこれらは使いません。)
説明が難しいのですが、今回みたいに曜日ごとの周期であれば、月曜の平均、火曜の平均、水曜の平均と、周期分の平均値を取り出し、さらにこうして計算した平均値たちからその平均を引いて標準化します。

言葉で書くとわかりにくいのでコードでやってみます。

import numpy as np


# 先述のトレンド成分の計算
df["pv_trend"] = df["pv"].rolling(7).mean().shift(-3)

# トレンド成分を取り除く
df["pv_detrended"] = df["pv"] - df["pv_trend"]

# 曜日ごとの平均を取る
period_averages = np.array([df["pv_detrended"][i::7].mean() for i in range(7)])
print(period_averages)
"""
[ 243.2556391   397.69172932  341.57894737  257.54285714  139.98496241
 -720.2481203  -675.17293233]
"""

# 曜日ごとの平均から、さらにそれらの平均を引く
period_averages -= period_averages.mean()
print(period_averages)
"""
[ 245.450913    399.88700322  343.77422127  259.73813104  142.18023631
 -718.0528464  -672.97765843]
"""

# 比較用。statsmodelsが算出した季節成分
decompose_result = sm.tsa.seasonal_decompose(
    df["pv"],
    period=7,
)
print(decompose_result.seasonal[: 7])
"""
date
2022-06-20    245.450913
2022-06-21    399.887003
2022-06-22    343.774221
2022-06-23    259.738131
2022-06-24    142.180236
2022-06-25   -718.052846
2022-06-26   -672.977658
Name: seasonal, dtype: float64
"""

データの形式が違いますが、モデルの結果と値が一致してるのがわかりますね。

以上で、トレンド成分と季節成分が計算できました。あと、statsmodelsは残差を計算できますがこれは単にトレンド成分と周期成分を元のデータから取り除いてるだけです。 df[“pv_detrended”] はトレンド成分除去済みなのでここから季節成分も引いてみます。

# np.tile で同じ値を繰り返す配列を作って引く
print(df["pv_detrended"] - np.tile(period_averages, 20))
"""
date
2022-06-20           NaN
2022-06-21           NaN
2022-06-22           NaN
2022-06-23     74.404726
2022-06-24     36.105478
                 ...    
2022-11-02     99.940064
2022-11-03   -749.738131
2022-11-04           NaN
2022-11-05           NaN
2022-11-06           NaN
Name: pv_detrended, Length: 140, dtype: float64
"""

# モデルの残差
print(decompose_result.resid)
"""
date
2022-06-20           NaN
2022-06-21           NaN
2022-06-22           NaN
2022-06-23     74.404726
2022-06-24     36.105478
                 ...    
2022-11-02     99.940064
2022-11-03   -749.738131
2022-11-04           NaN
2022-11-05           NaN
2022-11-06           NaN
Name: resid, Length: 140, dtype: float64
"""

以上が、statsmodelsにおける seasonal_decompose の実装の説明になります。

さて、ここからはちょっと補足で、周期が偶数の場合の挙動になります。月次データだと12を使うことが多いので周期が偶数というのはよくあるケースです。
説明をコンパクトにするために、周期(period=)4を例に取り上げます。

周期7の時、3日前から3日後までのデータの平均でトレンド成分を取り出してましたが、こういう期間の取り方ができるのは周期が奇数だったからで、偶数だとこうはいきません。

では、どうするのかというと、例えば周期が4日だったら、前日から翌日までの3日分のデータはそのまま使い、2日前と2日後のデータをそれぞれ0.5倍したものを計算に入れます。

# 周期4の場合のトレンド成分
decompose_result = sm.tsa.seasonal_decompose(
    df["pv"],
    period=4,
)
print(decompose_result.trend.iloc[: 5])
"""
date
2022-06-20         NaN
2022-06-21         NaN
2022-06-22    1719.750
2022-06-23    1567.250
2022-06-24    1299.125
Name: trend, dtype: float64
"""

# 以下、 2022-06-22    1719.750 が算出されるまでの計算式
# 元のデータ
df["pv"].iloc[: 5]
"""
date
2022-06-20    1703
2022-06-21    1758
2022-06-22    1732
2022-06-23    1744
2022-06-24    1587
Name: pv, dtype: int64
"""

# 計算 2日前〜2日後の5日分のデータから算出。ただし、最初と最後の日はウェイトが0.5
(1703*0.5 + 1758 + 1732 + 1744 + 1587*0.5)/4
# 1719.75

rolling()とshift()では同じ値を得られなかったので戸惑ったこともありましたが、まぁ、仕掛けがわかってしまえば簡単ですね。その日を挟んだ4日分のデータを考慮する手段としても一定の妥当性があると思います。

で、ここからが僕的には納得がいってないのですが、two_sided=Falseで周期を偶数(今の例では4)にした場合の挙動は少し不思議です。その日を含む過去4日分のデータを使えるので、単純に4日間の平均を取ればいいのに、そういう実装になっておらず、two_sided=Trueの場合の結果を単純にshiftしたものを使っています。要するに4日前の0.5倍と3日前から1日前、そして当日の0.5倍のデータを使ってます。two_sided=Falseにしたコードが以下ですが、上のTrue(省略した場合の値)と値が一致しているのがわかると思います。

decompose_result = sm.tsa.seasonal_decompose(
    df["pv"],
    period=4,
    two_sided=False,
)
print(decompose_result.trend.iloc[: 7])
"""
date
2022-06-20         NaN
2022-06-21         NaN
2022-06-22         NaN
2022-06-23         NaN
2022-06-24    1719.750
2022-06-25    1567.250
2022-06-26    1299.125
Name: trend, dtype: float64
"""

ここの挙動だけは将来のバージョンで修正されるのではと思っているのですが、
一旦今はこういう作りになっているということを頭に置いた上で気をつけて使うしかないようです。

statsmodelsを利用した時系列データの季節分解のやり方

要するに、seasonal_decomposeメソッドの使い方の紹介記事です。これもとっくに書いたと思っていたら書いてなかったのでまとめておきます。この記事では季節分解の概要の説明とライブラリの使い方を紹介します。そして、これの次の記事でstatsumodelsがどのような実装で季節分解を行っているのかを解説する予定です。
参考: statsmodels.tsa.seasonal.seasonal_decompose — statsmodels

現実の時系列データは、何かしらの季節性を持っていることが多くあります。季節って単語で言うと、春夏秋冬や、何月、といった粒度のものが想像されやすいですが、1週間の中で見ても曜日の傾向とか、1日の中でも時間帯別の違いなどがあります。

時系列データからこの季節に依存する部分を取り出し、季節成分と、トレンド成分、そして残差へと分解する手法が今回紹介する季節分解です。Wikipediaでは季節調整と書いてあります。また、基本成分など、別の用語を使ってる人もいるようです。(どれが一番メジャーなんだろう。)

定式化しておくと、元の時系列データ$Y_{t}$をトレンド成分$T_t$と季節成分$S_t$、そして残差$e_t$を用いて、
$$
Y_t = T_t + S_t + e_t
$$
と分解することを目指します。上記のは加法モデルと呼ばれる形で、和の代わりに積で分解する乗法モデル、
$$
Y_t = T_t * S_t * e_t
$$
もあります。

季節成分$S_t$は周期性を持っているので、その周期を$p$とすると、$S_{t}=S_{t+p}$を満たします。

具体的に例を見るのが早いと思うので、やっていきましょう。サンプルとして用意したデータはこのブログのpv数です。インデックスを日付けにしていますが、こうしておくとライブラリでplotしたときにx軸に日付が表示されるで便利です。ただ、通常の通し番号のindexでも動きます。

# データ件数
print(len(df))
# 140

# 2週間分のデータ
print(df.tail(14))
"""
              pv
date            
2022-10-24  2022
2022-10-25  2140
2022-10-26  2150
2022-10-27  1983
2022-10-28  1783
2022-10-29   847
2022-10-30   793
2022-10-31  1991
2022-11-01  2104
2022-11-02  1939
2022-11-03  1022
2022-11-04  1788
2022-11-05   830
2022-11-06   910
"""

最後に元のデータのグラフも出るので可視化しませんが、上の例見ても10/29,30や11/5,6など土日にpvが減っていて逆に平日多く、曜日ごと、つまり7日周期がありそうな想像がつきます。詳細省略しますが、自己相関等で評価してもはっきりとその傾向が出ます。

では、やってみましょう。まず、分解自体はライブラリにデータを渡して周期を指定するだけです。

from statsmodels import api as sm


decompose_result = sm.tsa.seasonal_decompose(
    df["pv"],
    period=7,  # 周期を指定する
)

結果は以下のプロパティに格納されています。DecomposeResultというデータ型で、ドキュメントはこちらです。
参考: statsmodels.tsa.seasonal.DecomposeResult — statsmodels

順番に表示していきます。

# データの数。
decompose_result.nobs[0]
# 140

# 元のデータ
print(decompose_result.observed[: 10])
"""
date
2022-06-20    1703.0
2022-06-21    1758.0
2022-06-22    1732.0
2022-06-23    1744.0
2022-06-24    1587.0
2022-06-25     654.0
2022-06-26     691.0
2022-06-27    1695.0
2022-06-28    1740.0
2022-06-29    1655.0
Name: pv, dtype: float64
"""
# トレンド成分
print(decompose_result.trend[: 10])
"""
date
2022-06-20            NaN
2022-06-21            NaN
2022-06-22            NaN
2022-06-23    1409.857143
2022-06-24    1408.714286
2022-06-25    1406.142857
2022-06-26    1395.142857
2022-06-27    1370.714286
2022-06-28    1345.285714
2022-06-29    1347.142857
Name: trend, dtype: float64
"""

# 季節成分
print(decompose_result.seasonal[: 14])
"""
date
2022-06-20    245.450913
2022-06-21    399.887003
2022-06-22    343.774221
2022-06-23    259.738131
2022-06-24    142.180236
2022-06-25   -718.052846
2022-06-26   -672.977658
2022-06-27    245.450913
2022-06-28    399.887003
2022-06-29    343.774221
2022-06-30    259.738131
2022-07-01    142.180236
2022-07-02   -718.052846
2022-07-03   -672.977658
Name: seasonal, dtype: float64
"""
# 残差
print(decompose_result.resid[: 10])
"""
date
2022-06-20          NaN
2022-06-21          NaN
2022-06-22          NaN
2022-06-23    74.404726
2022-06-24    36.105478
2022-06-25   -34.090011
2022-06-26   -31.165199
2022-06-27    78.834801
2022-06-28    -5.172718
2022-06-29   -35.917078
Name: resid, dtype: float64
"""

トレンド成分が最初の3項NaNになっているのは、アルゴリズムの都合によるものです。その日を中心とする前後で合計7日(周期分)のデータで移動平均をとっており、要するに、過去の3日、当日、次の3日間、の合計7日分のデータがないと計算できないので最初の3日と、表示していませんが最後の3日間はNaNになっています。この辺の挙動は推定時の引数で調整できます。

次に季節成分は14日分printしましたが、最初の7日間の値が繰り返されて次の7日間でも表示されているのがわかると思います。ずっとこの繰り返しです。

残差は元のデータからトレンド成分と季節成分を引いたものになります。トレンド成分や季節成分に比べて値が小さくなっていて、今回のデータではトレンドと季節である程度分解が綺麗に行えたと考えられます。

さて、データが取れたのでこれを使えばmatplotlib等で可視化できるのですが、大変ありがたいことにこのDecomposeResultが可視化のメソッドを持っています。
少し不便なところは、そのplotメソッドがfigsizeとかaxとかの引数を受け取ってくれないので、微調整とかしにくいのですよね。
個人的な感想ですが、デフォルトでは少しグラフが小さいのでrcParamsを事前に変更してデフォルトのfigsizeを大きくし、それで可視化します。

import matplotlib.pyplot as plt
#  注: DecomposeResult.plot()を実行するだけなら matplotlibのimportは不要。
#       今回画像サイズを変えるためにインポートする

# figure.figsizeの元の設定を退避しておく
figsize_backup = plt.rcParams["figure.figsize"]

# 少し大きめの値を設定
plt.rcParams["figure.figsize"] = [10, 8] 

# 可視化
decompose_result.plot()
plt.show()

# 設定を元に戻す
plt.rcParams["figure.figsize"] = figsize_backup

これで出力される画像が以下です。

一番上が元のpvです。冒頭に書いた通り、生データそのまま見ても周期性が明らかですね。

トレンド成分を見ていくと、お盆の時期にアクセスが減っていますが、その後順調にアクセスが伸びていることがわかりますね。

残差で見ると大きくアクセスが減っているのはそれぞれ祝日に対応しています。
8月に1日だけ異常にアクセス伸びた日がありますがこれは謎です。

説明や例をいろいろ書いてきたので長くなりましたが、基本的には、seasonal_decompose()で分解して、plot()で可視化してそれで完成という超お手軽ライブラリなので、時系列データが手元にあったらとりあえず試す、くらいの温度感で使っていけると思います。

最後に、seasonal_decompose にオプション的な引数が複数あるので使いそうなものを説明してきます。

まず、model= は、 “additive”,”multiplicative” の一方をとり、加法的なモデルか乗法的なモデルを切り替えることができます。デフォルトは、”additive”です。

two_sided= は True/Falseの値をとり、デフォルトはTrueです。これはトレンド成分の抽出方法を指定するもので、Trueの時は、その日を挟むように前後の日付から抽出しますが、Falseの場合は、その日以前の値から算出されます。True or False で並行移動するイメージです。
次回の記事で詳細書こうと思いますが、周期が偶数か奇数かで微妙に異なる挙動をするので注意が必要です。

extrapolate_trend= はトレンド成分の最初と最後の欠損値を補完するための引数です。1以上の値を渡しておくと、その件数のデータを使って最小二乗法を使って線形回帰してトレンドを延長し、NaNをなくしてくれます。使う場合はその回帰が妥当かどうか慎重にみて使う必要がありそうです。

pprintでデータを整形して出力する

前回の記事がtextwrapだったので、文字列の見栄えを整えるつながりで今回はpprintを紹介しようと思います。
参考: pprint — データ出力の整然化 — Python 3.11.0b5 ドキュメント

自分はもっぱらdictやlistの表示に使うのですが、ドキュメントを見ると任意のデータ構造に使えるようなことが書いてありますね。

使い方は簡単で、printすると結果が少しちょっと見にくくなるようなdict等のデータを渡すだけです。値が少し長いデータを使ってprintと見比べてみます。

import pprint


# サンブルデータ作成
sample_data = {
    1: "1つ目のキーの値",
    2: "2つ目のキーの値",
    3: "3つ目のキーの値",
    4: "4つ目のキーの値",
    5: "5つ目のキーの値",
    6: "6つ目のキーの値",
    7: "7つ目のキーの値",
}
# printした結果
print(sample_data)
"""
{1: '1つ目のキーの値', 2: '2つ目のキーの値', 3: '3つ目のキーの値', 4: '4つ目のキーの値', 5: '5つ目のキーの値', 6: '6つ目のキーの値', 7: '7つ目のキーの値'}
"""

# pprintした結果
pprint.pprint(sample_data)
"""
{1: '1つ目のキーの値',
 2: '2つ目のキーの値',
 3: '3つ目のキーの値',
 4: '4つ目のキーの値',
 5: '5つ目のキーの値',
 6: '6つ目のキーの値',
 7: '7つ目のキーの値'}
"""

pformat というメソッドもあって、こちらを使うと整形したものをprintするのではなく文字列として返してくれます。一応試しますが、文字列で戻ってきてるのをみないといけないので一旦変数に格納して通常のprintで出力します。

p_str = pprint.pformat(sample_data)
# 結果確認
print(p_str)
"""
{1: '1つ目のキーの値',
 2: '2つ目のキーの値',
 3: '3つ目のキーの値',
 4: '4つ目のキーの値',
 5: '5つ目のキーの値',
 6: '6つ目のキーの値',
 7: '7つ目のキーの値'}
"""

さて、このpprintですが、基本的にはそのまま使えば十分なのですが細かい調整ができるようにいろんな引数を取れます。

例えば、 indent= (デフォルト1)でインデントの文字数を指定できますし、width= (デフォルト80)で、横幅の文字数の最大値を指定できます。ただしwidthはベストエフォートでの指定なので、データによっては収めることできずにはみ出します。ちょっとwidthの指定によって結果が変わる例も見ておきましょう。さっきのdictはwidthが大きくても改行されたので、もう少しコンパクトなのを使います。

sample_data_mini = {
    1: '1つ目のキーの値',
    2: '2つ目のキーの値',
    3: '3つ目のキーの値',
}

# 80文字に収まるので、width未指定だと1行で出力
pprint.pprint(sample_data_mini, indent=4)
"""
{1: '1つ目のキーの値', 2: '2つ目のキーの値', 3: '3つ目のキーの値'}
"""

# width   が小さいと収まるように改行される。
pprint.pprint(sample_data_mini, indent=4, width=30)
"""
{   1: '1つ目のキーの値',
    2: '2つ目のキーの値',
    3: '3つ目のキーの値'}
"""

また、データの構造によっては、辞書やリスト、タプルの入れ子になっていることもあると思います。そのようなとき、depthという引数を指定することにより何階層目まで出力するか指定することもできます。オーバーした分は省略記号… になります。ドキュメントのサンプルでちょっとやってみます。

tup = ('spam', ('eggs', ('lumberjack', ('knights',
       ('ni', ('dead', ('parrot', ('fresh fruit',))))))))

# depth未指定
pprint.pprint(tup, width=20)
"""
('spam',
 ('eggs',
  ('lumberjack',
   ('knights',
    ('ni',
     ('dead',
      ('parrot',
       ('fresh '
        'fruit',))))))))
"""

# depth=3を指定
pprint.pprint(tup, width=20, depth=3)
"""
('spam',
 ('eggs',
  ('lumberjack',
   (...))))
"""

何かAPIとか叩いて巨大なJSONが帰ってきたとき、中身を確認するのに先立って上の階層のkeyだけちょっと見たい、って場面で非常に便利です。

このほかにも、辞書の出力をするときにkeyでソートしてくれるsort_key= (デフォルトでTrue)や、widthの範囲に収まるならばできるだけ1行にまとめてくれるcompact= (デフォルトでTrue)などのオプションもあります。正直のこの二つはわざわざFalseを指定することはないかなと追うので結果は省略します。

Pythonで複数行の文字列の行頭の空白を削除する

textwrapという標準ライブラリを最近知り、その中にdedentという便利なメソッドがあったのでその紹介です。
参考: textwrap — テキストの折り返しと詰め込み — Python 3.11.0b5 ドキュメント

ドキュメントのページタイトルにある通り、本来は長いテキストを折り返すためのライブラリです。

さて、Pythonでは基本的な技術ですが、三重引用符(“””か、”’)で囲むことによって、複数行のテストオブジェクトを生成できます。
参考: テキストシーケンス型

これをやるときに、コードの見た目をきれいにするためにインデントをつけると、こんな感じになってしまいます。(あくまでも例として出してるサンプルコードであって、走れメロスの本文を属性に持つクラスを作りたかったわけではありません。)

class foo():
    def __init__(self):
        self.text = """
            メロスは激怒した。
            必ず、かの邪智暴虐の王を除かなければならぬと決意した。
            メロスには政治がわからぬ。
            メロスは、村の牧人である。
            笛を吹き、羊と遊んで暮して来た。
            けれども邪悪に対しては、人一倍に敏感であった。
        """


obj = foo()
print(obj.text)
# 以下出力

            メロスは激怒した。
            必ず、かの邪智暴虐の王を除かなければならぬと決意した。
            メロスには政治がわからぬ。
            メロスは、村の牧人である。
            笛を吹き、羊と遊んで暮して来た。
            けれども邪悪に対しては、人一倍に敏感であった。
        

これをやると、各行の先頭に要らない半角スペースが入ってしまいます。上記のコードの例であれば各行12個入ってます。ついでに前後に不要な改行があり、空白行がそれぞれできています。これを避けるには次のように書かなければいけません。

class bar():
    def __init__(self):
        self.text = """メロスは激怒した。
必ず、かの邪智暴虐の王を除かなければならぬと決意した。
メロスには政治がわからぬ。
メロスは、村の牧人である。
笛を吹き、羊と遊んで暮して来た。
けれども邪悪に対しては、人一倍に敏感であった。"""


obj = bar()
print(obj.text)
# 以下出力
メロスは激怒した。
必ず、かの邪智暴虐の王を除かなければならぬと決意した。
メロスには政治がわからぬ。
メロスは、村の牧人である。
笛を吹き、羊と遊んで暮して来た。
けれども邪悪に対しては、人一倍に敏感であった。

上のコードくらい短ければいいのですが、長いコードにこういうのが入ると非常に不恰好です。実際日本語の文章がこんなダラダラコード中にハードコーディングされることは滅多にないのですがそれはさておき。

ここで、先述のtextwrap.dedentを使うと、そのメソッドが行頭の空白を消してくれます。

良い点でもあるのですが、テキスト中の「各行に共通する空白」だけ消します。空白が4個の行と8個の行が混在していたら、各行から4個消えて、元々8個存在してた行には4個スペースが残るので、相対的なインデントは保持されるということです。これは結構良い仕様です。

ちなみに、前後の改行コードは消してくれないので、それはそれで、strip()から何かで消します。

これを使うと次のようになります。

import textwrap


# 最初のコード例のクラスのインスタンスで実験
print(textwrap.dedent(obj.text).strip())
# 以下出力
メロスは激怒した。
必ず、かの邪智暴虐の王を除かなければならぬと決意した。
メロスには政治がわからぬ。
メロスは、村の牧人である。
笛を吹き、羊と遊んで暮して来た。
けれども邪悪に対しては、人一倍に敏感であった。

まずdedentを適用して、その結果に対してstrip()をするのが大事です。逆にすると意図せぬ結果になります。

これで、不要な行頭の空白が消えました。

おまけですが、逆に行頭に空白に限らず何かしらの文字列を挿入する、textwrap.indent もあります。これは、テキストと、挿入したい文字列を入れたらいいですね。例えば、 果物の名前の先頭に – (ハイフン) でも差し込みましょうか。

sample_text = """
    りんご
    みかん
    もも
    なし
"""
sample_text = textwrap.dedent(sample_text).strip()  # まず不要な空白消す

print(textwrap.indent(sample_text, "- "))  # 先頭に - 挿入
# 以下出力
"""
- りんご
- みかん
- もも
- なし
"""

このほかにも、textwrapには文字列を折り返したり切り詰めたりするなどの便利なメソッドが用意されています(というより本来そのためのライブラリです)ので、そのうち紹介しようと思います。