端末内の各フォルダに散らばっているファイルやドットファイルをGitで管理する

今回の記事は技術的には特に難しい話はなく、Gitのちょっとした応用なもので本職のエンジニアの人たちの間ではもしかしたら常識なのかもしれませんが、自分にとっては革新的だったので紹介しておきます。

プロジェクトのコードやファイルを管理するためにGitは普通に使っていると思いますが、何か大きなプロジェクトではなくて、端末(Macを想定)内の各ディレクトリに散っているファイルを個別にバージョン管理したくなることってないですか。

例えば、ホームディレクトリにある.vimrcなど.(ドット)始まりの隠しファイルとか、自分がよく使うコードをまとめた自作モジュールとか、頻繁に書くSQLの部品をまとめたメモとか、Pythonの環境構築に使う、requirements.txtなどもそうですね。
このファイルはGitで管理したいけど、それぞれの配置場所を個別にリポジトリにするのは面倒だし、同じディレクトリ内にGit管理が適さない属性のファイルもたくさんあるなぁ、っていう状況です。

このように、配置場所が散らばったファイルを1個のリポジトリで管理する方法があることを最近知りました。

やり方は簡単で、どこかに一つだけリポジトリを作り、その配下に各所に散らばっているファイルを集めてそれをgit管理し、元のフォルダにはシンボリックリンクを貼ると良いです。

まぁ、普通にディレクトリをどこかに掘って、リポジトリを作ります。
最初のコミットは空コミットにしておきましょう。

% mkdir my_files
% cd my_files
% git init
% git commit -m "first commit" --allow-empty

そして、このディレクトリへgit管理したいファイルたちを集めて、元ディレクトリへシンボリックリンク貼ってきます。
参考: ハードリンクとシンボリックリンク

% mv {元のファイルパス} {リポジトリ内のファイルパス}
% ln -s {リポジトリ内のファイルパス} {元のファイルパス}

どのファイルをどこにリンクしているかは、それはそれでREADME.md ファイルかどこかに一緒に記録しておくと良いでしょう。

ちなみに、リポジトリのルート直下に全ファイルまとめておくのはお勧めしません。dotファイルのディレクトリとかPythonモジュールのディレクトリとか切ってリポジトリ内を適切に整理しましょう。

こうすると、一つのリポジトリに各所に置いてあったファイルの実体が集まるのでgitでまとめて管理できるようになります。

そして本来の配置場所にはシンボリックリンクが貼られているので今までと全く同じように使用することができます。

注意点としては、ドットファイルの中でも特にセキュアな認証情報などを環境変数に設定するファイルを管理する場合の流出リスクですね。セキュアな情報はこの管理の対象外にするか、対象にするのであればgithub等外部のリポジトリへは上げない方が良いでしょう。(誤って公開リポジトリに上げてしまうと大事故に繋がり得ます)

J-Quants APIのページング処理に対応する

久々にJ-Quants API の記事です。もう結構前の話(2023/06/16)の話ですが、J-Quants APIはデータ量の増加位に対応するためにページング処理というものが導入されました。
参考: お知らせ – J-Quants API の 過去のお知らせ部分見てください。

要するにAPIから取得できるデータの量が多い時に、全部のデータを一度では取得できず、一部分だけ取得できるって話ですね。

こちらについて利用方法を記事にしておきます。

ページング処理対応方法

詳しくはこちらをご参照ください。
参考: API共通の留意事項 – J-Quants API

レスポンスが帰ってきた時、結果にpagination_key が含まれていたらページング(ページネーション)が発生しており、そこで得られた結果は取得したかったデータの全量ではありません。
得られたpagination_keyの値を付与して再度リクエストすることで以降のデータを得ることができます。

サンプルコード参照してやってみましょう。
ちなみに、認証にidトークンが必要ですがその取得方法は僕の過去記事参照してください。
参考: J-Quants API の基本的な使い方
以下の記事では、 id_token って変数にすでにトークンが取得できているものとします。

import json
import requests
import pandas as pd


print(len(id_token))  # id_tokenは過去記事の方法ですでに取得してるとします。(文字数確認)
# 1107

