NumPyの1変数多項式クラス

Pythonで多項式を扱う時はSympyを使うものだと思い込んでいたのですが、
NumPyにも多項式クラスが用意されいるのを見つけ、しかもかなり使い勝手が良かったので紹介します。

ドキュメント: numpy.poly1d

これを使えば簡単に多項式を生成し、多項式間の演算を行ったり値を代入したりすることができます。
まず、多項式の生成は次の2種類のやりかでできます。

– 高次の項から順番に係数を指定する。
– その多項式の根を指定する。(この場合、再高次の項の係数は1)。

$2x^3-4x^2-22x+24$ と $(z-1)(z+2)=z^2+z-2$ をそれぞれの方法で定義したのが次のコードです。


import numpy as np

# 係数を指定する場合は、高次の項から順番に指定する
p1 = np.poly1d([2, -4, -22, 24])
print(p1)
"""
   3     2
2 x - 4 x - 22 x + 24
"""

# r=True を指定することで、根を指定して生成できる
# variable で変数の文字も指定できる(デフォルトはx)
p2 = np.poly1d([-2, 1], r=True, variable="z")
print(p2)
"""
   2
1 z + 1 z - 2
"""

係数のリストはc、次数はo、根の一覧はr、という属性でそれぞれアクセスすることができます。
係数と根はその多項式オブジェクトを作る時に指定した方法に関係なく取得できて便利です。
方程式を解くのもこれでできますね。


# 係数のリスト
print(p1.c)
# [  2  -4 -22  24]

# 次数
print(p1.o)
# 3

# 根
print(p1.r)
# [-3.  4.  1.]

また、指定した次数の係数は辞書と同じように[次数]でアクセスできます。


# x^2 の係数
print(p1[2])
# -4

値の代入は通常の関数と同じように、 多項式オブジェクト(代入したい値) で計算できます。


# p1 に 2 を代入
print(p1(2))
# -20

このほか、 通常の +, -, * の 演算子で演算もできます。
/ は割り算ですが、商と余りを返してくれます。とても便利ですね。


p = np.poly1d([2, 7, 1, 4, 3])
q = np.poly1d([1, 3, 5])

print(p+q)
"""
   4     3     2
2 x + 7 x + 2 x + 7 x + 8
"""

print(p-q)
"""
   4     3     2
2 x + 7 x + 2 x + 7 x + 8
"""

print(p*q)
"""
   6      5      4      3      2
2 x + 13 x + 32 x + 42 x + 20 x + 29 x + 15
"""

print(p/q)
# (poly1d([  2.,   1., -12.]), poly1d([35., 63.]))

このほかさらに、polyderとpolyintで微分と不定積分も用意されています。


# 微分
print(np.polyder(p))
"""
   3      2
8 x + 21 x + 2 x + 4
"""

# 不定積分
print(np.polyint(p))
"""
     5        4          3     2
0.4 x + 1.75 x + 0.3333 x + 2 x + 3 x
"""

例は省略しますが、2個目の引数に自然数を渡せばn回微分や、n回積分もやってくれます。
積分の方は、3つ目の引数に積分定数を渡すこともできます。

数値をゼロ埋めして桁数を揃える

桁数の揃ったIDを振るときや、表の見栄えを整える時など、桁数が少ない数字の左側に0をくっつけて表示することがあります。
そうそう頻繁にあることではないので、これまでそういう操作が必要な時は、
単純に0をたくさんくっつけて規定文字数を右側から取り出す関数を作って対応していました。

例えば次のようなメソッドを定義していました。
例として 123 を 0埋めして6桁にしています。


def zero_padding(n, length):
    m = "0" * length + str(n)
    return m[-length:]


print(zero_padding(123, 6))
# 000123

普段の用途だとこれでもあまり困らないのですが、実はPythonにはゼロ埋め専用の関数が用意されていたのに気づいたのでそちらを紹介します。
文字列オブジェクトに定義されている、zfillメソッドがそれです。

ドキュメント: str.zfill(width)
str型が持ってるメソッドなので、数値型のデータに適用するにはstrに変換してから呼び出す必要があります。

このメソッドは上のコードで僕が定義した単純な関数に比べて次の2点で優れています。
– 数字がwidth桁未満の時は、元の数字をそのまま返す。(上の方の桁をロストしない)
– 文字列の先頭が+か-の符号の場合、符号を先頭としてその後ろを0埋めする。
対応する符号は+と-だけで、±はダメみたいでした。

