Pythonの関数から一部の引数を固定して新しい関数を作る

Pythonの多くのライブラリの様々な関数が非常に汎用的に使えるように作られているので多くの引数を受け取れるようになっています。しかし、そのほとんどの引数を固定して1変数関数として使いたいなぁと思うようなことがあります。PandasのDataFrameのapplyなど関数を引数として受け取る関数に渡す場合等ですね。
また、大量にある引数のほとんどを固定して一部だけ変えながら何度も実行する、といった場面も考えられます。

lambda式などを作ってラップした新しい関数を実装してもいいのですが、 functoolsという標準ライブラリにその専用のpartial というメソッドが用意されています。
参考: functools.partial(func/*args**keywords)

このpartialを使うと、引数の一部を固定した引数の少ない新しい関数を作ってくれます。

一個目の引数に元になる関数を渡し、2個目以降の引数に渡したものが、元の関数の固定引数として使われます。keyword引数で渡せばそのkeyword引数が固定されます。

一引数の固定の方は先頭から順番に固定されるので注意してください。つまり2番目以降の引数を固定したい場合はそれらはキーワード引数として指定する必要があります。

サンプル

引数を順番に表示するだけの単純な関数を作ってやってみましょう。

from functools import partial


# 3つの引数を表示するだけの関数
def sample_func(a, b, c):
    print("a=", a)
    print("b=", b)
    print("c=", c)


# テスト実行
sample_func(1, 2, 3)
"""
a= 1
b= 2
c= 3
"""

# a = 10, b = 20 を固定した新しい関数が作られる。
partial_f = partial(sample_func, 10, 20)


# 3個目の引数 c = 50だけ渡して実行できる。
partial_f(50)
"""
a= 10
b= 20
c= 50
"""

# キーワード引数で固定することもできる。
partial_f2 = partial(sample_func, a=100, c=200)

# b の値だけ渡して実行できる
partial_f2(b=-5)
"""
a= 100
b= -5
c= 200
"""

キーワード引数を固定した関数を、位置引数で使う場合は注意が必要です。
たとえば、次のようにaを固定して生成した関数に、残り2個の引数を位置引数で渡すと、aを2回渡した扱いになってエラーが起きます。

# aを固定
partial_f3 = partial(sample_func, a=1)

# bとcのつもりで残り2個の引数を渡すとエラー
try:
    partial_f3(2, 3)
except Exception as e:
    print(e)
# sample_func() got multiple values for argument 'a'

# bとcもキーワード引数で渡す。
partial_f3(b=2, c=3)
"""
a= 1
b= 2
c= 3
"""

まとめ

ほぼ小ネタのような内容でしたが、自作関数をベースに一部の振る舞いを固定した簡易的な関数を作るとか、apply等の1変数関数を受け取るメソッドに渡したいとかそういう場面で役に立つことがあるテクニックとしてpartialを紹介しました。

scipyのstats配下の各種メソッドであれば、それぞれがパラメーターを固定するfrozenメソッドを持ってるとか、引数が多いなら引数を辞書にまとめて**(アスタリスク2個)で展開すればいいとか、ラップした関数を自分で実装したらいいとか、代用手段も多いのですが、partialを使うとその辺の記述がシンプルになるので機会があれば使ってみてください。

SciPyでニュートン法を利用する

前回の記事の二分法に続いて、もう一つ求根アルゴリズムを紹介します。
参考: 二分法を用いて関数の根を求める

今回紹介するのはニュートン法です。これは微分可能な関数$f(x)$の根を求めることができるアルゴリズムです。
参考: ニュートン法 – Wikipedia

詳しい説明は上記のWikipediaにあるので、ざっくりと概要を説明します。

この方法の背景にあるのは、滑らかな関数をある点の近くだけ着目してみるとほぼ直線になり、接線で近似できるということをベースのアイデアにしています。

つまり、微分可能な関数$f$があって$f(x)=0$だとします。その根$x$の近くに点$x_0$を取ると、$x_0$の近くでは、$f$と$f$の接線ってかなり近いよね、それなら$f$の根と$f$の$x_0$における接線の根って近いよね、っていうのが基本的なアイデアです。

関数$f$の$f(x_0)$における接戦は次の式で書けます。

$$y=f'(x_0)(x-x_0)+f(x_0).$$

$f(x)=0$は解けない場合でも、この接線の根は容易に算出することができ、

$$x_0-\frac{f(x_0)}{f'(x_0)}$$

と求まります。

この値は元の$x_0$よりも真の根に近いことが期待され、これをもう一回$x_0$とおいて同じ操作を繰り返せば真の根にたどり着く、というのがニュートン法です。

Wikipediaから画像拝借しますが、図で見るとイメージしやすいですね。

注意しないといけないのは、初期値$x_0$は真の根$x$の十分近くに取らないといけない点です。十分近くを見れば関数をその接線で近侍できるよね、というのがアイデアの前提なので、根が近くになかったらその前提が崩れてしまいこのアルゴリズムは真の根に収束しなくなってしまいます。

ニュートン法のメリットとデメリット

先に紹介した二分法と比べて、ニュートン法のメリットデメリットを説明していきます。

1番のメリットは収束の速さです。二分法に比べてより少ない計算回数で効率的に会を探索することができます。

また、初期値として与える点が1点だけで良いというのもメリットです。二分法の場合は初期値は区間で設定する必要がありましたからね。

その一方で複数のデメリットもあります。実装していて一番不便に感じるのはその関数だけでなく微分も必要ということでしょうか。もちろん微分不可能な関数ではニュートン法は使えません。

また、初期値が真の解に十分近くない場合や、微分した値が$0$に近い場合、うまく収束せずにアルゴリズムが失敗してしまう、という点も大きなデメリットです。

SciPyによる実装

SciPyではscipy.optimizeというモジュールで実装されています。newtonという専用メソッドを使うか、root_scalarという汎用的なメソッドで(method=’newton’)を指定して使うことになります。二分法と同じですね。

参考:
scipy.optimize.newton — SciPy v1.12.0 Manual
root_scalar(method=’newton’) — SciPy v1.12.0 Manual

二分法の時と同じように、$\sin$関数の根$\pi$を探索させてみましょう。微分は$\cos$なのでこれを使います。

from scipy import optimize
import numpy as np


root1 = optimize.newton(np.sin, x0=3, fprime=np.cos)
print(root1)
# 3.141592653589793

root_result = optimize.root_scalar(np.sin, method="newton", x0=3, fprime=np.cos)
print(root_result)
"""
      converged: True
           flag: 'converged'
 function_calls: 6
     iterations: 3
           root: 3.141592653589793
"""

print(root_result.root)
# 3.141592653589793

簡単ですね。

注目するのは、iterationsの部分です。たった3回のイテレーションで収束していて、関数が実行されたのは、fとfの微分合わせて6回だけです。
二分法の時は39回もイテレーションが必要だったのと大違いです。そして実はこの例では解の精度もニュートン法の方が高くなっています。

ニュートン法が失敗する例

初期値が真の解の近くにないと失敗するという話がありましたのでそちらも見ておきます。

例えば、タンジェントの逆関数、$\arctan$で試してみましょう。(sin, cosは根が無限にあって、根から遠い実数を用意できないので関数を変えます。)

$\arctan(x)$の微分は$\frac{1}{1+x^2}$です。

やってみました。

def f(x):
    return np.arctan(x)


def fprime(x):
    return 1/(1+x**2)


# 初期値が1なら収束する
root_result_1 = optimize.root_scalar(np.sin, method="newton", x0=1, fprime=fprime)
print(root_result_1)
"""
      converged: True
           flag: 'converged'
 function_calls: 12
     iterations: 6
           root: 0.0
"""

# 初期値が2だと失敗し、結果のflagが'convergence error'になる。
root_result_2 = optimize.root_scalar(np.sin, method="newton", x0=2, fprime=fprime)
print(root_result_2)
"""
      converged: False
           flag: 'convergence error'
 function_calls: 100
     iterations: 50
           root: 1.854706857103781
"""


# optimize.newton の方だと例外が上がる。
try:
    optimize.newton(f, fprime=fprime, x0=2)
except Exception as e:
    print(e)
# Derivative was zero. Failed to converge after 10 iterations, value is -6.999943395317963e+168.

失敗した時の振る舞いがそれぞれ違うので、どちらのコードを使うかで注意深く扱う必要がありますね。optimize.root_scalarはコード自体は正常に終了しますがフラグが立ち、optimize.newtonの方は例外があがります。

ちなみに、エラーの中で出てくるvalue の値、 -6.999943395317963e+168 は次のように自分でニュートン法を実装しても同じ値が出て来ます。

x0 = 2  # 初期値
for i in range(12):
    x0 = x0 - f(x0)/fprime(x0)
    print(i+1, "回目: x0=", x0)

"""
1 回目: x0= -3.535743588970453
2 回目: x0= 13.950959086927496
3 回目: x0= -279.34406653361754
4 回目: x0= 122016.9989179547
5 回目: x0= -23386004197.933937
6 回目: x0= 8.590766671950415e+20
7 回目: x0= -1.1592676698907411e+42
8 回目: x0= 2.110995587611039e+84
9 回目: x0= -6.999943395317963e+168
10 回目: x0= inf
11 回目: x0= nan
12 回目: x0= nan
"""

絶対値が大きくなり続けていて全く収束に向かっていないのがわかりますね。

まとめ

元の関数だけではなく導関数も必要だったり、初期値の設定段階である程度解の目星をつけておかないといけないなどのデメリットはありますが、速度や精度の面で優秀でしかもロジックもわかりやすい手法なので、何か機会があればニュートン法の活用を検討してみてください。

二分法を用いて関数の根を求める

1変数連続関数の根(値が0になる点)を求める、二分法というアルゴリズムとそれをScipyで実装する方法を紹介します。

アルゴリズムの内容

二分法というのは中間値の定理をベースとした求根アルゴリズムです。アイデアは非常に単純で、連続関数$f$に対して、$f(x_1)$と$f(x_2)$の符号が異なるように、$x_1, x_2$を選びます。この時点で、中間値の定理より区間$(x_1, x_2)$に根があることがわかりますのでさらに細かくみていきます。次は、$x_1, x_2$の中点$x_M = \frac{x_1+x_2}{2}$を取り、$f(x_M)$の符号を調べます。$f(x_M)$と$f(x_1)$の符号が同じであれば、$x_1$を$x_M$で置き換え、逆に$f(x_M)$と$f(x_2)$の符号が同じであれば、$x_2$を$x_M$で置き換えると、区間の幅が半分になった$(x_1, x_2)$が得られますが根はこの中にあることがわかります。これを繰り返すと、根が存在する範囲を狭めていくことができ、$f(x_M)$の絶対値が0になるか、もしくは十分0に近づいたらその値を数値的に求めた根とします。

以上が、一般的な二分法のアルゴリズムの説明です。ただし、後に紹介するSciPyではどうやら区間が十分狭くなったかイレーション回数が上限に達したか等の基準でループを打ち切り、$f(x)$の値を確認していないようです。

二分法のメリットとデメリット

方法が単純でわかりやすい、というのが個人的に感じている1番のメリットです。

また、連続関数であれば使えるため、ニュートン法などのアルゴリズムのように元の関数の微分を必要とせず、微分が難しい関数や微分不可能な関数でも使えます。

また、根が存在しうる区間を狭めながら探索するため、最初の区間の幅と繰り返し回数により、結果の精度を保証できることも大きな利点です。

逆にデメリットとしては、ニュートン法等と比較して収束が遅いこととか、初期値として関数が異符号になる2点を探して与える必要があること、もしその区間に複数の根が存在した場合にどれに収束するか不確定なことなどが挙げられます。

ただし僕の経験上では、ある程度根の目処がついていたり、単調な関数に対して使う場面が多くこれらのデメリットを深刻に感じることは少ないです。

SciPyによる実装

SciPyではscipy.optimizeというモジュールで実装されています。bisectという専用メソッドを使うか、root_scalarという汎用的なメソッドで(method=’bisect’)を指定して使うことになります。

参考:
scipy.optimize.bisect — SciPy v1.11.4 Manual
root_scalar(method=’bisect’) — SciPy v1.11.4 Manual

試しに、$\sin$関数の 3と4の間にある根を探させて見せましょう。既知の通りそれは円周率$\pi$になるはずです。

from scipy import optimize
import numpy as np


root1 = optimize.bisect(np.sin, 3, 4)
print(root1)
# 3.1415926535901235

root_result = optimize.root_scalar(np.sin, bracket=[3, 4], method='bisect')
# 結果は複数の情報を含むRootResults形式で戻る。
print(root_result)
"""
      converged: True
           flag: 'converged'
 function_calls: 41
     iterations: 39
           root: 3.1415926535901235
"""
print(type(root_result))
# class 'scipy.optimize._zeros_py.RootResults'

# 根の値へのアクセス方法
print(root_result.root)
# 3.1415926535901235

いかにも円周率ぽい結果が得られましたね。root_scalarの方では収束したことを示すフラグや、イテレーション回数なども得られています。

失敗事例1. 区間の両端での関数の値が同符号の場合

二分法は初期設定を誤ってると失敗するのでその場合のSciPyの挙動も見ておきましょう。失敗パターンの一つは、最初に指定した区間の両端で符号が一致していた場合です。もちろん関数の形によってはその区間内に根がある可能性もあるのですが、存在は保証されなくなります。

また$\sin$関数で、その間に根が存在しない区間$(1, 2)$と、実は両端で同符号だけど根が存在する区間$(1, 7)$でやってみましょう。bisectとroot_scalarで全く同じエラーメッセージ出るのでbisectの方だけ載せます。

try:
    optimize.bisect(np.sin, 1, 2)
except Exception as e:
    print(e)
# f(a) and f(b) must have different signs

try:
    optimize.bisect(np.sin, 1, 7)
except Exception as e:
    print(e)
# f(a) and f(b) must have different signs

はい、$f(a)$と$f(b)$は違う符号にせよとのことでした。根が存在しない1個目の例はさておき、2個目の例は根は区間内に2個存在するのですが探さずにエラーになりました。

失敗事例2. 連続関数ではなかった場合

もう一つ失敗するのは、関数が連続関数ではないケースです。

例えば$\tan$の、$\frac{\pi}{2}$近辺の挙動で見てみましょう。数学的に厳密な話をすると、$\frac{\pi}{2}$では$\tan$は定義されないので、$\tan$は数学的には連続関数(定義域内のすべての点で連続)なのですが、数値計算的には不連続と考えた方が都合が良いです。

話が脇に逸れたので実例の話に移ります。実は、bisectメソッドは結果を返して来ちゃうんですよね。そしてそれが全然根ではないということも見ておきましょう。

root = optimize.bisect(np.tan, 1, 2)
# pi/2に近い結果が得られている
print(root)
# 1.5707963267941523

# 元の関数に代入した結果は全く0に近くない
np.tan(root)
# 1343445450736.3804

root_scalarの方だったら、結果のフラグ等もあるのでアラート等あげてくれるのかと期待したのですがそういう機能はなさそうです。

root_result = optimize.root_scalar(np.tan, bracket=[1, 2], method='bisect')
print(root_result)
"""
      converged: True
           flag: 'converged'
 function_calls: 41
     iterations: 39
           root: 1.5707963267941523
"""
np.tan(root_result.root)
# 1343445450736.3804

要するに、SciPyに渡す関数が本当に連続関数であるかどうかは利用者が責任を持たないといけないということです。また、結果が本当に根なのかどうかは代入して確認した方が良いでしょう。

まとめ

以上が二分法の説明とSciPyで利用する方法、その注意点でした。

MySQLでインデックスヒントを使う

※ MySQLって書いてますがその互換の Aurora で検証しています。

MySQLのレコード数が大きなテーブルからデータを検索するとき、インデックスは処理時間の短縮において重要な役割を果たしてくれます。

ただ、一個のテーブルにインデックスを何個も作成しているとSQLによっては最適なインデックスが使われないことがあります。このような場合、インデックスヒントを使うことでクエリを最適化できる可能性があります。

参考: MySQL :: MySQL 8.0 リファレンスマニュアル :: 8.9.4 インデックスヒント

自分の事例で言うと、昔仕事で扱っていたDBでインデックスが10個以上張られているテーブルがあり、きちんと指定してあげないと正しく使ってくれないものがあったことがあります。

また、最近は私用で個人的に集めているデータを溜めているテーブルにおいても、レコード数が増えるにれて正しいインデックスを使ってくれない事例が発生するようになりました。

自分の事例で言うと、どうもMySQLによってインデックスによる絞り込み効果が低いと判断されるとそれが使われないってことがあるようですね。半年分のデータを取得しようとすると正しく実行されないが、1ヶ月分のデータを取得しようとすると正しく使われる、といった事例をよく見ます。

テーブルに貼られているインデックスの確認方法

まずはどんなインデックスが使いうるのか知らないと話にならないのでその確認方法です。

SHOW CREATE TABLE {テーブル名};
でテーブル定義を丸ごと確認するか、インデックス情報取得専用の構文である、
SHOW INDEX FROM {テーブル名};
を使うことで確認できます。

僕個人が分析用に溜めている株価のテーブルだと次のようになります。code(証券コード)とdate(日付)を主キーとしていて、code, date 個別にもインデックスを貼っています。

select
SHOW INDEX FROM price
-- 結果
Table	Non_unique	Key_name	Seq_in_index	Column_name	Collation	Cardinality	Sub_part	Packed	Null	Index_type	Comment	Index_comment
0	price	0	PRIMARY	1	code	A	4561	None	None		BTREE		
1	price	0	PRIMARY	2	date	A	7336473	None	None		BTREE		
2	price	1	code	1	code	A	4383	None	None		BTREE		
3	price	1	date	1	date	A	3166	None	None		BTREE		

key_name列で、primary, code, date の3種類のインデックスがあることが確認できますね。

クエリが利用しようとしているインデックスの確認方法

おそらく処理時間とかを計測したりてこのクエリは正しくインデクス使ってないな、って気づくことになると思うのですが、実際にどのインデックスを使っているかは、そのクエリの先頭に EXPLAIN をつけて実行することで確認できます。

例えば、次のような感じです。possible_keys が利用可能なインデックスで、key が実際に使うインデックスです。

EXPLAIN SELECT
    *
FROM
    price
WHERE
    date >= '2023-01-01'

-- 結果 (これはkeyがNoneなのでインデックスを使ってない)
	id	select_type	table	partitions	type	possible_keys	key	key_len	ref	rows	filtered	Extra
0	1	SIMPLE	price	None	ALL	date	None	None	None	7336473	25.53	Using where

EXPLAIN SELECT
    *
FROM
    price
WHERE
    date >= '2023-12-01'

-- 結果 (これはdateをインデックスとして使っている)
	id	select_type	table	partitions	type	possible_keys	key	key_len	ref	rows	filtered	Extra
0	1	SIMPLE	price	None	range	date	date	3	None	176076	100.0	Using index condition

さて、上記の上の例でもdateをインデックスとして使って欲しい場合にインデックスヒントを使います。

インデックスヒントの使い方

インデックスヒントを使う場合、特定のインデックスを強制する USE INDEX, 特定のインデックスの利用を防ぐ IGNORE INDEX, USE INDEXよりも強力に指定したインデックスを強制する FORCE INDEX の3種類があります。

使い方は同じで、FROM句のテーブル名の後ろに、
テーブル名 USE INDEX (インデックス名)
のように書きます。カッコを忘れないように注意してください。

FORCE IDNEXでやってみます。

EXPLAIN SELECT
    *
FROM
    price FORCE INDEX(date)
WHERE
    date >= '2023-01-01'

-- 結果 (FORCEしたインデックスが使われている)

id	select_type	table	partitions	type	possible_keys	key	key_len	ref	rows	filtered	Extra
0	1	SIMPLE	price	None	range	date	date	3	None	176076	100.0	Using index condition

最後に

インデックスヒントを使うことでMySQLが利用するインデックスをある程度制御できるようにあります。ただ、実際にパフォーマスが改善するかどうかはまた別の問題です。というのもMySQLが選んだキーの方が効率的である可能性というのも十分あり得るからです。

このクエリはチューニングが必要だなと感じることがあった場合にインデックスヒントを使うことになると思いますが、処理時間が本当に改善してるかどうかはきちんと計測するようにしましょう。

2024年のご挨拶と今年の方針

新年明けましておめでとうございます。本年もよろしくお願いします。

今年のこのブログの運用方針ですが、2022年, 2023年と同じように週1回更新を目指していこうと思います。昨年後半からnoteもスタートしててそっちは月1回なのでアウトプットの総量としては少し増加するイメージです。

毎年年初の記事でこれを書くぞって宣言した内容ほど書けてない変な傾向にあるので、昨年書きたいのに書かなかった内容を少しでもまとめて行けたらいいなと思っています。それ以外だと、最近の自分の関心としては、pyMCとかStreamlit あたりを使い始めているのでこの辺で記事を書けるといいですね。SnowflakeとかReDashの記事を書いてもいいのですが仕事で使ってる系はnoteの方に回りそうです。

ブログ以外の目標

データサイエンティストとしてはせっかく新しい業界に転職したので、その業界のドメイン知識・技術を重点的に学んでいく一年にしたいと思っています。項目応答理論とかもっと幅広く教育工学全般について。今年の取り組みとしてはこれが一番重要かな。

株式の運用に使っている自作システム群の改良も続けていきます。昨年から本格的にJ-Quants APIを使っていますが今年はプランをもう一個上げて活用の幅を広げていくことを検討中です。

その自作システム群ではAmazon Aurora Serverless v1 を使っているのですが、これが2024年の12月末でサポート終了するとアナウンス がありました(2023/12/28に。年末にびっくりしました)。個人開発周りで今年一番重いのはこの移行作業ですね。v2は高いので採用したくなく、何か別のやり方を考えて移行していく必要があります。

そういった感じで、このブログ自体は昨年と変わらず細々と更新されていく形になると思いますがどうぞ本年もよろしくお願いいたします。