# 特定の日付の4本値を取得する
date = "2024-03-15"
daily_quotes_url = f"https://api.jquants.com/v1/prices/daily_quotes?date={date}"
headers = {"Authorization": f"Bearer {id_token}"}
daily_quotes_result = requests.get(daily_quotes_url, headers=headers)

# レスポンスに、pagination_key が含まれていることが確認できる。
print(daily_quotes_result.json().keys())
# dict_keys(['daily_quotes', 'pagination_key'])

pagination_key = daily_quotes_result.json()["pagination_key"]

# pagination_key も付与してもう一度リクエストする。
daily_quotes_url_2 = f"https://api.jquants.com/v1/prices/daily_quotes?date={date}&pagination_key={pagination_key}"
daily_quotes_result_2 = requests.get(daily_quotes_url_2, headers=headers)

# 今度は、pagination_keyは含まれていない。
print(daily_quotes_result_2.json().keys())
# dict_keys(['daily_quotes'])

# それぞれデータが得られている。
len(daily_quotes_result.json()["daily_quotes"]),  len(daily_quotes_result_2.json()["daily_quotes"])
# (4030, 312)

# それぞれ配列型のデータなので + で連結できる。
# DataFrame化までついでに行った。
df = pd.DataFrame(daily_quotes_result.json()["daily_quotes"]
                  + daily_quotes_result_2.json()["daily_quotes"])

print(len(df))
# 4342

1回目のリクエストでは、本当は4342件得られるはずだったデータのうち、4030件しか取得できてなかったことがわかりますね。そして、pagination_keyを合わせて送信することで、続きを取得できています。

上記のサンプルコードはわかりやすさ優先のため、2回で全部取得できると決め打ちしていますが、実際は2回目のリクエストでもpagination_keyが戻ってくる可能性があります。

そのため、実際の運用ではドキュメントのコードのようにpagination_keyがなくなるまでループするような実装にすると良いでしょう。

# 特定の日付の4本値を取得する
date = "2024-03-15"
daily_quotes_url = f"https://api.jquants.com/v1/prices/daily_quotes?date={date}"
headers = {"Authorization": f"Bearer {id_token}"}
daily_quotes_result = requests.get(daily_quotes_url, headers=headers)

# 1回目のレスポンスで得られたdata
data = daily_quotes_result.json()["daily_quotes"]

# pagination_keyが含まれている限りはループする。
while "pagination_key" in daily_quotes_result.json():
    pagination_key = daily_quotes_result.json()["pagination_key"]
    daily_quotes_url = f"https://api.jquants.com/v1/prices/daily_quotes?date={date}&pagination_key={pagination_key}"
    daily_quotes_result = requests.get(daily_quotes_url, headers=headers)
    # 得られたデータを連結する。
    data += daily_quotes_result.json()["daily_quotes"]


# データが揃っている。
print(len(data))
# 4342

これで、J-Quants APIのページング処理にも対応できました。

pandas.qcutでデータを分位数で離散化する

今回の記事ではpandasのqcutという関数を紹介します。
参考: pandas.qcut — pandas 2.2.1 ドキュメント

記事タイトルに書いていますが、これは分位数に基づいてデータを離散化する関数です。
実は以前、数値の区間で区切って離散化するpandas.cutというのを紹介したことがあるのですが、その仲間みたいなものですね。僕はつい最近までqcutを知りませんでしたが。
参考: pandasで数値データを区間ごとに区切って数える

cutでは数字の絶対値を基準に、0以上100未満、100以上200未満、みたいにデータを切り分けることができましたが、qcutでは分位数(パーセンタイル)を基準にデータを分けることができます。要するに、4つに分けるのであれば、25%以下、50%以下、75%以下、それより上、みたいにデータを区切り、各区切りには大体同じ件数のデータが分類されます。

cutだったら区間の幅が揃い、qcutだったら各区間に含まれるデータの件数が揃うというのが一番簡潔な説明ですね。

適当に乱数を使ってやってみましょう。ポアソン分布で200個ほどデータを作って、q_cutで5つのグループに分けてみます。