挙動を確認するため、いくつかのパターンでやってみます。


# 123をゼロ埋めして7桁にする
print("123".zfill(7))
# 0000123

# 幅が元の数値の桁数未満なら元の数値をそのまま返す。
print("12345".zfill(3))
# 12345

# 先頭が符号の場合は符号と数字の間をゼロ埋めする。符号も結果の文字数にカウントされる。
print("-5678".zfill(8))
# -0005678

# 小数点も文字数に数えられる。
print("+12.34".zfill(6))
# +12.34

# 数字以外の文字列に対しても使える。
print("abc".zfill(5))
# 00abc

小数や負の数をゼロパディングする機会にはまだ遭遇したことないのですが、覚えておいて損はないと思います。

Matplotlibでヒストグラムを描く時に各binのレンジを明示的に指定する方法

そもそも、Tableauなどを使えばこんな手間もないのですが、Python(Matplotlib)でヒストグラムを描く時に、各ビンの区間を指定したいことがよくあります。
0かはじめて0.5区切りにしたいとか20区切りにしたいとかです。
Matplotlibのhistメソッドでは、bins引数で、binの引数を指定でき、range引数でヒストグラムに描写する幅を指定できるので、
僕はこれまではこの二つを組み合わせて使うことで、想定通りのヒストグラムを描いていました。

試しに、 0 〜 300のデータを 20区切りで、15本のbinでヒストグラムに表示するコードがこれです。
hist メソッドの戻り値で binの区切り位置が取れるので、そちらを確認し、出力の図は省略します。
参考: matplotlibのhist()の戻り値


import matplotlib.pyplot as plt
from scipy.stats import beta

# データ生成
beta_frozen = beta(a=1, b=1, scale=300)
data = beta_frozen.rvs(100)

fig = plt.figure(facecolor="w")
ax = fig.add_subplot(111)
ns, bins, _ = ax.hist(data, bins=15, range=(0, 300))
print(bins)
# [  0.  20.  40.  60.  80. 100. 120. 140. 160. 180. 200. 220. 240. 260. 280. 300.]

これ、もしreangeを指定せずに、bins=15だけ指定しているととても中途半端なところで区切られます。


fig = plt.figure(facecolor="w")
ax = fig.add_subplot(111)
ns, bins, _ = ax.hist(data, bins=15)
print(bins)
"""
[  0.59033775  20.53472376  40.47910977  60.42349579  80.3678818
 100.31226782 120.25665383 140.20103985 160.14542586 180.08981188
 200.03419789 219.9785839  239.92296992 259.86735593 279.81174195
 299.75612796]
"""

さて、上記のようなblog記事ように自分で生成したデータなど取りうるレンジがわかりきってるものであれば、上記のやり方でも問題なのですが、実データでは少しだけ面倒です。
レンジとデータ量を確認して、何本くらいのbinを指定すれば切りの良い区切りで可視化でできるか考えないといけません。

しかしドキュメントあたらめて読んでみると、bins引数で、ビンの本数ではなく、区間を配列で指定できることがわかりました。
参考: matplotlib.pyplot.hist

bins : int or sequence or str, optional

If an integer is given, bins + 1 bin edges are calculated and returned, consistent with numpy.histogram.
If bins is a sequence, gives bin edges, including left edge of first bin and right edge of last bin. In this case, bins is returned unmodified.

これを使うと、例えば20区切りで可視化したい、と言った時は次のような書き方ができます。


# 300を含めるため、2個目の引数は301にしました。
bins = range(0, 301, 20)
fig = plt.figure(facecolor="w")
ax = fig.add_subplot(111)
ns, bins, _ = ax.hist(data, bins=bins)
print(bins)
# [  0  20  40  60  80 100 120 140 160 180 200 220 240 260 280 300]

楽ですね。
注意点として、 bisで渡した配列の区間の外側のデータは可視化されないということがあります。
bins = range(0, 300, 20) とすると、binsの配列は、
[ 0 20 40 60 80 100 120 140 160 180 200 220 240 260 280]
になるので、 280~300のデータは可視化されません。
区間の下側も同様です。
なので、binsを指定する時に、データが全部その範囲に含まれているのかは確認しておく必要があります。

