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

mplfinanceで1枚の画像に複数のチャートを描く方法

mplfinanceの4記事目です。今後また書くかもしれないけど一旦、連続でmplfinanceを扱うのは今回までにしようと思います。
今回は1枚の画像に複数のグラフを描く方法です。いろんな銘柄を並べて分析する際には必須の技術ですね。

ドキュメントはこちらになります。
参考: mplfinance/subplots.md at master · matplotlib/mplfinance

The Panels Method と、External Axes Method があると書いてありますね。

一つ目のパネルメソッドは特に新しい手法ではなく、以下の記事で紹介した、ローソク足の下にどんどん指標を追加していく方法のことです。
参考: mplfinanceの株価チャートに指標を追加する

ドキュメントにもありますが、この方法はx軸を共有することとか、32個までしか追加できないなどの制限があります。ただ、1銘柄ずつ分析するのであれば手軽で十分な方法だと思います。

今回の記事で紹介するのは、axesを追加していくもう一つの方法です。これはmatplotlibに近い使い方をします。figure(正確には、Mpf_Figure)というオブジェクトを作って、それに対して、subplotを追加し、その中にチャートを書いていきます。

注意しないといけないのは、matplotlibの mpl.figure ではなく、mpfの、mpf.figureを使うことと、plot するときに、ax引数でsubplotを指定することですね。

ドキュメントのサンプルコードでは、次のように4個ハードコーディングした実装が紹介されていますね。

fig = mpf.figure(figsize=(12,9))
<Mpf_Figure size 1200x900 with 0 Axes>
ax1 = fig.add_subplot(2,2,1,style='blueskies')
ax2 = fig.add_subplot(2,2,2,style='yahoo')

s   = mpf.make_mpf_style(base_mpl_style='fast',base_mpf_style='nightclouds')
ax3 = fig.add_subplot(2,2,3,style=s)

ax4 = fig.add_subplot(2,2,4,style='starsandstripes')
mpf.plot(df,ax=ax1,axtitle='blueskies',xrotation=15)
mpf.plot(df,type='candle',ax=ax2,axtitle='yahoo',xrotation=15)
mpf.plot(df,ax=ax3,type='candle',axtitle='nightclouds')
mpf.plot(df,type='candle',ax=ax4,axtitle='starsandstripes')
fig

まぁ、上記のサンプルコードはスタイルの紹介も兼ねてると思いますが、チャートごとにスタイルを変えたいってこともあまりないと思うのでもう少し実用的な例をやってみましょう。

ランダムに選抜した20社のデータを揃えておきました。

print(len(price_df))
# 1680
print(price_df.head(5))
"""
   code        date   open   high    low  close   volume
0  1712  2022-06-01  962.0  989.0  957.0  982.0  94300.0
1  1712  2022-06-02  970.0  970.0  958.0  961.0  65400.0
2  1712  2022-06-03  968.0  976.0  955.0  965.0  79400.0
3  1712  2022-06-06  960.0  969.0  950.0  964.0  83100.0
4  1712  2022-06-07  968.0  978.0  962.0  962.0  65700.0
"""
print(price_df["code"].nunique())
# 20

また、company_name_dict という辞書に “証券コード”: “企業名” という形でデータがあるとします。ラベルに使います。

この20社のデータを1枚の画像にプロットするコードは次のようになります。
なお、日本語が文字化けするので、前回の記事で紹介した対策をやります。
参考: mplfinanceで日本語文字が表示されない問題について
これは複数チャートを描く場合は、mpf.plot ではなく、 mpf.figure のタイミングでstyleを設定しないといけないという罠がありますので注意してください。

出来上がったコードは次のようになります。

import mplfinance as mpf
import matplotlib.pyplot as plt


font_family = plt.rcParams["font.family"][0]  # ファイルで設定したIPAPGothicが入る。
s = mpf.make_mpf_style(
    base_mpf_style='default',
    rc={"font.family": font_family},
)

# styleはこの時点で設定する。
fig = mpf.figure(figsize=(24,35), style=s)
i = 1
for code, sub_df in price_df.groupby("code"):
    ax = fig.add_subplot(5,4,i, title=code + ":" + company_name_dict[code])
    mpf.plot(
        sub_df,
        ax=ax,
        type='candle',
    )
    i+=1

出力される図がこちらです。

しっかりかけましたね。

パネルメソッドではなくaxesを作成する方法のデメリットとして、volume=True を指定するだけでは出来高のグラフを追加できなくなるということが挙げられます。(エラーになります。)

この手法で出来高も表示したい場合は、出来高用にもaxesを作成し、それをvolume引数に渡す必要があります。

さっとサンプルを作ると次のような感じでしょうか。少し狭くてラベルの重なりが発生したりしていますし、何番目のaxesに四本値と出来高を表示するかの指定がトリッキーなコードになっていますがいったん役目は果たすと思います。

font_family = plt.rcParams["font.family"][0]  # ファイルで設定したIPAPGothicが入る。
s = mpf.make_mpf_style(
    base_mpf_style='default',
    rc={"font.family": font_family},
)