import pandas as pd
from scipy.stats import poisson  # テストデータ生成用


# λ=100のポアソン分布に従う乱数を200個生成
data = poisson(mu=100).rvs(size=200, random_state=0)
print(data[:10])  # 最初の10項表示
# [101 103  98  98 127 109 102  82  99  86]

# データと区切りたいグループの個数を指定して実行
out = pd.qcut(data, q=5)
# 各データがそれが含まれる区間お
print(out)
"""
[(97.0, 102.0], (102.0, 107.0], (97.0, 102.0], (97.0, 102.0], (107.0, 127.0], ..., (102.0, 107.0], (102.0, 107.0], (92.0, 97.0], (92.0, 97.0], (78.999, 92.0]]
Length: 200
Categories (5, interval[float64, right]): [(78.999, 92.0] < (92.0, 97.0] < (97.0, 102.0] < (102.0, 107.0] < (107.0, 127.0]]
"""

データの先頭の方と、あと、結果をprintして表示されたやつを上のコードに出しました。Categories として5つの区間が表示されていますが、「それぞれのデータがどの区間に含まれているのか」に変換されたものが得られていますね。例えば最初のデータは101ですが、これは区間(97, 102] に含まれます。

区間にラベルをつけることもできます。低い方からL1, L2, L3 みたいにつけていく場合はlabel引数にqで指定した数と同じ要素数の配列を渡して実現します。(今回文字列でサンプル作っていますが、数値をラベルにすることもできます。)

# ラベルを指定する
out = pd.qcut(data, q=5, labels=["L1", "L2", "L3", "L4", "L5"])
print(out)
"""
['L3', 'L4', 'L3', 'L3', 'L5', ..., 'L4', 'L4', 'L2', 'L2', 'L1']
Length: 200
Categories (5, object): ['L1' < 'L2' < 'L3' < 'L4' < 'L5']
"""

変換後のデータとして扱いやすそうな形で結果が得られました。

ただ、それぞれのラベルの区間がこれだとわからないですね。区間の情報を別途得る必要があるのでその場合はretbins引数にTrueを渡して、結果を受け取るときにもう一個変数を用意して受け取ることで、区切り位置の譲歩を得ることもできます。もちろん、labelsは使わずに、retbinsだけ指定することもできますよ。

# ラベルを指定する
out, bins = pd.qcut(data, q=5, labels=["L1", "L2", "L3", "L4", "L5"], retbins=True)
print(out)
"""
['L3', 'L4', 'L3', 'L3', 'L5', ..., 'L4', 'L4', 'L2', 'L2', 'L1']
Length: 200
Categories (5, object): ['L1' < 'L2' < 'L3' < 'L4' < 'L5']
"""
# 区切り位置の情報
print(bins)
# [ 79.  92.  97. 102. 107. 127.]

最後に注意です。qcutを使うと連続値のデータは大体同じ個数ずつに分けてくれることが多くそれが目的で使うことが多くなるのですが、今回の例のように整数値など離散な値しか取らない場合はそうでもなくなってきます。今回乱数で発生したデータはちょうど区切り位置の107が10個も混ざってた等々の事情で、ちょっとだけ偏りが出ています。実際に使う場合はこのあたりの結果もよく注意してみてください。

print(out.value_counts())
"""
L1    44
L2    39
L3    39
L4    41
L5    37
dtype: int64
"""

np.vectorizeで関数をベクトル化する

NumPyやScyPyの関数って非常に便利で、NumPy配列(要するにArray)を渡すと空気を読んでその渡したデータの各要素に関数を適用してNumPy配列で結果を返してくれたりします。

自分で定義した関数でもNumPyやSciPyの関数の組み合わせで作った関数であれば結構そのように動いてくれるのですが、文字列操作が入ったりif文による分岐等があると必ずしもそうはならず、スカラー値を受け取ってスカラー値を返すだけの関数になることがあります。

そのような関数を、手軽にベクトルか対応することができる方法があるのでこの記事で紹介します。

それが、記事タイトルのnp.vectorizeです。

ドキュメント: numpy.vectorize — NumPy v1.26 Manual