ちなみに豆知識ですが、各区間は最後(最大)の区間以外は、左の値を含み右の値を含みません、
[20, 40) の区間であれば、 $20 \leq x < 40$ のデータを数えます。
ただし、最後(最大)の区間に限って、右の値も含み、最大のbinが
[280, 300] の区間であれば、 $280 \leq x \leq 300$ のデータを数えます。

使いどこがすぐには思いつかないのですが、 bins を配列で指定する場合は、等間隔以外の区切りもサポートされているということも覚えておきましょう。

pandasのDataFrameの欠損値をfillnaで埋める時の小技

DataFrameの欠損値(NoneやNaN)を定数で埋める時、fillnaというメソッドをよく使っていましたが、
定数で埋める以外にもいろいろ指定ができることがわかったので紹介します。

ドキュメントはこちらです。
pandas.DataFrame.fillna — pandas 1.1.2 documentation

まず、サンプルとして欠損値を含むDataFrameを作っておきます。


import pandas as pd
import numpy as np
df = pd.DataFrame(
    [[np.nan, 2, np.nan, 0],
     [3, 4, np.nan, 1],
     [np.nan, np.nan, np.nan, 5],
     [np.nan, 3, np.nan, 4]],
    columns=list('ABCD')
)
print(df)
"""
     A    B   C  D
0  NaN  2.0 NaN  0
1  3.0  4.0 NaN  1
2  NaN  NaN NaN  5
3  NaN  3.0 NaN  4
"""

fillnaの一番基本的な使い方は定数を指定するものです。
例えば、0を渡せばNaNを0に置き換えたDataFrameを返します。(inplace=True も指定すれば、データフレームそのものを書き換えます)


print(df.fillna(0))
"""
     A    B    C  D
0  0.0  2.0  0.0  0
1  3.0  4.0  0.0  1
2  0.0  0.0  0.0  5
3  0.0  3.0  0.0  4
"""

いつもはこうやって、数値なら0、文字列なら空文字列(“”)で埋めるだけの使い方をしていました。

しかし、ドキュメントを読むと、定数で埋める以外にもいろいろできます。

まず、定数ではなく、 列名: 値 の辞書を渡すことで、列ごとに別々の値で埋めることができます。


fill_values = {'A': 0, 'B': 1, 'C': 2, 'D': 3}
print(df.fillna(value=fill_values))
"""
     A    B    C  D
0  0.0  2.0  2.0  0
1  3.0  4.0  2.0  1
2  0.0  1.0  2.0  5
3  0.0  3.0  2.0  4
"""

そしてこれは、辞書の代わりに、indexに列名、valueに値を持つSeriesを渡しても同じ挙動になります。


fill_values_sr = pd.Series(fill_values)
print(fill_values_sr)
"""
A    0
B    1
C    2
D    3
dtype: int64
"""

print(df.fillna(value=fill_values_sr))
"""
     A    B    C  D
0  0.0  2.0  2.0  0
1  3.0  4.0  2.0  1
2  0.0  1.0  2.0  5
3  0.0  3.0  2.0  4
"""

これを応用すると、各列をその列の平均値や最大値、最小値で埋めることも簡単にできます。
平均値でやってみたコードが次です。


print(df.fillna(value=df.mean()))
"""
     A    B   C  D
0  3.0  2.0 NaN  0
1  3.0  4.0 NaN  1
2  3.0  3.0 NaN  5
3  3.0  3.0 NaN  4
"""

さて、ここまでは列単位である一定の値で欠損値を補完する方法でしたが、時系列データなどを扱っている時は、
一定の値ではなく、直前や直後の値で埋めたいこともあると思います。

実はこの fillna はそのようなケースにも対応しており、
method という引数に “backfill”か”bfill”を指定すれば直後の値、
“pad”, “ffill”を指定すれば直前の値で埋めることができます。

bfillとffillだけですがそれぞれ試してみたのが次のコードです。


print(df.fillna(method="bfill"))
"""
     A    B   C  D
0  3.0  2.0 NaN  0
1  3.0  4.0 NaN  1
2  NaN  3.0 NaN  5
3  NaN  3.0 NaN  4
"""

