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の紹介を終えます。
どちらを使うかであったり、繰り返し方向の指定などを間違えがちだと思うので、よく確認しながら使いましょう。