関数を渡すと戻り値で新しい関数オブジェクトが帰ってきてそれがベクトル対応(配列対応)しています。

基本的な使い方

数学関数だと特にArrayを渡すと元々期待通り動いたりするので、少々無理矢理な例ですが文字列操作の関数を作ってお見せします。これは数値を1個受けとって、その数値に、「回目」っていいう単位をつけて返すだけの関数です。普通に実験、そのまま配列渡してみる、ベクとライズして配列を渡してみる、の3パターンやってみました。

import numpy as np


# 数値に単位をつける関数を実装
def number_format(n):
    return f"{n}回目"


# 数値を渡すと想定通り動く
print(number_format(5))
# 5回目

# 配列を渡すと配列を一個の値とみなして文字列化して単位をつけてしまう。
print(number_format([1, 2, 3]))
# [1, 2, 3]回目

# ベクトル化した関数を作る
number_format_vec = np.vectorize(number_format)

# それに配列を渡すと配列の各要素に元の関数を適用してくれる。
print(number_format_vec([1, 2, 3]))
# ['1回目' '2回目' '3回目']

# Array型もタプルもいける
print(number_format_vec(np.array([1, 2, 3])))
# ['1回目' '2回目' '3回目']
print(number_format_vec((1, 2, 3)))
# ['1回目' '2回目' '3回目']

# もちろん、内包表記で同じことをすることは可能(ただし、この結果はlist)
print([number_format(n) for n in [1, 2, 3]])
# ['1回目', '2回目', '3回目']

ベクトル化した関数を1回しか使わないなら内包表記で済ましちゃっていいんじゃないかな、と思うのですが、何度も利用したい関数であればnp.vectorizeを使うと言う選択肢もあるのかな、と思います。

注意点

NumPyやSciPyで実装されている関数群って並列処理できる部分は並列処理するような賢い実装になっていることがありますが、この np.vectorize はそこまで気が利いたものではありません。どうやら単純にfor文で順次処理するようになるだけらしいので処理の高速化等の効果はありません。ドキュメントにも利便性のためのもので、パフォーマンスのため使うようなものではなく、for loop回してるだけだって書いてありますね。

そのため、本当に頻繁に大規模なベクトルを処理する関数なのであれば別の方法で対応させる必要があるでしょう。

もう一点、細かいですが戻り値がNumPyのarrayであることも注意が必要ですね。と言ってもこれは便利に感じることが多いですが。内包表記であればlistで結果が得られますがvectorizeするとlist渡してもlistではなくarrayで帰ってきます。

引数を複数受け取る関数の場合

この np.vectorize は引数を複数受け取る関数にも対応しています。ドキュメントのサンプルもa, b の2変数受け取っていますしね。一応その例も見ておきましょう。年と月の数値を受け取って何年何月、という文字列返す関数でやってみます。

def month_str(year, month):
    return (f"{year}年{month}月")


month_str_vec = np.vectorize(month_str)

# 元の関数はyear, monthは1個ずつしか値を受け取れない
print(month_str([2020, 2023, 2026], [1, 4, 7]))
# [2020, 2023, 2026]年[1, 4, 7]月

# ベクトル化すると複数ペアをまとめて処理できる。
print(month_str_vec([2020, 2023, 2026], [1, 4, 7]))
# ['2020年1月' '2023年4月' '2026年7月']

# 片方は配列で、片方はスカラーというパターンにも対応する
print(month_str_vec([2020, 2023, 2026], 1))
# ['2020年1月' '2023年1月' '2026年1月']

さいごに

以上が手軽に関数をベクトル化する方法でした。まぁ、内包表記もあればmapを使うやり方もあるのでこれが必須というわけではないのですがいい感じに動く関数を手軽に作る方法として頭の片隅に置いておくと使う場面はあるんじゃないかなと思います。

ちなみに、関数を定義した直後にベクトル化した関数で元の関数名を上書きしておくと、最初っからベクトル化した関数を宣言したのと同じように使えますよ。

def func(x):
    # 何かの処理


func = np.vectorize(func)
# 以降に呼び出されるfuncはベクトル対応した関数。