# styleはこの時点で設定する。
fig = mpf.figure(figsize=(20, 50), style=s)
i = 1
for code, sub_df in price_df.groupby("code"):
    ax = fig.add_subplot(10,4,i, title=code + ":" + company_name_dict[code])
    ax_volume = fig.add_subplot(10,4,i+4)
    mpf.plot(
        sub_df,
        ax=ax,
        type='candle',
        volume=ax_volume,
    )
    if i % 4 == 0:
        i+=5
    else:
        i+=1

mplfinanceで日本語文字が表示されない問題について

3回続けてになりますが今回もmplfinanceの話です。本当は1枚のfigureに複数チャートを表示する方法について書いてそれで終わりにしようと思っていたのですが、ラベルやタイトルの表示で詰まったので今回先にその記事を書きます。

将来のバージョンでは修正される可能性もあると思うので、この記事で想定しているバージョンを書いておきます。

$ pip freeze # の結果を抜粋
matplotlib==3.5.2
mplfinance==0.12.9b1
jupyterlab==3.4.3

また、matplotlibには以下の記事の設定がされており、標準状態では日本語文字が表示できるとします。(以下の記事の設定を行なっていない場合はこの記事の対応を行なっても表示できません。)
前提記事: matplotlibのデフォルトのフォントを変更する

mplfinanceではmatplotlibのグラフと同じようにタイトルやy軸のラベルの表示ができます。チャートをズラズラと何枚も並べる場合は銘柄や期間の情報が必須なのでとても重要な機能です。

この時、証券コードとか英単語は問題なく表示されるのですが、日本語の文字については設定したstyleによっては表示できないことがあります。

問題について言及する前に、ラベル等を設定する方法について説明します。ドキュメントにはあまり親切なガイドがないので、ソースコードで引数を確認するのが早いと思います。該当箇所はこちらのバリデーション部分

使うのは次の3つです。
– title : タイトル
– ylabel : チャートのy軸のラベル
– ylabel_lower : 出来高のパネルのy軸のラベル

データは次のように適当に用意しました。

print(len(price_df))
# 84
print(price_df.head())
"""
            code    open    high     low   close     volume
date                                                       
2022-06-01  9434  1488.5  1497.0  1477.5  1481.5  7555300.0
2022-06-02  9434  1481.0  1484.5  1471.0  1479.5  5307700.0
2022-06-03  9434  1481.0  1482.0  1472.0  1475.0  5942800.0
2022-06-06  9434  1470.0  1474.5  1466.0  1473.0  5791300.0
2022-06-07  9434  1481.5  1482.0  1465.0  1465.0  7202900.0
"""

二つのstyleでサンプルをお見せします。
style を未指定(‘default’を指定するのと同じ) と、 ‘yahoo’を指定して出してみたのが次です。

import mplfinance as mpf
import matplotlib.pyplot as plt


mpf.plot(
    price_df,
    type="candle",
    title="ソフトバンク",
    ylabel="株価",
    ylabel_lower="出来高",
    volume=True,
)
plt.show()
mpf.plot(
    price_df,
    type="candle",
    title="ソフトバンク",
    ylabel="株価",
    ylabel_lower="出来高",
    volume=True,
    style='yahoo'
)
plt.show()

ご覧の通り、2枚目のstyle=’yahoo’の方は日本語が表示できていますが1枚目の未設定(デフォルト)の方は白い箱になっています。

一番簡単な対応は、日本語が使えるstyleを使うと決めてしまうことですね。お手軽なのでそれでも良いと思います。どのstyleなら使えるのかって判断は試すのが早いです。

ただ、僕はなぜこんな現象が起きるのか気になったので、ソースを読んで原因を調査しました。以降はその調査結果の話になります。

まず、mplfinance の style というのは、本来 mpf.make_mpf_style というメソッドを使って作った辞書によって指定するものです。毎回全部指定して作るのは大変なので、ライブラリでdefault とか yahoo といった手軽に使える設定のセットが用意されていて、ここまで使っていたのがそれです。その設定の中身なのですが、こちらのディレクトリのファイル群の中に記述されています。
参考: mplfinance/src/mplfinance/_styledata at master · matplotlib/mplfinance

_始まりのディレクトリなので、Pythonのお作法的には import されることは想定してないはずですが、次のようにして中身を見ることができます。

# style='default'の設定
print(mpf._styledata.default.style)
"""
{'style_name': 'default',
 'base_mpl_style': 'seaborn-darkgrid',
 'marketcolors': {'candle': {'up': 'w', 'down': 'k'},
  'edge': {'up': 'k', 'down': 'k'},
  'wick': {'up': 'k', 'down': 'k'},
  'ohlc': {'up': 'k', 'down': 'k'},
  'volume': {'up': '#1f77b4', 'down': '#1f77b4'},
  'vcedge': {'up': '#1f77b4', 'down': '#1f77b4'},
  'vcdopcod': False,
  'alpha': 0.9},
 'mavcolors': ['#40e0d0',
  '#ff00ff',
  '#ffd700',
  '#1f77b4',
  '#ff7f0e',
  '#2ca02c',
  '#e377c2'],
 'y_on_right': False,
 'gridcolor': None,
 'gridstyle': None,
 'facecolor': '#DCE3EF',
 'rc': [('axes.edgecolor', 'black'),
  ('axes.linewidth', 1.5),
  ('axes.labelsize', 'large'),
  ('axes.labelweight', 'semibold'),
  ('lines.linewidth', 2.0),
  ('font.weight', 'medium'),
  ('font.size', 12.0),
  ('figure.titlesize', 'x-large'),
  ('figure.titleweight', 'semibold')],
 'base_mpf_style': 'default'}
"""

