numpyで重み付き平均

つい最近まで、numpyやpandasには重み付き平均を求める関数は無いと勘違いしていて、
必要な時は自分で実装したのを使っていました。

データと重みが numpy の array で渡される場合だけ対応するのであればこのような関数で計算できます。
(listなどにも対応しようと思うとこれでは動きません)


def weighted_mean(data, weights):
    return np.sum(data * weights) / np.sum(weights)

しかしよくよく調べてみると、いつも使っている numpy.mean のほかにも、
numpy.averageという関数があって、これは引数にweightsを渡せるでは無いですか。
(averageの方が常に優秀というわけではなく、 meanにしか無い引数もあります。)

参考:
numpy.average
numpy.mean

numpy.average を使うと重み付き平均を手軽に計算できます。
せっかくなので適当なデータについて上の関数と結果を見比べましょう。
(ついでに計算直書きしたのも並べて確認しました。)


import numpy as np
data = np.array([1, 3, 7])
weights = np.array([5, 12, 2])
print(weighted_mean(data, weights))
# 2.8947368421052633
print(np.average(data, weights=weights))
# 2.8947368421052633
print((1*5+3*12+7*2)/(5+12+2))
# 2.8947368421052633

完全一致してますね。

numpyのarrayを並び替えた結果をインデックスで取得する

データを大きい順や小さい順に並び替えることはよくある操作ですが、
その結果として、”n番目の値は何だったのか?”よりも、”何番目の要素がn番目だったのか?”を知りたいケースは地味にあります。
[241,631,362,222,2,44] のようなデータがあって、 最大値は631だってことよりも、
最大値のindexは1だ(0から始まることに注意)ってことを得たい場合ですね。

そのような時、ソートした結果をインデックスのリストで得られると便利です。
それは numpyのargsortで得られます。
numpy.argsort

通常の並べ替え結果を返してくれる sort とそれぞれ実行してみましょう。

それぞれドキュメントを見るといくつか引数が用意されていて、多次元の場合の挙動や、ソートアルゴリズムなどを指定できます。
しかし、自分にとってはその中に昇順/降順の指定が”ない”ことの方が重要です。
デフォルトで昇順にソートするので、降順がいい時はどは別途指定します。

それでは、sortとargsortを昇順降順で試します。


import numpy as np
data = [241, 631, 362, 222, 2, 44]

print(list(np.sort(data)))
# [2, 44, 222, 241, 362, 631]
print(list(np.argsort(data)))
# [4, 5, 3, 0, 2, 1]

# 降順にしたい時は[::-1]を使う
print(list(np.sort(data))[::-1])
# [631, 362, 241, 222, 44, 2]
print(list(np.argsort(data))[::-1])
# [1, 2, 0, 3, 5, 4]

うまく動いていますね。

このブログでもすでに次の記事のコード中で利用しています。
参考:pythonでトピックモデル(LDA)

numpyで配列内の値を特定の値に制限する

前の記事の最後で、
異常時の前処理として、1〜99パーセンタイルでクリップするって話を少し書いたので、
それをnumpyで実現する関数の紹介です。
と言っても、わざわざ専用関数を使わなくても容易に実装できるのですが、せっかく用意されているのがあるので知っておくと便利です。

ドキュメントはこちら。
numpy.clip

np.clip(配列, 最小値, 最大値)と指定すると、
配列の値のうち、区間[最小値, 最大値]からはみ出た値を、その範囲に収まるように区切ってくれます。

ためしに、標準正規分布に従う値20個を生成して、[-1, 1]の範囲にクリッピングしてみましょう。