print(df.fillna(method="ffill"))
"""
     A    B   C  D
0  NaN  2.0 NaN  0
1  3.0  4.0 NaN  1
2  3.0  4.0 NaN  5
3  3.0  3.0 NaN  4
"""

以上のように、fillnaを使うと定数での補完(0埋めなど)以外にもいろんなことができることがわかりました。

pandasのデータフレームのexplodeメソッドの紹介

以前の記事で、pandasのDataFrameの列中に含まれている配列を行に展開する方法について書きました。
参考: pandasのDataFrameのappendは遅い

お恥ずかしながらこの記事を書いた時は知らなかったのですが、pandasの version 0.25.0 から、専用のメソッドが準備されていて、
先述の記事のような面倒なことはしなくて良くなっています。

そのメソッドが pandas.DataFrame.explode です。

使い方は簡単で、行に展開したい、つまり配列を含んだ列の名前を渡すだけです。

実際にやってみます。


import pandas as pd

df = pd.DataFrame(
    {'A': [[1, 2, 3], 'foo', [], [3, 4]], 'B': 1, "C": [1, 2, 3, 4]})
print(df)
"""
           A  B  C
0  [1, 2, 3]  1  1
1        foo  1  2
2         []  1  3
3     [3, 4]  1  4
"""

df_explode = df.explode('A')
print(df_explode)
"""
     A  B  C
0    1  1  1
0    2  1  1
0    3  1  1
1  foo  1  2
2  NaN  1  3
3    3  1  4
3    4  1  4
"""

めちゃくちゃお手軽ですね。
前の記事で書いていたようにappendの遅さに文句を言ったり、自分で配列を作るコードを書いたりと言ったことは全くしなくて良くなりました。

matplotlibでレーダーチャート(メモリも多角形)を描写する

Pythonでレーダーチャートを書きたくなり、matplotlibでやってみたのでそのメモです。
公式にサンプルがあるのですが、2020年09月 現在うまく動きません。
参考: api example code: radar_chart.py
実装自体も、PolarAxesクラスを継承してメソッドを書き換えるかなり仰々しいものですし、
メモリが円形のままで、僕が望む形ではなかったのでゼロベースでやってみました。

まずライブラリをインポートして、適当にデータを作っておきます。
データはレーダーチャートで可視化する値とそれぞれのラベルがあれば良いです。


import matplotlib.pyplot as plt
import numpy as np

values = np.array([31, 18, 96, 53, 68])
labels = [f"データ{i}" for i in range(1, len(values)+1)]

さて、レーダーチャートですが、見栄えにこだわりがなければ簡単に書くことができます。
matplotlibで極座標のグラフを作り、一周ぐるっとplotするだけです。
簡易版ですがそのコードを先に紹介します。


# 多角形を閉じるためにデータの最後に最初の値を追加する。
radar_values = np.concatenate([values, [values[0]]])
# プロットする角度を生成する。
angles = np.linspace(0, 2 * np.pi, len(labels) + 1, endpoint=True)

fig = plt.figure(facecolor="w")
# 極座標でaxを作成。
ax = fig.add_subplot(1, 1, 1, polar=True)
# レーダーチャートの線を引く
ax.plot(angles, radar_values)
# レーダーチャートの内側を塗りつぶす
ax.fill(angles, radar_values, alpha=0.2)
# 項目ラベルの表示
ax.set_thetagrids(angles[:-1] * 180 / np.pi, labels)

ax.set_title("レーダーチャート", pad=20)
plt.show()

出力がこちらです。

これでも最低限の要件は満たせますね。ただ、データを表してる青の線が真っ直ぐなのに、
その目安となるメモリ線が円形なのが気になります。
また、普通の曲座標と違って、ラベルを上を始点にして時計回りにしたいです。

このラベルの開始位置と回転方向を変えるのは簡単なのですが、メモリを多角形にするのはまともに取り組むと非常に大変です。
(公式サンプルの様なClassを継承しての大がかりな改修が必要になります。)

なので、アプローチを変えてみました。
matplotlibの機能で引いてくれるメモリ線は全部消してしまいます。
そして、定数値のレーダーチャートとして、灰色の線を自分で引きました。

出来上がったコードがこちらです。