# style='yahoo'の設定
print(mpf._styledata.yahoo.style)
"""
{'base_mpl_style': 'fast',
 'marketcolors': {'candle': {'up': '#00b060', 'down': '#fe3032'},
  'edge': {'up': '#00b060', 'down': '#fe3032'},
  'wick': {'up': '#606060', 'down': '#606060'},
  'ohlc': {'up': '#00b060', 'down': '#fe3032'},
  'volume': {'up': '#4dc790', 'down': '#fd6b6c'},
  'vcedge': {'up': '#1f77b4', 'down': '#1f77b4'},
  'vcdopcod': True,
  'alpha': 0.9},
 'mavcolors': None,
 'facecolor': '#fafafa',
 'gridcolor': '#d0d0d0',
 'gridstyle': '-',
 'y_on_right': True,
 'rc': {'axes.labelcolor': '#101010',
  'axes.edgecolor': 'f0f0f0',
  'axes.grid.axis': 'y',
  'ytick.color': '#101010',
  'xtick.color': '#101010',
  'figure.titlesize': 'x-large',
  'figure.titleweight': 'semibold'},
 'base_mpf_style': 'yahoo'}
"""

default の方は、font.weight とか font.size とか指定されていますが、yahooの方はそれはないですね。でもどちらもフォントを指定するfont.familyは指定されておらず、この両者でフォントの挙動が変わるのは不思議でちょっと悩みました。

結局わかったことは、base_mpl_style で指定されているmatplotlibのスタイルの影響で動きが変わってるってことでした。
default では seaborn-darkgrid が指定され、 yahoo では fast になっています。

matplotlibのリポジトリで確認すると、seaborn-darkgrid を指定すると、font.familyがsans-serifに書き換えられてしまうってことがわかりました。これによって、せっかく設定ファイルで指定してたIPAフォントが使えなくなってしまっていたのですね。
参考: matplotlib/seaborn-v0_8-darkgrid.mplstyle at main · matplotlib/matplotlib

一方で、fastの方 ではfont.familyが指定されていないので僕が設定ファイルで指定していたIPAフォントが使われていたようです。

以上で原因が分かりましたのでここから対応編です。
全体的にstyle=’default’のデザインを使いたくて、フォントだけ日本語にしたいのであれば、font.familyだけをもう一回設定し直したら良いのです。
次のようなコードで実現できました。引数の base_mpf_style はスペルミスしないように気をつけてください。 さっきまで話題にしてたbase_mpl_styleとは1文字だけ違います。

font.familyの新しい設定値は ‘IPAGothic’ とか ‘IPAexGothic’ とか直接指定しても大丈夫です。ただ、僕はAWS/EC2とMacでフォントが違って書き分けるの面倒なので、rcParamsから取得するようにしています。

# styleの設定値を作る
s = mpf.make_mpf_style(
    # 基本はdefaultの設定値を使う。
    base_mpf_style='default',
    # font.family を matplotlibに設定されている値にする。
    rc={"font.family": plt.rcParams["font.family"][0]},
)

mpf.plot(
    price_df,
    type="candle",
    title="ソフトバンク",
    ylabel="株価",
    ylabel_lower="出来高",
    volume=True,
    style=s,
)

これで以下の図が得られます。

これで、デフォルトのスタイルでも日本語文字が使えるようになりました。

mplfinanceの株価チャートに指標を追加する

前回紹介した、mplfinanceの使い方の続編です。前回はただ単純に四本値でローソクチャートを書きましたが、今回はそれに各種テクニカル指標等を追加する方法を紹介します。
参考: mplfinanceで株価チャートを描く

免責事項
本記事では株式や為替などの金融商品の価格に関するデータをサンプルとして利用しますが、効果や正当性を保証するものではありません。本ブログを利用して損失を被った場合でも一切の責任を負いません。そもそも、この記事ではライブラリを使って図に線や点を追加する方法を紹介しているだけであり、著者自身がこの記事で登場している指標の投資における有用性を検証していませんし、投資にも利用していません。あくまでもこういうコードを書いたらこう動くという例の紹介です。

おきまりの文章が終わったのでやってきましょう。前回同様、こんな感じのデータがあるとします。

print("データ件数:", len(price_df))
# データ件数: 182

print(price_df.head(5))
"""
              open    high     low   close   volume
date                                               
2022-01-04  3330.0  3340.0  3295.0  3335.0  75300.0
2022-01-05  3355.0  3370.0  3320.0  3360.0  62200.0
2022-01-06  3345.0  3370.0  3305.0  3305.0  67200.0
2022-01-07  3315.0  3330.0  3265.0  3295.0  84500.0
2022-01-11  3300.0  3300.0  3215.0  3220.0  87400.0
"""

前回はこれをただそのまま表示しましたが、今回はそれに追加して以下の情報を追加で書いていこうと思います。追記する場所として、四本値のローソク足のパネル、出来高のグラフのパネル、新しいパネルを用意してそこに書き込む、の3種類ができるということ、何かしらの指標を線で表示することや特定の点にマーカーをプロットできる、ということを例示するために以下のような例を考えました。

  • 20日間の高値、安値の線 (四本値のパネル)
  • 高値、安値を更新した点のプロット(四本値のパネル)
  • 過去3日間の出来高の合計(出来高のパネル)
  • 前日の終値と当日の終値の差分(新規のパネル)