import numpy as np
data = np.random.randn(20)
print(data)
'''
[-1.71434343  0.33093523 -0.0648882   1.34432289 -0.15426638 -1.05988754
 -0.41423379 -0.8896041   0.12403786  1.40810052  0.61199047  1.60193951
 -0.72897283 -0.00861939 -0.38774556  0.40188148  0.08256356  1.61743754
 -0.12320721  1.45184382]
'''
data = np.clip(data, -1, 1)
print(data)
'''
[-1.          0.33093523 -0.0648882   1.         -0.15426638 -1.
 -0.41423379 -0.8896041   0.12403786  1.          0.61199047  1.
 -0.72897283 -0.00861939 -0.38774556  0.40188148  0.08256356  1.
 -0.12320721  1.        ]
'''

-1 より小さかった値は-1に、 1より大きかった値は1になりました。

ちなみに下記のコードでも同じことができます。


data[data < -1] = -1
data[data > 1] = 1

前回紹介した、percentileと組み合わせて使うことで、
nパーセンタイルからmパーセンタイルにクリップするということも簡単に実現できます。

試しに 5〜95パーセンタイルにクリップしたのが次のコードです。


data = np.random.randn(10)
print(data)
'''
[-0.41127091 -1.34043164  0.09598778 -1.19662011 -0.04607188 -0.02745831
  0.23184919  0.85601106  0.58430572  0.88205005]
'''
c_min, c_max = np.percentile(data, [5, 95])
print(c_min, c_max)
'''
-1.2757164503037743 0.8703325037861378
'''
data = np.clip(data, c_min, c_max)
'''
[-0.41127091 -1.27571645  0.09598778 -1.19662011 -0.04607188 -0.02745831
  0.23184919  0.85601106  0.58430572  0.8703325 ]
'''
print(data)

この例だけ見てもありがたみを感じないのですが、実際のデータを決定木などにかける時、
ほんの数件のデータだけ極端な外れ値になっていたりすると、
いい感じの範囲にデータを収めることができるので便利です。

また、scikit-learnなどのライブラリのコードを見てみると、
値を 0より大きく1より小さい範囲に収める目的などでも使われています。
ここなど
n以上m以下、ではなくnより大きいmより小さい、で区切る時は便宜上、eps=1e-15のような非常に小さい値を用意して、
[n+eps, m-eps]で代用するようですね。
こういう書き方も参考になります。

numpyのpercentile関数の仕様を確認する

中央値や四分位数を一般化した概念に分位数ってのがあります。
その中でも特にq/100分位数をqパーセンタイルといい、numpyに専用の関数が用意されています。
numpy.percentile

データの可視化や外れ値の除外で使うためにこれの仕様を確認したのでそのメモです。

そもそも僕が何を疑問に思ったのかを説明したほうがいいと思うので、いくつか例を紹介します。

まずわかりやすい例で50パーセンタイル。
これは、奇数個の値があればその中央の値、偶数個の値に対しては、真ん中の二つの値の中点を返します。


import numpy

# 5個の値の3番目の数を返す
data_1 = np.array([3, 12, 3, 7, 10])
print(np.percentile(data_1, 50))  # 7.0

# 6個の値の3番目の数と4番目の数の平均を返す
data_2 = np.array([3, 12, 3, 7, 10, 20])
print(np.percentile(data_2, 50))  # 8.5

同様にして、区切りのいい値がある時のパーセンタイルは非常にわかりやすい。
11個の値があれば、それぞれ順番に 0パーセンタイル, 10パーセンタイル, … 90パーセンタイル, 100パーセンタイルです。


data_3 = np.random.randint(0, 2000, 11)
print(data_3)
# 出力
# [1306  183 1323  266  998 1263 1503 1986  250  305 1397]
for p in range(0, 101, 10):
    print(p, "パーセンタイル・・・", np.percentile(data_3, p))
# 出力
'''
0 パーセンタイル・・・ 183.0
10 パーセンタイル・・・ 250.0
20 パーセンタイル・・・ 266.0
30 パーセンタイル・・・ 305.0
40 パーセンタイル・・・ 998.0
50 パーセンタイル・・・ 1263.0
60 パーセンタイル・・・ 1306.0
70 パーセンタイル・・・ 1323.0
80 パーセンタイル・・・ 1397.0
90 パーセンタイル・・・ 1503.0
100 パーセンタイル・・・ 1986.0
'''