# 多角形を閉じるためにデータの最後に最初の値を追加する。
radar_values = np.concatenate([values, [values[0]]])
# プロットする角度を生成する。
angles = np.linspace(0, 2 * np.pi, len(labels) + 1, endpoint=True)
# メモリ軸の生成
rgrids = [0, 20, 40, 60, 80, 100]


fig = plt.figure(facecolor="w")
# 極座標でaxを作成
ax = fig.add_subplot(1, 1, 1, polar=True)
# レーダーチャートの線を引く
ax.plot(angles, radar_values)
# レーダーチャートの内側を塗りつぶす
ax.fill(angles, radar_values, alpha=0.2)
# 項目ラベルの表示
ax.set_thetagrids(angles[:-1] * 180 / np.pi, labels)
# 円形の目盛線を消す
ax.set_rgrids([])
# 一番外側の円を消す
ax.spines['polar'].set_visible(False)
# 始点を上(北)に変更
ax.set_theta_zero_location("N")
# 時計回りに変更(デフォルトの逆回り)
ax.set_theta_direction(-1)

# 多角形の目盛線を引く
for grid_value in rgrids:
    grid_values = [grid_value] * (len(labels)+1)
    ax.plot(angles, grid_values, color="gray",  linewidth=0.5)

# メモリの値を表示する
for t in rgrids:
    # xが偏角、yが絶対値でテキストの表示場所が指定される
    ax.text(x=0, y=t, s=t)
    
# rの範囲を指定
ax.set_rlim([min(rgrids), max(rgrids)])

ax.set_title("レーダーチャート", pad=20)
plt.show()

出力がこちら。

自分がイメージしていたのに近いものが作れました。
コード中にコメントを全部入れたので、ここからさらに見た目を変える場合はすぐ改良できると思います。

PythonでF検定を実装する

最近、とある二つのサンプルの分散が異なることを確認する機会があり、F検定を行う必要がありました。
いつもみたいにSciPyですぐにできるだろうと考えていたのですが、
Statistical functions をみる限りでは、SciPyにF検定は実装されていなさそうでした。

その代わり、バートレット検定とルビーン検定が実装されているのですが。
この二つの検定については別途勉強するとして、とりあえずF検定を行いたかったので自分で実装しました。
幸い、F分布自体はSciPyにあるので簡単です。
t検定のメソッドを参考にし、サンプルを二つ渡したらF統計量とp値を返してくれる関数ように実装しました。

※F検定自体の説明は今回は省略します。
興味のあるかたには、東大出版会の統計学入門(いわゆる赤本)の244ページ、12.2.4 母分散の比の検定 あたりが参考になります。


from scipy.stats import f
import numpy as np


def ftest(a, b):
    # 統計量Fの計算
    v1 = np.var(a, ddof=1)
    v2 = np.var(b, ddof=1)
    n1 = len(a)
    n2 = len(b)
    f_value = v1/v2

    # 帰無仮説が正しい場合にFが従う確率分を生成
    f_frozen = f.freeze(dfn=n1-1, dfd=n2-1)

    # 右側
    p1 = f_frozen.sf(f_value)
    # 左側
    p2 = f_frozen.cdf(f_value)
    # 小さい方の2倍がp値
    p_value = min(p1, p2) * 2

    # 統計量Fとp値を返す
    return f_value, p_value

きちんと実装できているかどうか確認するため、統計学入門の練習問題を一つ解いておきます。
252ページの練習問題、 12.2 の iii) が分散の検定なのでやってみます。

次のコードのdata_1とdata_2の分散が等しいというのが帰無仮説で、等しくないというのが対立仮説です。


# 問題文のデータを入力
data_1 = np.array([15.4, 18.3, 16.5, 17.4, 18.9, 17.2, 15.0, 15.7, 17.9, 16.5])
data_2 = np.array([14.2, 15.9, 16.0, 14.0, 17.0, 13.8, 15.2, 14.5, 15.0, 14.4])

# F統計量とp値を計算
f_value, p_value = ftest(data_1, data_2)
print(f"F統計量: {f_value:1.3f}")
# F統計量: 1.564
print(f"p値: {p_value:1.3f}")
# p値: 0.516

F統計量は正解と一致しましたし、帰無仮説が棄却できないのも確認できました。

Jupyter Notebookでインタラクティブに関数を実行する

Juputer Notebookで関数を実行するとき、結果を見ながら何度も引数を変更して再実行したくなることはないでしょうか。
毎回コード中の引数を書き換えて実行しても良いのですが、それをGUIで実行できると便利です。