まずプロットするデータを作ります。データは四本値のデータと同じ長さで、インデックスの日付が共通のDataFrameかSeriesである必要があります。高値線や安値線などのテクニカル指標の場合は大丈夫雨だと思うのですが、1~2ヶ所点をプロットしたいだけ、といった場合であっても、点をプロットない場所は全部NaN値を入れる形で、同じ長さのデータを作らなければなりません。それだけ気をつければ特に詰まるところはないと思います。
まぁ、元のデータのDataFrameに列を追加する形で作っていけば確実でしょう。

高値安値とその更新した点は次のように作りました。更新点はちょっと上下にずれた位置にプロットしたかったので、それぞれ1.02倍/0.98倍しています。

import pandas as pd
import numpy as np


# 高値線、安値線
price_df["h_line"] = price_df.rolling(20, min_periods=1).max()["high"]
price_df["l_line"] = price_df.rolling(20, min_periods=1).min()["low"]

# 高値、安値を更新した日付を算出
h_breakout_flg = price_df["h_line"].shift(1) < price_df["high"]
l_breakout_flg = price_df["l_line"].shift(1) > price_df["low"]

# 更新日にプロットする値を用意する。
price_df["h_breakout"] = np.nan
price_df["l_breakout"] = np.nan
price_df.loc[h_breakout_flg, "h_breakout"] = price_df.loc[h_breakout_flg, "high"] * 1.02
price_df.loc[l_breakout_flg, "l_breakout"] = price_df.loc[l_breakout_flg, "low"] * 0.98

出来高の3日の和と終値の前日との差分はそれぞれ次のように作れます。

price_df["volume_sum"] = price_df.rolling(3)["volume"].sum()
price_df["close_diff"] = price_df["close"].diff()

これで、サンプルデータが出揃ったので可視化していきます。ドキュメントは前回同様Githubのサンプルコードを参照します。今回見るのはこれです。
参考: mplfinance/addplot.ipynb at master · matplotlib/mplfinance

ドキュメントは頼りないので必要に応じてソースコードもみましょう。

チャートに指標を追加するには、plotメソッドを呼び出すときに、addplot 引数に追加したい指標の情報をdictで渡します。複数追加したい時は追加したい指標の数だけのdictをlistにまとめて渡します。(今回やるのはこちら)。

addplot に渡す辞書というのは以下の例のような結構大掛かりな辞書です。

{'data': date
 2022-01-04       NaN
 2022-01-05    3437.4
 2022-01-06       NaN
 2022-01-07       NaN
 2022-01-11       NaN
                ...  
 2022-09-26       NaN
 2022-09-27       NaN
 2022-09-28       NaN
 2022-09-29       NaN
 2022-09-30       NaN
 Name: h_breakout, Length: 182, dtype: float64,
 'scatter': False,
 'type': 'scatter',
 'mav': None,
 'panel': 0,
 'marker': '^',
 'markersize': 18,
 'color': None,
 'linestyle': None,
 'linewidths': None,
 'edgecolors': None,
 'width': None,
 'bottom': 0,
 'alpha': 1,
 'secondary_y': 'auto',
 'y_on_right': None,
 'ylabel': None,
 'ylim': None,
 'title': None,
 'ax': None,
 'yscale': None,
 'stepwhere': 'pre',
 'marketcolors': None,
 'fill_between': None}

このdictデータを自分で作るのは大変です。そこで、mplfinance が専用のメソッド、make_addplot というのを持っているのでこれを使います。これを使って、追加する指標のデータと、どのグラフに書き込むのか(panel)可視化の方法(type, ‘line’, ‘bar’, ‘scatter’, ‘step’から選択)、マーカーや線のスタイル、大きや色、などの情報と合わせて渡すことでデータを作ってくれます。 (メインの四本値のデータよりそこに加筆する指標を先にライブラリに渡すのって微妙に直感的で無くて使いにくいですね。)

例えば、以下のようにすることで加筆データを生成できます。make_addplotに指定できる引数は上のサンプル辞書のkeyを見るのが早いと思います。だいたいイメージ通り動きます。

import mplfinance as mpf


adp = [
    # 高値安値線。 panel=0 と指定し、四本値と同じパネルを指定
    mpf.make_addplot(price_df[["h_line", "l_line"]], type='step',panel=0),
    # 高値更新位置。 scatter を指定し、markerで上向三角形を指定
    mpf.make_addplot(price_df["h_breakout"], type='scatter',panel=0, marker="^"),
    # 安値更新位置。 scatter を指定し、markerで下向三角形を指定
    mpf.make_addplot(price_df["l_breakout"], type='scatter',panel=0, marker="v"),
    # 出来高の和。 panel=1 とすると 出来高のパネルを指定したことになる。 line を指定して折れ線グラフに。
    mpf.make_addplot(price_df["volume_sum"], type='line',panel=1, linestyle="--", color="g"),
    # 終値の前日差分。棒グラフのサンプルも欲しかったのでbarを指定。panel=2とすると新規のパネルが追加される
    mpf.make_addplot(price_df.close.diff(), type='bar',panel=2),
]