ここまではわかりやすいのですが、自分が疑問に思ったのは、
もっと中途半端なパーセンタイルです。

(例)この出力の40.16ってどうやって算出された?


data_4 = np.array([15, 52, 100, 73, 102])
print(np.percentile(data_4, 17))
#  出力
# 40.16

この疑問放置したままなのが気持ち悪かったので、
これまでパーセンタイルや四分位数、そしてこれらを使う箱ひげ図などを使わなかったのですが、
とあるタスクの中で箱ひげ図を使いたくなったのでこの機会に仕様を確認しました。

といっても、numpyの該当ページにもNote.として記されていますし、
wikipediaにも普通に載ってます。
分位数
あと、pを1刻みで動かして適当なデータに対してパーセンタイル算出してプロットしたら明快にわかりました。

要は、中途半端な値に対しては、隣接の2つの値を線形補完して求めるそうです。
上の例で言えば、
15が0パーセンタイル、52が25パーセンタイルなので、17パーセンタイルは
$(52-15)*17/25+15=40.16$ と計算されています。
仕様がわかったのでこれからはバシバシ使おう。

機械学習を行う時、異常時の前処理として、1〜99パーセンタイルでクリップすると有効なことがあるという話を最近聞いたので、
それも試してみたいです。

numpyの配列をファイルに保存する

日常の実際で必要になったことはないのですが、
numpyに配列(ndarray)をファイルに保存する機能があるのでその紹介です。

(実用上、ファイルに保存したくなるようなデータは pandasのデータフレームに変換したあと、
to_csvやto_excelを使ってcsvかエクセルにしてしまうことが多いです。)

ドキュメントはこの辺り
numpy.save
numpy.load
numpy.savez

簡単に言ってしまえば、
numpy.save(ファイル名, 保存したい配列) で保存して、
numpy.load(ファイル名) で読み込める。
numpy.savez(ファイル名, (名前=)保存したい配列, (名前=)保存したい配列, …) で、
1ファイルに複数配列保存できる、と言った使い方になります。(名前=)は省略可能。

実際にやってみましょう。
まず検証用にダミーデータを作ります。


import numpy as np

# 保存のテスト用データを作成
data0 = np.arange(12).reshape(3, 4)
data1 = data0 ** 2
print(data0)
'''
[[ 0  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]]
'''
print(data1)
'''
[[  0   1   4   9]
 [ 16  25  36  49]
 [ 64  81 100 121]]
'''

まずは、save関数で配列を一つ保存し、それを別の変数に読み込んでみましょう。
特に難しいことはなく非常に直感的な使い方で上手く動きます。


# .npyフォーマットで保存
np.save("data0.npy", data0)

# 読み込み
data0_load = np.load("data0.npy")
print(data0_load)
'''
[[ 0  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]]
'''

続いて、savez関数で複数配列を1ファイルに保存し、復元してみます。
こちらは、loadした時に返されるオブジェクトが保存した配列そのものではないので注意です。
まずは名前をつけずに保存するパターン。


# savez を使うと複数の配列を.npzフォーマットの1ファイルにまとめて保存できる
np.savez("data0_1.npz", ary0, ary1)

# .npzファイルを読み込むと、配列ではなく、NpzFile オブジェクトが返される。
npzfile = np.load("data0_1.npz")
# .filesプロパティを見ると、中に二つの配列を持っていることがわかる
print(npzfile.files)
# ['arr_0', 'arr_1']

# それぞれの配列の取り出し
print(npzfile["arr_0"])
'''
[[ 0  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]]
'''
print(npzfile["arr_1"])
'''
[[  0   1   4   9]
 [ 16  25  36  49]
 [ 64  81 100 121]]
'''

また、名前をつけて保存すると次のようになります。