その便利な機能が、ウィジェットとして用意されているのでそれを紹介します。
使うのは ipywidgets です。
ドキュメントはこちら: Jupyter Widgets
実は細かい設定でGUIをいろいろ作れるのですが、今回はとりあえず最低限度の機能でGUIで操作しながらグラフを描写する関数を実行します。

実行するサンプル関数としてリサージュ曲線を描く関数を用意しました。


import numpy as np
import matplotlib.pyplot as plt


def lissajous_curve(a, b, delta, color="b", title="リサージュ曲線"):

    t = np.linspace(0, 2, 601) * np.pi
    x = np.cos(a * t)
    y = np.sin(b * t + delta)

    fig = plt.figure(figsize=(8, 8))
    ax = fig.add_subplot(111, aspect="equal", title=title)
    ax.plot(x, y, color=color, )
    plt.show()

一応説明しておくと、これは変数$t$を媒介変数とし、
$x=A\cos(at), y=B\sin(bt+\delta)$で表される点をプロットした曲線です。
($A,B$はただ図形の縮尺が変わるだけなので今回のコードは$1$で固定しました。)

さて、通常は$a, b, \delta$や色とタイトルに値を指定して実行することで目当てのグラフを描写しますが、
これらの引数をGUIで指定できる様にします。

方法は簡単で、ipywidgets.interact に先ほどの関数と、指定できる引数の範囲を渡すだけです。
引数の範囲は次の様に指定できます。
– (開始, 終了, ステップ) のタプル(ステップを省略したら1)
– 指定できる値のリスト
– テキスト
– 表示用文字列: 渡す値の辞書


from ipywidgets import interact


interact(
    lissajous_curve,
    a=(1, 10, 1),
    b=(1, 10, 1),
    delta=(0, 2*np.pi, 0.01),
    title="リサージュ曲線",
    # ただの配列で指定することも可能。
    # color=["b", "k", "r", "g", "y", "m", "c"]
    # 表示用文字列: 渡す値の辞書も使える
    color={
        "Blue": "b",
        "Green": "g",
        "Red": "r",
        "Black": "k",
    }
)

実行したのがこちらです。
Blogなので画像キャプチャになってしまっていますが、実際はスライドバーやドロップダウンで操作し、グラフを書き直すことができます。

NumPyのブロードキャストで変換できる型

NumPyを普段使いしてると便利な機能に、ブロードキャストがあります。
これは配列のサイズが違うもの通しを演算するときに、いい感じに小さい方を拡張して演算してくれるものです。

例えば、配列とスカラーの和や、行列とベクトルの和を次の様に計算してくれます。


a = np.array(range(4))
print(a)
# [0 1 2 3]

# 7 を [7, 7, 7, 7] として扱って足してくれる
b = a + 7
print(b)
# [ 7  8  9 10]

c = np.array(range(6)).reshape(2, 3)
print(c)
"""
[[0 1 2]
 [3 4 5]]
"""

d = np.array([5, 5, 5])

# [5, 5, 5] を [[5, 5, 5], [5, 5, 5]] として扱って足してくれる
e = c+d
print(e)
"""
[[ 5  6  7]
 [ 8  9 10]]
 """

本当にいい感じにやってくれるのであまり意識せずに使っていましたが、仕様を正確に把握しておきたかったので改めてドキュメントを読みました。
と言うのも、いつでも動くわけではありませんし、正方行列とベクトルの和の様にどちらの軸にブロードキャストされるか迷うことなどあるからです。


# 動かない例
a = np.array(range(6)).reshape(3, 2)
b = np.array(range(3))
a + b
# ValueError: operands could not be broadcast together with shapes (3,2) (3,)

# 行か列か引き伸ばされる方向がわかりにくい例
c = np.zeros(shape=(3, 3))
d = np.array(range(3))
print(c+d)
"""
[[0. 1. 2.]
 [0. 1. 2.]
 [0. 1. 2.]]
"""

ドキュメントはこちらです。
参考: Broadcasting

長いのですが、基本的なルールは General Broadcasting Rules に書かれてる次の法則だけです。
配列の次元数は後ろから順番に前に向かって比較されます。(長さが違う場合は、短い方に1が追加されて揃えられます。)
そして、それらの値が等しいか、もしくは一方が1であればブロードキャストされます。