さて、これで加筆データができました。 注意する点としてはpanel ですね。panel=0は四本値のグラフで固定ですが、panel=1は次のチャート描写のメソッドで、出来高の表示をするかどうか指定するvolume引数の値で挙動が変わります。Trueなら、出来高のグラフがpanel=1で、panel=2以降が新規のグラフです。一方Falseなら、panel=1から新規のグラフです。番号を飛ばすことができず、panel=1がないとpanel=2は指定できないので注意してください。

では、前回のグラフに追加して、上記のadpの値も渡してみます。今回、パネルが3個になりますが、panel_ratios でそれぞれの幅の調整ができます。メインの四本値のパネルを大きめにしておきました。

mpf.plot(
    price_df,
    volume=True,  # 出来高も表示
    mav=[10, 20],  # 移動平均線
    addplot=adp,  # 追加指標
    figratio=(3, 2),  # 図全体の縦横比
    panel_ratios=(2, 1, 1),  # パネルの縦幅の比率
)

出来上がった図形がこちらです。

いい感じですね。細かい話ですが、高値安値線はtypeをstepにしてカクカクした線にしていて、高値の方に引いた線はtypeをlineにして斜めにつながる線にしています。好みの問題ですがこういう微調整ができるのが良いですね。

最初はデータの準備等に戸惑ったり、思うような微調整に苦戦したりするかもしれませんが、一回作ってしまうと、あとは銘柄を入れ替えたり期間を変えたりしながらパラパラといろんな検証ができます。scatterで自分の仕掛けと手仕舞いのポイント等を入れて検証したりってことも可能ですね。

mplfinanceで株価チャートを描く

以前、Pythonでローソク足のチャートを描く方法を紹介しました。
参考: pythonでローソク足を描く

この時は、mpl_financeという既にメンテナンスされていないライブラリを使っていました。まぁ、個人的にちょっと使う分には問題ないのですがやはりちょっと不安になりますよね。

その一方で、実は株価チャートを描く別のライブラリがあることがわかりました。それが、
mplfinance です。 名前が非常に似ていますが、これはアンダーバー(_)がありません。
さっき見たらGithubに15時間前のコミットがあり、しっかりメンテナンス継続中のようです。

ドキュメントとなるのは以下の二つでしょうか。Githubのチュートリアルや、サンプルコードが比較的充実しているのでこちらが使いやすそうですね。
参考1 (Github): matplotlib/mplfinance: Financial Markets Data Visualization using Matplotlib
参考2(PyPI): mplfinance · PyPI

今回はこれのチュートリアルを見ながら一番基本的なローソク足と、出来高、あとついでに移動平均線でも書いてみましょう。

まずはデータの準備です。例えば次のような4本値データがあったとしましょう。データ件数とサンプルとして最初の10行表示しています。チャートを描くので、日付と、始値、高値、安値、終値が必須で、出来高がオプションです。

print("データ件数:", len(price_df))
# データ件数: 81
print(price_df.head(10))
"""
         date    open    high     low   close    volume
0  2022-04-01  3710.0  3725.0  3670.0  3715.0  113600.0
1  2022-04-04  3685.0  3730.0  3675.0  3725.0   94200.0
2  2022-04-05  3705.0  3725.0  3675.0  3675.0  113900.0
3  2022-04-06  3650.0  3680.0  3625.0  3635.0  163800.0
4  2022-04-07  3620.0  3660.0  3615.0  3630.0   98900.0
5  2022-04-08  3700.0  3730.0  3660.0  3700.0  135100.0
6  2022-04-11  3840.0  4060.0  3810.0  4045.0  494900.0
7  2022-04-12  4030.0  4210.0  4015.0  4120.0  403000.0
8  2022-04-13  4115.0  4165.0  4080.0  4155.0  184600.0
9  2022-04-14  4085.0  4120.0  4020.0  4065.0  240900.0
"""

これをライブラリが要求する形にする必要があります。まず、日付(date)がデータフレームのインデックスに指定されていないといけません。また、データ型も文字列ではダメで、DatetimeIndexである必要があります。その変形をします。

price_df["date"] = pd.to_datetime(price_df["date"])
price_df.set_index("date", inplace=True)

print(price_df.head(5))
"""
              open    high     low   close    volume
date                                                
2022-04-01  3710.0  3725.0  3670.0  3715.0  113600.0
2022-04-04  3685.0  3730.0  3675.0  3725.0   94200.0
2022-04-05  3705.0  3725.0  3675.0  3675.0  113900.0
2022-04-06  3650.0  3680.0  3625.0  3635.0  163800.0
2022-04-07  3620.0  3660.0  3615.0  3630.0   98900.0
"""

また、データの各列の名前は[“Open”, “High”, “Low”, “Close”, “Volume”] (“Volume”は無くても可)にするよう指定されています。日本語で”始値”とか入っている場合は変換しましょう。
先頭を大文字にしないといけないのかな?と思ったのですが、全部小文字でも動くことが確認できているので、今回はこのまま使います。