# 名前(例ではxとy)をつけて保存することも可能
np.savez("named_data0_1.npz", x=ary0, y=ary1)
# .npzファイルを読み込むと、配列ではなく、NpzFile オブジェクトが返される。
npzfile2 = np.load("named_data0_1.npz")
# .filesプロパティを見ると、中に二つの配列を持っていることがわかる
print(npzfile2.files)
# ['x', 'y']

# 保存時の名前で取り出すことが可能
print(npzfile2["x"])
'''
[[ 0  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]]
'''
print(npzfile2["y"])
'''
[[  0   1   4   9]
 [ 16  25  36  49]
 [ 64  81 100 121]]
'''

勝手にarr_0とか命名されるよりも、自分で名前をつけておいた方が混乱がなさそうですね。

pythonで累積和

数列に対して、最初の項からn番目の項まで足したものの数列が必要になることがあります。
日々の売上データからその日までの累計の売上データを出すような計算ですね。

イメージとしては、
1,2,3,4,5,…に対して、1,3,6,10,15,… を得るような操作です。

スライスと内包表記を組み合わせてもいいし、足し算くらいならfor文を回しても問題ないのですが、
numpyやpandas に専用の関数が用意されているのでその紹介です。

ドキュメントはこの辺
numpy.cumsum
pandas.Series.cumsum

早速実行してみました。


import numpy as np
import pandas as pd

ary = np.arange(1, 11)
print(ary)
# [ 1  2  3  4  5  6  7  8  9 10]

print(ary.cumsum())
# [ 1  3  6 10 15 21 28 36 45 55]

series = pd.Series(np.arange(1, 11))
print(series)
'''
0     1
1     2
2     3
3     4
4     5
5     6
6     7
7     8
8     9
9    10
dtype: int64
'''

print(series.cumsum())
'''
0     1
1     3
2     6
3    10
4    15
5    21
6    28
7    36
8    45
9    55
dtype: int64
'''

よく似た関数として、
numpyには累積の積を返すcumprodが用意されており、
さらにpandasの方にはcumprodに加えて、そこまでの最大値と最小値を得られるcummax, cumminが用意されています。

numpyの乱数生成関数の設計について

このブログに登場するコードでも頻繁にnumpyで乱数を生成していますが、
そのドキュメントは一回読んでおいたほうがいいよという話です。

Random sampling (numpy.random)

pythonの学習を始めた頃は、本に載っているサンプルコードや検索したらでてくる各サイトを参考に、
個々の関数の使い方を覚えて使ってました。

例えばrandであれば次のような感じ。


import numpy as np
# スカラーで一つ値が欲しい場合
np.random.rand()
# 配列で5個値が欲しい場合
np.random.rand(5)
# 引数を複数指定すると多次元配列で乱数が生成される
np.random.rand(2, 2, 2)

標準正規分布に従う乱数が欲しい時はrandnがあり、randと同じように使えます。

その一方で、normalという関数も準備されていて、
これは使い方が違います。

引数が
normal([loc, scale, size])となっていて、
最初に平均と標準偏差を与える必要があり、
欲しい乱数の個数は3個目の引数に与えます。

一方、randやrandnは複数個の乱数を得るには便利ですが、パラメータを渡すことができません。
(なので、得た乱数に対して、定数を足したり掛けたりして調整する)

特に難しくも複雑でもないので、本に出てきた通りに暗記して使っていましたが、
関数の設計が統一されてないので不便だとずっと思ってました。

それでこの度ドキュメントをみたのですが、各関数が、
Simple random data と、Distributionsというカテゴリに分けて定義されていることを知りました。
(あと二つ、PermutationsとRandom generatorがありますが割愛)
そして、(完全に揃ってる訳ではないのですが、)それぞれのカテゴリ内である程度使い方を揃えて作られていることがわかりました。

要は、Simple random dataのほうは欲しいデータの数だけ渡せばよく、
Distributionsの方は最初に分布のパラメーターを指定して、データの個数はsize引数で渡す。

今更なんですが、最初にpython勉強し始めた頃に、
公式ドキュメントもきちんと読んでおけば、もう少し楽に覚えられたかなと思いますね。