ブロードキャストされるときは、値が1だった(もしくは無かった)次元の向きにデータがコピーされて拡張されます。

先ほどのエラーが起きた例で言えば、 (3, 2)次元と (3) 次元の 2と3が比較されてこれが等しくないからエラーになったわけですね。
その次の例についてはまず、
[0, 1, 2] (shapeは(3,))に、次元が一個追加されて、
[[0, 1, 2]] (shapeは(1, 3)) に変換され、それが各行にコピーされたので上の例の様な結果になっています。

先述のシンプルなルールさえ満たしていれば、次の例の様な少々無茶でイメージしにくい配列同士でもブロードキャストされます。


a = np.array(range(21)).reshape(1,1,3,1,7)
b = np.array(range(10)).reshape(2,1,5,1)

print(a.shape)
# (1, 1, 3, 1, 7)
print(b.shape)
# (2, 1, 5, 1)

c = a+b
print(c.shape)
# (1, 2, 3, 5, 7)

(1, 1, 3, 1, 7) と (2, 1, 5, 1) では長さが違うので、後者の方の先頭に1が挿入され
以下の二つになるわけですね。
(1, 1, 3, 1, 7)
(1, 2, 1, 5, 1)
これを順番に見ていくと、ブロードキャストのルールをみたいしているので足りない向きについてはデータがコピーされ、
和がとれているわけです。

pandasのDataFrameのappendは遅い

この記事のタイトルは悩んだのですが、一番伝えたかった内容がそれなので、上記の様になりました。
他のタイトル候補は次の様になります。

– 配列を要素に持つDataFrameの縦横変換
– 高速にDataFrameを作成する方法

要するに、1件ごとに発生するデータを毎回appendしてデータフレームを構成する処理は、
コーデイングの観点ではわかりやすいですが、件数によっては非常に時間がかかります。

僕の場合ですが、DataFrameの要素に配列(もしくはカンマ区切りなどの文字列)が入っていた時に、
それを要素ごとに縦横変換する時によく遭遇します。

例えば以下の感じのDataFrameを


       key                                         value
0    key_0          [value_0, value_1, value_2, value_3]
1    key_1                            [value_4, value_5]
2    key_2                   [value_6, value_7, value_8]
3    key_3       [value_9, value_10, value_11, value_12]
4    key_4                          [value_13, value_14]
# (以下略)

次の様なDataFrameに変換したい場合です。


     key    value
0  key_0  value_0
1  key_0  value_1
2  key_0  value_2
3  key_0  value_3
4  key_1  value_4
5  key_1  value_5
6  key_2  value_6
7  key_2  value_7
8  key_2  value_8
# (以下略)

データ量が少ない場合は、1件ずつ appendしても問題ありません。
次の様に、iterrows で1行1行取り出して、出力先にpivot_dfに追加してくと、素直で理解しやすい処理になります。


pivot_df = pd.DataFrame(columns=["key", "value"])

for _, row in df.iterrows():
    value_list = row["value"]
    for v in value_list:
        pivot_df = pivot_df.append(
            pd.DataFrame(
                {
                    "key": [row.key],
                    "value": [v],
                }
            )
        )

ただし、この処理は対象のデータ量が2倍になれば2倍以上の時間がかかる様になります。
そこで、大量データ(自分の場合は数十万件以上くらい。)を処理する時は別の書き方を使っていました。

つい最近まで、自分の環境が悪いのか、謎のバグが潜んでいるのだと思っていたのですが、
ドキュメントにも行の追加を繰り返すと計算負荷が高くなるからリストに一回入れろと書いてあるのを見つけました。
どうやら仕様だった様です。

引用

Iteratively appending rows to a DataFrame can be more computationally intensive than a single concatenate. A better solution is to append those rows to a list and then concatenate the list with the original DataFrame all at once.

その様なわけで、DataFrame中の配列の縦横変換は自分は以下の様に、一度配列を作って配列に追加を続け、
出来上がった配列をDataFrameに変換して完成としています。


keys = []
values = []

for _, row in df.iterrows():
    keys += [row.key] * len(row.value)
    values += row.value

pivot_df = pd.DataFrame(
    {
        "key": keys,
        "value": values,
    }
)