NumPy 入門 — 配列が Python のリストより速い理由と基本の使い方

読了 6分

pandas、scikit-learn、PyTorch、OpenCV。Python のデータエコシステムの主要ライブラリを一枚めくると、すべて NumPy の配列が出てきます。NumPy を飛ばして pandas から学ぶと、「なぜこの演算は速く、あのループは遅いのか」「なぜ元の配列まで変わってしまうのか」を永遠に勘だけで扱うことになります。この記事では NumPy の核心概念 5 つを原理から整理します。結論を先に書くと、NumPy の本質は「同じ型の数値を連続したメモリに並べ、ループを C に任せること」 のひとつだけです。残りはすべてこの文の系です。

リストはなぜ遅いのか — ポインタの海 #

Python のリスト [1, 2, 3] は、数値が 3 つ並んだ構造ではありません。オブジェクトを指すポインタが 3 つ並んでいて、実際の数値(int オブジェクト)はメモリのあちこちに散らばっています。リストの要素を 100 万回足すループは、100 万回のポインタたどり、100 万回の型チェック、100 万回のオブジェクト演算を支払います。

ndarray は逆です。要素がすべて同じ型(dtype)という制約を受け入れる代わりに、実際の値が連続したメモリに隙間なく並びます。 すると 2 つのことが無料で付いてきます。型チェックが配列ごとに 1 回で済み、演算ループ全体をコンパイル済みの C コードが回せます。CPU キャッシュの効率まで加わって、数値演算でリスト比数十〜数百倍の差が出るのはこのためです。

array_basics.py
import numpy as np

a = np.array([1.0, 2.5, 3.0])      # dtype は float64 と推論
z = np.zeros((3, 4))               # 3 行 4 列、0 で埋める
r = np.arange(0, 10, 2)            # [0 2 4 6 8]
print(a.dtype, z.shape, z.ndim)    # float64 (3, 4) 2

配列のプロフィールは dtype(要素の型)、shape(各次元のサイズ)、ndim(次元数)の 3 つに要約されます。NumPy のコードを読んで詰まったら、この 3 つを表示してみるのがデバッグの出発点です。

ベクトル化 — ループを書かないことが文法です #

NumPy の使い方は一文に要約できます。要素を巡回する for ループを書かず、配列全体に演算を適用せよ。

vectorization.py
prices = np.array([12000, 45000, 8900, 23000])

# こう書きません
discounted = [p * 0.9 for p in prices]

# こう書きます: ベクトル化
discounted = prices * 0.9              # 全体に掛け算
over_20k = prices[prices > 20000]      # ブールマスクでフィルタリング
total = prices.sum()

prices * 0.9 は配列全体を C のループ 1 回で処理します。prices > 20000 は True/False の配列(ブールマスク)を作り、それをインデックスに入れると条件に合う要素だけが取り出されます。「ループ + if」でやっていたことが、すべてこのパターンに置き換わります。Python の for ループの中で ndarray の要素をひとつずつ取り出しているなら、ほぼ確実に使い方を間違えています。 その瞬間に NumPy の速度の利点は消え、リストより遅くなることさえあります。

ブロードキャスト — 形が違っても計算できるルール #

prices * 0.9 で配列とスカラーを掛けられたのが、ブロードキャストの最も単純な例です。ルールはこうです。2 つの配列の shape を後ろから比べて、各次元が同じか、片方が 1 なら、その次元を引き伸ばして合わせます。

broadcasting.py
matrix = np.array([[1, 2, 3],
                   [4, 5, 6]])       # shape (2, 3)
row = np.array([10, 20, 30])         # shape (3,)
col = np.array([[100], [200]])       # shape (2, 1)

matrix + row   # row が各行に足される → (2, 3)
matrix + col   # col が各列に足される → (2, 3)

「各行から列平均を引く」正規化がループなしの 1 行になるのは、このルールのおかげです。ルールに合わない組み合わせはエラーになるので、shape エラーに出会ったら 2 つの配列の shape を後ろ揃えで並べて書いてみると原因が見えます。

罠 — スライスはコピーではなくビューです #

ここが初心者が最も大きくやけどするポイントです。NumPy のスライスはデータをコピーせず、同じメモリを見るビュー(view)を作ります。

view_vs_copy.py
a = np.arange(10)     # [0 1 2 ... 9]
b = a[2:5]            # ビュー: a のメモリを共有
b[0] = 999
print(a[2])           # 999 — 元が変わった!

c = a[2:5].copy()     # 独立したコピーが必要なら明示的に

リストのスライス(lst[2:5])がコピーを返すのと正反対なので、リストの感覚で使うと元データの汚染を経験します。大きな配列をコピーなしで扱うための意図された設計なので、ルールとして覚えるのが正解です。切り出して書き換えるなら .copy() を明示する。 なお、ブールマスクのインデックス(a[a > 5])はコピーを作ります。ビューになるのは連続範囲のスライスです。

axis — 集計の方向の読み方 #

2 次元からは集計に方向が生まれます。axis 引数がその方向で、ルールはひとつです。指定した axis が消える。

axis_sum.py
sales = np.array([[10, 20, 30],      # 1 号店の 1〜3 月
                  [40, 50, 60]])     # 2 号店の 1〜3 月

sales.sum()          # 210 — 全体
sales.sum(axis=0)    # [50 70 90] — 行方向に畳む: 月別合計
sales.sum(axis=1)    # [60 150] — 列方向に畳む: 店舗別合計

shape (2, 3) で axis=0 を畳むと (3,) になり、axis=1 を畳むと (2,) になります。「axis=0 は縦、axis=1 は横」と覚えるより、「その軸が消えた結果の shape」で考えるほうが、3 次元以上でも崩れません。この感覚は pandas の axis、ディープラーニングフレームワークの dim までそのままつながります。

NumPy が答えではないケース #

道具の境界も知っておくべきです。

  • 要素が数十個レベルの小さいデータ: 配列生成のオーバーヘッドがあり、リストのほうが速くて単純です。NumPy はデータが大きくなるほど勝つ道具です。
  • 型が混ざったデータ、ラベルが必要なデータ: 数値の行列ではなく「名前・日付・金額のある表」なら、最初から pandas が正解です。pandas が内部で NumPy を使っています。
  • 文字列処理、複雑な分岐ロジック: ベクトル化になじまないロジックを無理に NumPy に押し込むと、読めないコードになります。素の Python が正解の領域はそのままにしておきます。

性能が本当に問題なのか確かめたいなら、勘ではなく測定です。プロファイリングの道具は py-spy とモダン Python 高度 #7 で扱いました。

まとめ #

  • リストはポインタの並びなので遅く、ndarray は同じ dtype を連続メモリに敷いて C のループとキャッシュ効率を得ます。これが NumPy のすべてです。
  • 文法の核心はベクトル化です。for ループの代わりに配列全体の演算とブールマスクを使います。ndarray をループで巡回するコードは危険信号です。
  • ブロードキャストは shape を後ろから比べて 1 の次元を引き伸ばすルールです。shape エラーは 2 つの shape を並べて書けば解けます。
  • スライスはビューで、元とメモリを共有します。切り出して書き換えるなら .copy() を明示します。
  • 集計は「指定した axis が消える」と読みます。この感覚が pandas とディープラーニングフレームワークまでつながります。
  • 小さいデータ、ラベル付きの表、複雑な分岐にはリスト・pandas・素の Python が合います。NumPy は大きな数値配列の道具です。
X