なんか、ソースコードを見ると多くの人が要求したから小文字にも対応してくれたそうです。
ここ

    # We will not be fully case-insensitive (since Pandas columns as NOT case-insensitive)
    # but because so many people have requested it, for the default column names we will
    # try both Capitalized and lower case:
    columns = config['columns']
    if columns is None:
        columns =  ('Open', 'High', 'Low', 'Close', 'Volume')
        if all([c.lower() in data for c in columns[0:4]]):
            columns =  ('open', 'high', 'low', 'close', 'volume')

さて、これでデータが揃いました。

ここからの使い方はとても簡単で、こだわりがなければライブラリをimportして、plotってメソッドに渡すだけです。

import mplfinance as mpf

mpf.plot(price_df)

いわゆるOHLCチャートが出力されました。

ここから少しカスタマイズしていきます。

まず、チャートをローソク足にするのは、type=”candle”です。”candle”の他にはデフォルトの”ohlc”や、”line”, “renko”, “pnf” が指定できます。それぞれイメージ通りの出力が得られるので興味がある方は試してみてください。

出来高の追加は volume=True を指定します。

また、移動平均線は、mav 引数で指定します。整数を一つ指定すればその日数の移動平均が1本、配列等で複数の整数を渡して数本引くこともできます。

チャート全体をもう少し横長にしたいなど、縦横比率を変えたい場合は、figratioで指定します。デフォルトは(8.00,5.75) です。どうも名前の通り、数値の比だけが重要のようです。(4, 2)と(2, 1)の結果が一緒でした。

以上、やってみます。

mpf.plot(
    price_df,
    type="candle",
    volume=True,
    mav=[5, 12],
    figratio=(2, 1),
)

出力がこちらです。

いい感じですね。

この他にも見栄えを整えるオプションは多数用意さてれいるのですが、いくつかの組み合わせがstyleとして用意されています。style=”yahoo”なんてのもあります。
こちらのページにサンプルがまとまっているので、気に入ったのがあったら使ってみるのも良いと思います。(僕は一旦上の、style=”default”でいいかな。)

今回はデータを渡すだけでサクッとチャートを描いてくれるmplfinanceの基本的な使い方を紹介しました。近いうちの記事で、これに別のテクニカル指標を表示したり線や点を追加するなどのカスタマイズ方法を紹介していきたいと思います。

Jupyter notebookのファイルをコマンドラインで実行する

Jupyter notebookのファイル (.ipynbファイル)をそのまま実行したい、って場面は結構あります。notebookファイルから通常のPythonファイル(.pyファイル)に変換しておけばいいじゃないか、という意見もあると思いますし、それはそれでごもっともです。ただ、僕個人の事例で言うと、個人的に開発してるツールの中に土日に触る時はちょっとずつ編集して改良して実行し、平日はそのまま全セルを実行するだけってnotebookファイルなどもあります。そのようなファイルについて、逐一上から順番にnotebookのセルを実行していくのはやや面倒です。

と言うことで、.ipynbファイルをコマンドラインからバッチのように実行できると便利、ってことでその方法を紹介していきます。

Google等で検索するとよく出てくる方法と、もう一つ、ドキュメントを読んでいて見つけた方法があるのでそれぞれ紹介します。後者の方法の方が手軽なので、まずそちらを書きます。

jupyter execute コマンドを使う方法

一つ目に紹介する方法は、jupyter execute コマンドです。
ドキュメントはこちら。
参考: Executing notebooks — nbclient – Using a command-line interface

これはすごく簡単で、以下のコマンドで実行するだけです。

$ jupyter execute {ファイル名}.ipynb
# 以下出力
[NbClientApp] Executing {ファイル名}.ipynb
[NbClientApp] Executing notebook with kernel: python3

コマンド名は直感的でわかりやすくて記述量も少なくて僕は気に入っています。

ただし、注意点があってこの方法でnotebookを実行しても元のnotebookファイルは更新されません。つまりどう言うことかと言うと、notebook内の出力領域に表示されるはずの情報は残らないと言うことです。printしたテキストとか、matplotlib等で表示した画像などは見れず、ただプログラムが走るだけと言う状態になります。

そのため、この方法でnotebookを実行する場合は必要な出力はnotebookの外部に保存するように作っておく必要があります。必要な結果はファイルに書き出すとかDBに保存するような実装にしておきましょう。

次に紹介する方法(ググるとよく出てくる方法)では、実行結果の出力を残せるので、このexexuteコマンドでも何かオプションを指定したら実行結果を残せるだろうと思って探してたんですが、どうも今日時点ではそのような機能は実装されていなさそうです。今後に期待したいところです。

全体的にオプションも少なく、その中でも実際使えるものというと実質的に次の二つだけかなと思います。

# $ jupyter execute --help の出力結果から抜粋
--allow-errors
    Errors are ignored and execution is continued until the end of the notebook.
    Equivalent to: [--NbClientApp.allow_errors=True]
--timeout=<Int>
    The time to wait (in seconds) for output from executions. If a cell
    execution takes longer, a TimeoutError is raised. ``-1`` will disable the
    timeout.
    Default: None
    Equivalent to: [--NbClientApp.timeout]