numpyの線形代数モジュールで連立一次方程式を解く

numpyで作成できる配列によく似たオブジェクトのarrayですが、配列同様にネストして多次元のarrayを作成できます。
(日頃から意識せず普通に使ってますね。)
特に2次元のarrayについては、行列として扱うことが可能です。

そしてnumpyには、numpy.linalgという線形代数のモジュールがあり、
行列式や固有値、逆行列の計算などができます。

ドキュメントはこちら。
Linear algebra (numpy.linalg)

今回は基本的な例として次の連立一次方程式を解いてみましょう。
$$
\begin{eqnarray}
&x_0-4&x_1+2&x_2&=7\\
9&x_0-5&x_1+2&x_2&=-2\\
3&x_0-10&x_1+5&x_2&=3
\end{eqnarray}
$$

これは
$$
A = \left[ \begin{matrix}
1&-4&2\\
9&-5&2\\
3&-10&5
\end{matrix}\right]
$$
$$
b = \left[ \begin{matrix}
7\\
-2\\
-3
\end{matrix}\right]
$$
とおいて、 Aに逆行列が存在すれば、(つまりAの行列式が0でなければ)$A^{-1}b$を計算することで解けます。

それではやってみましょう。
行列式はlinalg.det(A)
逆行列はlinalg.inv(A)で算出できます。
行列の式はnp.dot(A,B)A.dot(B)か、A@Bなどの書き方があります。


# 行列の定義
A = np.array(
        [
            [1,  -4, 2],
            [9,  -5, 2],
            [3, -10, 5]
        ]
    )
b = np.array([7, -2, 3]).T

# Aの行列式の確認 (実装の都合でわずかに誤差が出ます。)
print("dat(A)=", np.linalg.det(A))

# 方程式の解
print("[x_0, x_1, x_2]=", np.linalg.inv(A)@b)

# 以下出力
dat(A)= 0.9999999999999893
[x_0, x_1, x_2]= [ -29. -223. -428.]

これで、連立一次方程式が解けました。
Aの行列式が微妙に1にならないのは実装されていているアルゴリズムの都合のようです。

カバン検定を自分で実装してみる

前の記事で、statsmodels に実装されている acorr_ljungbox 関数を使って、カバン検定を行ってみました。
statsmodelsでかばん検定 (自己相関の検定)

今回は学習のため、numpyで実装しました。
実装にあったって数式から説明します。
検定の帰無仮説や対立仮説については前の記事を参照してください。

$\{y_t\}_{0}^{T-1}$を時系列データとします。 (沖本本の定義とインデックスが一つずれているので注意。)

標本平均$\bar{y}$, 標本自己共分散$\hat{\gamma}_k$, 標本自己相関係数$\hat{\rho}_k$を次のように定義します。
\begin{eqnarray}
\bar{y} & = & \frac{1}{T}\sum_{t=0}^{T-1}y_t\\
\hat{\gamma}_k & = & \frac{1}{T}\sum_{t=k}^{T-1}(y_t-\bar{y})(y_{t-k}-\bar{y}) , \hspace{1em} k = 0, 1, 2, \cdots\\
\hat{\rho}_k & = & \frac{\hat{\gamma}_k}{\hat{\gamma}_0},\hspace{1em} k = 1, 2, 3, \cdots
\end{eqnarray}

この時、Ljung と Box が考案した次の統計量$Q(m)$は漸近的に自由度$m$のカイ2乗分布に従います。
$$Q(m) = T(T+2)\sum_{k=1}^{m}\frac{\hat{\rho}_k^2}{T-k}$$

今回も7点周期のデータを準備します。
m = 7 で検定を行いますので、帰無仮説が棄却されれば、
LAG1〜7のいずれかの自己相関係数が0でないことが主張されます。
(p値は5%を基準に判断します。)


import numpy as np
import pandas as pd
from scipy.stats import chi2
# 答え合わせ用
from statsmodels.stats.diagnostic import acorr_ljungbox