–allow-errors をつけると、エラーが発生してもそれ以降のセルも実行されるようになります。これをつけてない場合は、エラーになったセルがあればそれ以降のセルは実行されません。
試してみたのですが、–allow-errorsをつけていると、エラーになったセルがあってもそのエラー文等は表示されないので、リスクを伴うオプションだと思います。エラーになったらその旨を外部のログに残す実装になっていないと自分で気づく手段がありません。なお、–allow-errorsをつけてない場合、エラーになるセルがあったらそこで標準エラー出力にエラーを表示して止まるので気付けます。

–timeout の方はデフォルトでタイムアウト無しになっているのであまり気にしなくても良いかと思うのですが、異常に長く時間がかかるリスクがある場合などは設定しても良いでしょう。

jupyter nbconvert コマンドを使う方法

次に紹介するのは、 jupyter nbconvert コマンドを使う方法です。jupyter notebookをコマンドライン(CUI)で使う方法として検索するとよく出てくるのはこちらの方法です。

nbconvert 自体は、notebookを実行するコマンドじゃなくて、別の形式に変換するコマンドなので、正直これをnotebookの実行に使うのって抵抗あるのですが、どういうわけかこちらの方がいろんなオプションが充実していて、実行専用と思われる先ほどの jupyter execute コマンドよりも柔軟な設定が必要です。詳細は不明ですが歴史的な経緯か何かによるものでしょうか。

ドキュメントはこちら
参考: Executing notebooks — nbconvert 7.1.0.dev0 documentation

基本的な使い方は次のようになります。–to でファイルの変換先のタイプを指定するのですが、そこでnotebookを指定して、さらに–execute をつけると実行されます。

$ jupyter nbconvert --to notebook --execute {ファイル名}.ipynb
# 以下出力
[NbConvertApp] Converting notebook {ファイル名}.ipynb to notebook
[NbConvertApp] Writing {ファイルサイズ} bytes to {ファイル名}.nbconvert.ipynb

上記の出力をみていただくと分かる通り、実行した結果を、{ファイル名}.nbconvert.ipynb という新しいファイルに書き出してくれています。これの内容がセルを(空のセルを飛ばしながら)上から順番に実行した結果になっていて、こちらの方法であればnotebookの出力領域にprintした文字列やmatplotlibの画像なども残すことができます。

細かいオプションについては、 jupyter nbconvert –help で確認可能ですが、 先ほども書きましたがexecuteよりもたくさんあります。

–allow-errors は同じように指定できますし、 –output {ファイル名} で、書き込み先のファイル名を変更することも可能です。
ちなみにデフォルトだと、上記の実行例の通り{ファイル名}.nbconvert.ipynbに書き込みますが、既に同名のファイルが存在した場合は上書きしてしまいます。そのため、毎回の実行履歴を残しておきたいならば出来上がったファイルを退避しておくか、–outputオプションで別の名前をつける必要があるでしょう。
–inplace をつけて、別ファイルに書き出すのではなくて、元のファイルを置き換えるなども可能です。この辺の細かい調整を行えるのがnbconvertの方を使える利点ですね。executeの方にも実装していただきたいものです。

まとめ

以上で、jupyter notebookファイルをコマンドラインで実行する方法を二つ紹介してきました。それぞれメリットデメリットあるので用途に応じて便利な方を使っていただけたらと思います。

Pythonで線形和割り当て問題を解く

昔、あるアルゴリズムを実装する中で使ったことがある、 linear_sum_assignment っていうscipyのメソッドを久々に使うと思ったら使い方を忘れていたのでその復習を兼ねた記事です。

これは、2部グラフの最小重みマッチングとも呼ばれている問題で、要するに、二つのグループの要素からそれぞれ1個ずつ選んだペアにコストが定義されていて、どのように組み合わせてペアを選んでいったらコストの和を最小にできるかという問題です。

この説明はわかりにくいですね。もう少し具体的なのがいいと思うので、Scipyのドキュメントで使われている例を使いましょう。

Scipyのドキュメントではworker(作業者)とjob(仕事)を例に解説されています。
参考: scipy.optimize.linear_sum_assignment — SciPy v1.9.1 マニュアル

例えば、4人の作業者がいて4つの仕事があったとします。そして、その4人がそれぞれの仕事をした場合に、かかる時間(=コスト)が次のように与えられていたとします。行列形式ですが、i行j列の値が、作業者iが仕事jを実行した場合にかかるコストです。(例を乱数で作りました。)

import numpy as np


np.random.seed(0)
cost = np.random.randint(1, 10, size=(4, 4))
print(cost)

"""
[[6 1 4 4]
 [8 4 6 3]
 [5 8 7 9]
 [9 2 7 8]]
"""

cost[1, 2] = 6 ですが、これは作業者1が仕事2を行った場合のコストが6ということです。
(インデックスが0始まりであることに注意してください。cost[1, 2]は2行3列目の要素です。)

さて、上の図を見ての通り、作業者ごとに仕事の得手不得手があり、コストが違うようです。そこで、これらの仕事をそれぞれ誰が担当したらコストの総和を最小にできるでしょうか、というのが線形和割り当て問題です。

これが、先ほどの linear_sum_assignment を使うと一発で解けます。

ドキュメントにある通り、戻り値が行のインデックス、列のインデックスと帰ってくるので注意してください。

from scipy.optimize import linear_sum_assignment


row_ind, col_ind = linear_sum_assignment(cost)
print("行:", row_ind)
print("列:", col_ind)
"""
行: [0 1 2 3]
列: [2 3 0 1]
"""