# 7点ごとに周期性のあるデータを準備
series = pd.Series([1, 1, 1, 1, 1, 1, 5]*10)
# 乱数追加
series += np.random.randn(70)

# データの個数
T = len(series)
# 検定対象のm
m = 7
# 標本平均
y_ = series.mean()
# 標本自己共分散の列
gamma_list = np.array([
                np.sum([
                    (series[t]-y_)*(series[t-k]-y_) for t in range(k, T)
                ])/T for k in range(m+1)
            ])
# 標本自己相関係数の列
ro_list = gamma_list/gamma_list[0]

# Q(m)の計算
Q = T*(T+2)*np.sum([ro_list[k]**2 / (T-k) for k in range(1, m+1)])
print("lb値:", Q)
print("p値:", chi2.sf(Q, m))

# ライブラリを使った計算結果
lbvalues, pvalues = acorr_ljungbox(series, lags=7)
print(lbvalues[6])
print(pvalues[6])

# 出力結果
lb値: 43.78077348272421
p値: 2.3564368405873674e-07
43.780773482724186
2.3564368405873896e-07

p値が非常に小さな値であることがわかりました。
また、ライブラリを使った結果と四捨五入による誤差がありますが、それ以外は同じ値なのでミスも無さそうです。

この計算の注意点としては、
標本自己相関係数を使うところでしょうか。
pandasのautocorrは自己相関係数を出力してくれますが、
これが標本自己相関係数とは微妙に定義が異なり、autocorrを使うと少し値がずれてしまいます。

numpy の数値を表示するときの桁数を指定する

当然ですが、numpyを使っていると数値をprintして値を確認する機会が多々あります。
そこで問題になるのが、表示形式です。
本来は利便性のためだと思うのですが、小数点以下の桁が何桁も表示されたり、突然指数表記になったりします。
正直言って、配列内のどの値が大きくてどの値が小さいのか、ぱっと見でわかりにくいです。

表示例。


>>> import numpy as np
>>> ary = np.random.randn(3, 5)
>>> print(ary)
[[-8.69277072e-01 -4.72662319e-01  5.48868554e-01 -6.03789326e-01 1.95117216e-01]
 [-1.46386861e+00  9.92037075e-01  8.04045031e-01 -1.43756938e+00 7.46898368e-02]
 [-1.05065247e+00  3.72571551e-04 -1.15836779e-01 -5.80949053e-03 1.59979389e+00]]

numpy のドキュメントによると、絶対値が一番大きいものと一番小さいものの差が一定値を超えると指数表記になるそうです。

そこで、値を確認するときは、適当なくらいで四捨五入して表示したりしていたのですが、
実はnumpyのオプションで表示桁数を指定できることがわかりました。

設定を変える前に、デフォルトの設定は下記の関数で見ることができます。
(numpyのバージョンによって設定可能項目は変わります。)


>>> np.get_printoptions()
{'edgeitems': 3, 'threshold': 1000, 'floatmode': 'maxprec', 'precision': 8, 'suppress': False, 'linewidth': 75, 'nanstr': 'nan', 'infstr': 'inf', 'sign': '-', 'formatter': None, 'legacy': False}

各設定値の意味はこちら。set_printoptions
(get_printoptionsのページにはset_printoptions を見ろと書いてある。)

これらの設定値を、set_printoptions関数で変更することができます。
この中で、よく使うのはこの二つ。
precision = 3 # 小数点以下の表記を
suppress = True # 指数表記を禁止

設定してみたのがこちら。


>>> np.set_printoptions(precision=3, suppress=True)
>>> ary = np.random.randn(5,3)
>>> print(ary)
[[ 1.611 -2.259  0.022]
 [-1.937 -0.394  2.011]
 [-0.01  -0.162 -0.823]
 [-1.818 -2.474  0.341]
 [ 0.363 -2.018 -0.667]]

見やすくなりました。