二つのarray(プリントしてるのでlistに見えますがnumpyのArrayです)が戻ってきます。
これが、worker0がjob2を担当し、worker1がjob3を担当し、、、と読んでいきます。
これがコストを最小にする組み合わせです。簡単でしたね。

さて、値の戻ってき方がちょっと独特だったのでプログラムでこれを使うにはコツが要ります。こう使うと便利だよ、ってところまでドキュメントに書いてあると嬉しいのですが、書いてないので自分で考えないといけません。

インデックスとして返ってきているので、次のようにコスト行列のインデックスにこの値を入れると、最適化された組み合わせのコストが得られます。そして、sum()すると合計が得られます。以下の通り、14が最小ということがわかります。

print(cost[row_ind, col_ind].sum())
# 14

Scipyの実装を疑うわけではないのですが、念の為、本当にこの組み合わせが最適で14が最小なのか、全組み合わせ見ておきましょう。itertools.permutationsを使います。

from itertools import permutations


for perm in permutations(range(4)):
    print(list(perm), "=>", cost[range(4), perm].sum())
"""
[0, 1, 2, 3] => 25
[0, 1, 3, 2] => 26
[0, 2, 1, 3] => 28
[0, 2, 3, 1] => 23
[0, 3, 1, 2] => 24
[0, 3, 2, 1] => 18
[1, 0, 2, 3] => 24
[1, 0, 3, 2] => 25
[1, 2, 0, 3] => 20
[1, 2, 3, 0] => 25
[1, 3, 0, 2] => 16
[1, 3, 2, 0] => 20
[2, 0, 1, 3] => 28
[2, 0, 3, 1] => 23
[2, 1, 0, 3] => 21
[2, 1, 3, 0] => 26
[2, 3, 0, 1] => 14
[2, 3, 1, 0] => 24
[3, 0, 1, 2] => 27
[3, 0, 2, 1] => 21
[3, 1, 0, 2] => 20
[3, 1, 2, 0] => 24
[3, 2, 0, 1] => 17
[3, 2, 1, 0] => 27
"""

どうやらあってそうですね。

col_ind の方を使って、行列を並び替えることもできます。i行目のworkerがi列目のjobを担当する直感的に見やすい行列が次のようにして得られます。

print(cost[:, col_ind])
"""
[[4 4 6 1]
 [6 3 8 4]
 [7 9 5 8]
 [7 8 9 2]]
"""

また、解きたい問題や実装によっては、この行と列の対応を辞書にしたほうが使いやすいこともあるでしょう。そのような時はdictとzipで変換します。

print(dict(zip(row_ind, col_ind)))
# {0: 2, 1: 3, 2: 0, 3: 1}

ここまでの例では、与えられた行列はコストの行列でこれを最小化したい、という問題設定でやってきました。ただ、場合によっては利益やスコアの行列が与えられて、最大化する組み合わせを探したいという場合もあると思います。行列にマイナス掛けて同じことすればいいのですが、linear_sum_assignment自体にもそれに対応した引数があります。

それが、maximize で、 デフォルトはFalseですが、Trueにすると最大化を目指すようになります。同じ行列でやってみます。さっき全パターン列挙しているので正解はわかっていて、[0, 2, 1, 3]か[2, 0, 1, 3]のどちらかが得られるはずです。

print(linear_sum_assignment(cost, maximize=True))
# (array([0, 1, 2, 3]), array([0, 2, 1, 3]))

[0, 2, 1, 3]の方が出ててきましたね。

ここまで、正方行列を取り上げてきましたが、linear_sum_assignment は、一般行列についても実行できます。行と列の数が違う場合は、行と列のうち数が小さい方に揃えて、実行されます。

まず、行が多い(workerが多い)場合をやってみましょう。7行4列で、7人のworkerがいて、jobが4つあって、コストがそれぞれ定義されていた場合に、どの4人を選抜してそれぞれにどの4つのタスクをやってもらうのが最適か、という問題を解くのと対応します。

np.random.seed(0)
cost = np.random.randint(1, 10, size=(7, 4))
print(cost)
"""
[[6 1 4 4]
 [8 4 6 3]
 [5 8 7 9]
 [9 2 7 8]
 [8 9 2 6]
 [9 5 4 1]
 [4 6 1 3]]
"""

row_ind, col_ind = linear_sum_assignment(cost)
print("行:", row_ind)
print("列:", col_ind)
"""
行: [0 2 5 6]
列: [1 0 3 2]
"""

次に同様に横長の行列の場合です。例えば4人のworkerがいて7つのjobがあったときに、どの4つのjobを選んで実行したら利益を最大化できるか、って問題がこれに相当します。(最小化でいい例が思いつかなかったのでこれは最大化でやります。)

np.random.seed(0)
score = np.random.randint(1, 10, size=(4, 7))
print(score)
"""
[[6 1 4 4 8 4 6]
 [3 5 8 7 9 9 2]
 [7 8 8 9 2 6 9]
 [5 4 1 4 6 1 3]]
"""

row_ind, col_ind = linear_sum_assignment(score, maximize=True)
print("行:", row_ind)
print("列:", col_ind)
"""
行: [0 1 2 3]
列: [4 5 3 0]
"""

以上が linear_sum_assignment の基本的な使い方になります。