0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NumPyってなんだ?〜「for文で回すな」と言われる本当の理由〜

0
Posted at

この記事の対象読者

  • Pythonは書けるが、NumPyを「なんとなく」使っている方
  • np.arraylist の違いを明確に説明できない方
  • 「for文で回すな、NumPyを使え」と言われたがなぜなのかを理解したい方
  • AI/ML分野に進みたいが、NumPyの基盤知識が曖昧な方

この記事で得られること

この記事を読むと、以下のことが理解できます:

  • ndarrayの「正体」— Pythonリストとの根本的な違い
  • ベクトル化演算が高速な「仕組み」— C言語レベルで何が起きているか
  • ブロードキャスティングの「ルール」— なぜ形状が違う配列同士で計算できるのか
  • viewとcopyの違い — 「変更したはずの配列が変わっていない」問題の正体
  • メモリレイアウト — C-contiguousとF-contiguousが性能に与える影響

この記事で扱わないこと

  • NumPyの全関数リファレンス(公式ドキュメントを参照)
  • pandas / scipy / scikit-learn の詳細
  • PyTorch テンソルとの詳細な比較

本記事ではNumPy 1.26〜2.x系を前提としています。コード例はPython 3.11以降で動作確認済みです。


本記事の比喩について

この記事では、NumPyの仕組みを工場の生産ラインに見立てて解説する。

qiita-2.png

NumPyの概念 工場での対応物
ndarray ベルトコンベア — 同じ規格の製品が隙間なく一列に並ぶ
dtype(データ型) 製品規格 — ライン上の全製品は同じ規格でなければならない
Pythonリスト 手作業テーブル — 種類もサイズもバラバラな物が雑多に置かれている
ベクトル化演算 ライン全体を一括処理する機械 — 1個ずつ手作業するより桁違いに速い
ブロードキャスティング ラインの自動拡張装置 — サイズが違う部品を自動で合わせてくれる
view 監視カメラ映像 — 元のラインと常に連動する
copy 写真撮影 — 撮った瞬間のスナップショット。元ラインとは独立
C-contiguous / F-contiguous 製品の並び順 — 行方向に詰めるか、列方向に詰めるか

1. NumPyの「正体」— なぜPythonリストではダメなのか

1.1 Pythonリストの構造的な限界

Pythonのリストは非常に便利だ。型の異なる要素を自由に混ぜられるし、サイズも動的に変わる。

# Pythonリストは何でも入る
mixed = [42, "hello", 3.14, True, [1, 2, 3]]

だがこの「自由さ」こそが、数値計算においては致命的な足枷になる。

工場の比喩で考えてみよう。Pythonリストは手作業テーブルだ。テーブルの上にはネジ、ゴムパッキン、基板、説明書がバラバラに置かれている。作業員(CPython)は1個ずつ手に取り、「これは何だ?」と確認してから処理する。

Pythonリストが保持しているのは、実はデータそのものではなくPythonオブジェクトへのポインタだ。各要素が別々のメモリ位置に散らばっており、1つの要素にアクセスするたびにポインタを辿って型を確認する必要がある。

1.2 ndarrayという「ベルトコンベア」

NumPyのndarrayは根本的に異なる。ベルトコンベアの上に同じ規格の製品が隙間なく一列に並んでいる

import numpy as np

# ndarrayは全要素が同じ型
arr = np.array([42, 3.14, 2.71, 1.41])
print(arr.dtype)  # float64 — 全要素がfloat64に統一されている

ndarrayの内部構造はこうなっている:

属性 意味 工場の比喩
data 実データが格納されたメモリバッファへのポインタ ベルトコンベアの先頭アドレス
dtype 各要素のデータ型 製品規格
shape 配列の形状(次元ごとのサイズ) ラインの配置(1列×100個、10列×10個 等)
strides 次の要素に移動するためのバイト数 製品間の間隔
ndim 次元数 ラインの段数
arr = np.array([[1, 2, 3],
                [4, 5, 6]])

print(f"dtype:   {arr.dtype}")    # int64
print(f"shape:   {arr.shape}")    # (2, 3)
print(f"strides: {arr.strides}")  # (24, 8) — 行方向に24バイト、列方向に8バイト
print(f"ndim:    {arr.ndim}")     # 2
print(f"size:    {arr.size}")     # 6(全要素数)
print(f"nbytes:  {arr.nbytes}")   # 48(6要素 × 8バイト)

ここで重要なのはstridesだ。(24, 8) は「次の行に進むには24バイト移動、次の列に進むには8バイト移動」を意味する。int64は1要素8バイトなので、1行3要素 × 8バイト = 24バイトで次の行に到達する。この単純な算術でメモリ上の任意の要素に一発でアクセスできる仕組みだ。

stridesの概念を理解すると、後述するreshape、transpose、viewの挙動が腹落ちします。ndarrayの「賢さ」の多くはstridesの操作で実現されています。

ndarrayの内部構造がわかったところで、いよいよ核心に迫ろう。「for文で回すな」の本当の理由 — ベクトル化演算の仕組みだ。


2. ベクトル化演算 — 「for文で回すな」の本当の理由

2.1 何が起きているのか

「for文で回すな」という言葉を聞いたことがある人は多いだろう。だがその理由を「NumPyの方が速いから」で済ませているなら、まだ本当には理解できていない。

ベクトル化演算とは、ループをC言語レベルに落として一括実行する仕組みだ。

import numpy as np

# 100万要素の配列
a = np.random.rand(1_000_000)
b = np.random.rand(1_000_000)
# ❌ Pythonのfor文で回す(手作業テーブル方式)
result_loop = np.empty(1_000_000)
for i in range(1_000_000):
    result_loop[i] = a[i] + b[i]
# ✅ NumPyのベクトル化演算(ベルトコンベア方式)
result_vec = a + b

工場の比喩で説明しよう。

for文(手作業テーブル方式): 作業員が100万個の製品を1個ずつ手に取り、「これはfloat64か?」と型を確認し、対応する製品と足し合わせ、結果を置く。100万回この動作を繰り返す。

ベクトル化演算(ベルトコンベア方式): ベルトコンベアに100万個の製品が隙間なく並んでいる。「全製品を一括で加算する機械」のスイッチを入れる。機械はC言語で書かれた超高速の内部ループを回し、型チェックなしで連続メモリ上のデータを処理する。

2.2 実測してみよう

言葉だけでは説得力に欠ける。実際に計測する。

import numpy as np
import time

size = 1_000_000
a = np.random.rand(size)
b = np.random.rand(size)

# --- Pythonリスト + for文 ---
a_list = a.tolist()
b_list = b.tolist()

start = time.perf_counter()
result_list = [a_list[i] + b_list[i] for i in range(size)]
elapsed_list = time.perf_counter() - start

# --- NumPy ベクトル化 ---
start = time.perf_counter()
result_np = a + b
elapsed_np = time.perf_counter() - start

print(f"Pythonリスト: {elapsed_list:.4f}")
print(f"NumPy:        {elapsed_np:.6f}")
print(f"速度差:       {elapsed_list / elapsed_np:.0f}")

筆者の環境(Intel Core Ultra 9 285K)での実行結果:

方法 実行時間 速度比
Pythonリスト + for文 約 0.12秒 1x
NumPyベクトル化 約 0.0008秒 約150倍

150倍。二桁違う。100万要素でこの差なので、データサイエンスやML処理で億単位の要素を扱う場面では文字通り「for文で回したら日が暮れる」。

2.3 なぜこれほど速いのか — 3つの理由

速度差の原因は3つに分解できる。

理由1: 型チェックの排除

Pythonのfor文は毎回要素の型を確認する。ndarrayは全要素がfloat64と決まっているため型チェック不要。100万回の型チェック省略は大きい。

理由2: メモリの連続性

ndarrayのデータはメモリ上に隙間なく並んでいる。CPUのキャッシュラインにデータが効率的に載り、メモリアクセスの待ち時間が激減する。Pythonリストはポインタの先を辿るため、メモリアクセスが散発的になる。

理由3: SIMD命令の活用

NumPyの内部ループは、CPUのSIMD命令(Single Instruction, Multiple Data)を活用する。1つのCPU命令で複数のデータを同時に処理できるため、ハードウェアレベルで並列化される。

ベクトル化できない複雑な条件分岐を含むループでは、np.wherenp.select で置き換えられないか検討してください。それでも無理な場合は numba の JIT コンパイルや Cython への書き換えが選択肢になります。

ベクトル化の威力がわかったところで、次はNumPyの「魔法」のなかでも最も誤解されやすい仕組み — ブロードキャスティングを解き明かそう。


3. ブロードキャスティング — 形状が違うのに計算できる魔法

3.1 ブロードキャスティングとは何か

import numpy as np

a = np.array([[1, 2, 3],
              [4, 5, 6]])   # shape: (2, 3)
b = np.array([10, 20, 30])  # shape: (3,)

# 形状が違うのに足せる!
c = a + b
print(c)
# [[11 22 33]
#  [14 25 36]]

なぜ (2, 3)(3,) で計算できるのか。工場の比喩で説明する。

ベルトコンベアAには製品が2列×3個並んでいる。ベルトコンベアBには製品が1列×3個しかない。本来ならサイズが合わないので一括加工はできないはずだ。

ここで自動拡張装置(ブロードキャスティング)が起動する。装置はBの1列を仮想的に2列にコピーして、Aと同じ形状に揃える。実際にはメモリ上でコピーは発生せず、「同じデータを繰り返し読む」ことで実現される。

3.2 ブロードキャスティングの3つのルール

ブロードキャスティングは魔法ではない。明確なルールがある。

ルール1: 次元数が異なる場合、少ない方の先頭に1を補って次元数を揃える

# shape (3,) → shape (1, 3) に自動拡張
a = np.ones((2, 3))        # (2, 3)
b = np.array([1, 2, 3])    # (3,) → (1, 3) と解釈される

ルール2: 各次元について、サイズが一致するか、どちらかが1であれば互換

# (2, 3) と (1, 3) → 次元0: 2 vs 1 → 1を2に拡張 → OK
# (2, 3) と (2, 1) → 次元1: 3 vs 1 → 1を3に拡張 → OK
# (2, 3) と (2, 4) → 次元1: 3 vs 4 → 不一致で1でもない → NG!

ルール3: サイズ1の次元は、もう一方のサイズに引き伸ばされる

a = np.ones((3, 1))    # shape: (3, 1)
b = np.ones((1, 4))    # shape: (1, 4)
c = a + b              # shape: (3, 4) — 両方が引き伸ばされる
print(c.shape)         # (3, 4)

3.3 ブロードキャスティングの実用例

# 例1: 各行からその行の平均を引く(行ごとの正規化)
data = np.array([[10, 20, 30],
                 [40, 50, 60]])

row_mean = data.mean(axis=1, keepdims=True)  # shape: (2, 1)
normalized = data - row_mean                  # (2, 3) - (2, 1) → ブロードキャスト

print(row_mean)
# [[20.]
#  [50.]]
print(normalized)
# [[-10.   0.  10.]
#  [-10.   0.  10.]]
# 例2: 距離行列の計算(全点間の距離を一発で計算)
points = np.random.rand(100, 2)  # 100個の2D座標

# ブロードキャスティングで全組み合わせの差を計算
diff = points[:, np.newaxis, :] - points[np.newaxis, :, :]  # (100, 100, 2)
distances = np.sqrt((diff ** 2).sum(axis=2))                 # (100, 100)
# for文なら二重ループが必要 → ベクトル化で1行

keepdims=True を忘れると、次元が潰れてブロードキャスティングが意図通りに動かないことがあります。集約関数(mean, sum, max 等)を使う際は常に keepdims を意識してください。

ブロードキャスティングのルールを押さえたところで、次はNumPy初心者が最も混乱する「viewとcopy」の問題を整理しよう。この違いを知らないと、「配列を変更したはずなのに元データが壊れていた」というバグに悩まされることになる。


4. viewとcopy — 「元データが壊れた」問題の正体

4.1 viewは監視カメラ、copyは写真

qiita-1.jpeg

NumPyの多くの操作はviewを返す。viewは元データへの「窓」であり、元のメモリバッファを共有している。

工場で言えば、viewは監視カメラの映像だ。カメラを通して見ているベルトコンベアは、実際のラインそのもの。映像越しに製品を入れ替えれば、実際のラインの製品も変わる。

一方、copyは写真撮影だ。撮った瞬間のスナップショットであり、その後に実際のラインが変わっても写真には影響しない。

import numpy as np

original = np.array([1, 2, 3, 4, 5])

# --- view(監視カメラ)---
view = original[1:4]       # スライスはviewを返す
view[0] = 999              # viewを変更すると...
print(original)            # [  1 999   3   4   5] ← 元データも変わる!(;゚д゚)ポカーン

# --- copy(写真撮影)---
original = np.array([1, 2, 3, 4, 5])
copied = original[1:4].copy()  # 明示的にcopy
copied[0] = 999                # copyを変更しても...
print(original)                # [1 2 3 4 5] ← 元データは無傷

4.2 viewを返す操作 / copyを返す操作

操作 返すもの
基本スライス a[1:4] view sub = a[1:4]
reshape view(可能な場合) b = a.reshape(2, 3)
transpose / .T view b = a.T
ravel view(可能な場合) b = a.ravel()
ファンシーインデックス a[[0,2,4]] copy sub = a[[0, 2, 4]]
ブールインデックス a[a > 3] copy sub = a[a > 3]
flatten copy b = a.flatten()
.copy() copy b = a.copy()
# viewかcopyかを確認する方法
a = np.array([1, 2, 3, 4, 5])

s = a[1:4]
print(s.base is a)         # True → viewである(baseが元配列を指す)

f = a[[1, 2, 3]]
print(f.base is a)         # False → copyである

「スライスはview、ファンシーインデックスはcopy」はNumPy初心者が最もハマる落とし穴です。意図せずviewを通じて元データを破壊するバグは、発見が非常に困難です。迷ったら .copy() を付けるのが安全策です。

4.3 reshapeとstridesの関係

reshape がviewを返せる理由は、stridesの操作だけで形状変更を実現できるからだ。メモリ上のデータ配置は一切変えず、「何バイトおきにアクセスするか」の情報だけを書き換える。

a = np.arange(12)
print(a)          # [ 0  1  2  3  4  5  6  7  8  9 10 11]
print(a.strides)  # (8,) — 次の要素まで8バイト

b = a.reshape(3, 4)
print(b)
# [[ 0  1  2  3]
#  [ 4  5  6  7]
#  [ 8  9 10 11]]
print(b.strides)  # (32, 8) — 次の行まで32バイト、次の列まで8バイト

# メモリ上のデータは同一!
print(b.base is a)  # True — viewである

工場で言えば、ベルトコンベア上の製品の配置は変えずに、「3列×4個のラインとして読む」という読み取り方だけを変更したのだ。製品を物理的に移動させるコスト(メモリコピー)がゼロだから高速なのだ。

viewとcopyの仕組みがわかったところで、次はさらに深い領域 — メモリレイアウトの話に進もう。「なぜ同じ計算なのに軸を変えると速度が変わるのか」の答えがここにある。


5. メモリレイアウト — C-contiguousとF-contiguous

5.1 行優先と列優先

2次元配列をメモリ(1次元)上にどう並べるか。この選択が性能を左右する。

arr = np.array([[1, 2, 3],
                [4, 5, 6]])
レイアウト メモリ上の並び 採用言語
C-contiguous(行優先) 1, 2, 3, 4, 5, 6 C, C++, Python(NumPy既定)
F-contiguous(列優先) 1, 4, 2, 5, 3, 6 Fortran, MATLAB, R, Julia

工場の比喩で言えば、製品を行方向に詰めるか、列方向に詰めるかの違いだ。

c_arr = np.array([[1, 2, 3], [4, 5, 6]], order='C')  # C-contiguous
f_arr = np.array([[1, 2, 3], [4, 5, 6]], order='F')  # F-contiguous

print(f"C-contiguous strides: {c_arr.strides}")  # (24, 8) — 行方向に連続
print(f"F-contiguous strides: {f_arr.strides}")  # (8, 16) — 列方向に連続

5.2 なぜこれが性能に影響するのか

CPUはメモリを「キャッシュライン」という塊(通常64バイト)で読み込む。C-contiguousの配列を行方向にアクセスすると、連続するデータが一度にキャッシュに載る。逆に列方向にアクセスすると、必要なデータが飛び飛びになり、キャッシュミスが頻発する。

import numpy as np
import time

size = 5000
arr = np.random.rand(size, size)  # C-contiguous(既定)

# 行方向の合計(メモリ連続方向 → 速い)
start = time.perf_counter()
for _ in range(100):
    _ = arr.sum(axis=1)
elapsed_row = time.perf_counter() - start

# 列方向の合計(メモリ非連続方向 → 遅い)
start = time.perf_counter()
for _ in range(100):
    _ = arr.sum(axis=0)
elapsed_col = time.perf_counter() - start

print(f"行方向合計: {elapsed_row:.4f}")
print(f"列方向合計: {elapsed_col:.4f}")
print(f"速度差:     {elapsed_col / elapsed_row:.2f}")

大規模な行列計算で列方向のアクセスが頻繁な場合は、np.asfortranarray() でF-contiguousに変換するとパフォーマンスが改善することがあります。

メモリレイアウトの知識は、特にPyTorchCUDAを使ったGPU計算に進む際に必須になる。次のセクションでは、NumPyがAI/MLスタック全体の中でどの位置にいるのかを俯瞰しよう。


6. NumPyとAI/MLスタックの関係

6.1 NumPyは「全ての土台」

AI/MLのエコシステムは巨大だが、その根底にあるのは常にNumPyだ。

6.2 NumPy配列 ↔ PyTorchテンソルの変換

import numpy as np
import torch

# NumPy → PyTorch(ゼロコピー。メモリ共有)
np_arr = np.array([1.0, 2.0, 3.0])
tensor = torch.from_numpy(np_arr)      # メモリを共有するview

np_arr[0] = 999
print(tensor)  # tensor([999.,   2.,   3.]) ← 連動する!

# PyTorch → NumPy
tensor2 = torch.tensor([4.0, 5.0, 6.0])
np_arr2 = tensor2.numpy()  # CPU上のテンソルのみ可能

# GPU上のテンソルはcpu()で戻してからnumpy()
# gpu_tensor = tensor2.to("cuda")
# np_from_gpu = gpu_tensor.cpu().numpy()

torch.from_numpy はメモリを共有するviewを作ります。元のNumPy配列を変更するとテンソルも変わります。意図しない副作用を防ぐには torch.tensor(np_arr) でコピーを作ってください。

6.3 NumPyの限界 — GPUとの壁

NumPyは強力だが、CPU専用だ。GPU上の計算はサポートしていない。AI/MLでGPUの恩恵を受けるには、PyTorchやCuPy(NumPy互換のGPU配列ライブラリ)に橋渡しする必要がある。

ライブラリ CPU GPU NumPy互換API
NumPy --
CuPy ほぼ完全互換
PyTorch 部分互換
JAX 高い互換性
# CuPy — NumPyのコードをほぼそのままGPUで動かせる
# pip install cupy-cuda12x
import cupy as cp

a_gpu = cp.random.rand(1_000_000)
b_gpu = cp.random.rand(1_000_000)
c_gpu = a_gpu + b_gpu  # GPU上でベクトル化演算!

# NumPy配列に戻す
c_cpu = cp.asnumpy(c_gpu)

NumPyの立ち位置が明確になったところで、実務で遭遇しやすいエラーと対処法をまとめよう。


7. よくあるエラーと対処法

# エラー/症状 原因 対処法
1 ValueError: operands could not be broadcast together with shapes (3,) (4,) ブロードキャスト不可能な形状 a.shapeb.shape を確認。reshapenp.newaxis で形状を揃える
2 元配列を変更していないのにデータが変わっている viewを経由した意図しない書き換え .copy() で明示的にコピーを作る。arr.base で共有元を確認
3 MemoryError 巨大配列でメモリ不足 dtype を float32 に変更、np.memmap でディスク上の配列を使用、バッチ処理に分割
4 RuntimeWarning: overflow encountered 整数型のオーバーフロー dtype=np.int64 または dtype=np.float64 に明示変更
5 AxisError: axis N is out of bounds for array of dimension M 存在しない軸を指定 arr.ndim で次元数を確認。axis=0 は行方向、axis=1 は列方向
6 計算結果が nan だらけになる ゼロ除算、無効な数学演算 np.nan_to_num() で置換、np.errstate で警告制御
7 np.array([1,2,3]) == np.array([1,2,4]) が True/False の配列になって if 文で使えない 配列同士の比較は要素ごとの比較になる 全要素一致なら np.array_equal(a, b)、いずれか一致なら np.any(a == b)
8 FutureWarning: ... NumPy 2.0 ... NumPy 2.0 での非互換変更の予告 警告メッセージに従い推奨APIに移行。np.bool_np.bool_

エラー2の「viewを経由した意図しない書き換え」は、データ分析パイプラインで最も危険なバグの一つです。pandasの .iloc スライスでも同様の問題が発生します。「原因不明のデータ化け」に遭遇したら、まずviewの存在を疑ってください。


8. NumPy環境診断スクリプト

#!/usr/bin/env python3
"""
NumPy環境診断スクリプト v1.0
- NumPyのバージョン/ビルド情報
- BLAS/LAPACKバックエンドの確認
- 基本的なベンチマーク
- メモリ使用量の見積もり
"""

import sys
import time

def diagnose_numpy():
    try:
        import numpy as np
    except ImportError:
        print("❌ NumPyがインストールされていません")
        print("   pip install numpy でインストールしてください")
        return

    print("=" * 60)
    print("  NumPy 環境診断レポート")
    print("=" * 60)

    # 1. 基本情報
    print("\n[1] NumPy基本情報")
    print(f"  バージョン:    {np.__version__}")
    print(f"  インストール先: {np.__file__}")
    print(f"  Python:        {sys.version.split()[0]}")

    # 2. ビルド構成
    print("\n[2] ビルド構成(BLAS/LAPACK)")
    try:
        config = np.show_config(mode="dicts")
        if isinstance(config, dict):
            blas = config.get("Build Dependencies", {}).get("blas", {})
            lapack = config.get("Build Dependencies", {}).get("lapack", {})
            print(f"  BLAS:   {blas.get('name', '不明')} {blas.get('version', '')}")
            print(f"  LAPACK: {lapack.get('name', '不明')} {lapack.get('version', '')}")
        else:
            print("  (詳細はnp.show_config()で確認)")
    except Exception:
        print("  (ビルド情報の取得に失敗)")
        np.show_config()

    # 3. dtypeサイズ確認
    print("\n[3] dtype別メモリサイズ")
    dtypes = [np.float16, np.float32, np.float64,
              np.int8, np.int16, np.int32, np.int64]
    for dt in dtypes:
        print(f"  {dt.__name__:10s}: {np.dtype(dt).itemsize} bytes")

    # 4. 簡易ベンチマーク
    print("\n[4] 簡易ベンチマーク(100万要素)")
    size = 1_000_000
    a = np.random.rand(size)
    b = np.random.rand(size)

    # 加算
    start = time.perf_counter()
    for _ in range(100):
        _ = a + b
    elapsed = time.perf_counter() - start
    print(f"  加算(float64) × 100回:   {elapsed:.4f}")

    # 行列積
    m = np.random.rand(500, 500)
    start = time.perf_counter()
    for _ in range(10):
        _ = m @ m
    elapsed = time.perf_counter() - start
    print(f"  行列積(500×500) × 10回:  {elapsed:.4f}")

    # ソート
    arr = np.random.rand(size)
    start = time.perf_counter()
    _ = np.sort(arr)
    elapsed = time.perf_counter() - start
    print(f"  ソート(100万要素):        {elapsed:.4f}")

    # 5. メモリ見積もり
    print("\n[5] 配列サイズ別メモリ使用量(float64)")
    shapes = [(1000,), (1000, 1000), (10000, 1000), (1000, 1000, 10)]
    for shape in shapes:
        nbytes = np.prod(shape) * 8  # float64 = 8bytes
        if nbytes < 1024**2:
            mem = f"{nbytes / 1024:.1f} KB"
        elif nbytes < 1024**3:
            mem = f"{nbytes / 1024**2:.1f} MB"
        else:
            mem = f"{nbytes / 1024**3:.2f} GB"
        print(f"  shape {str(shape):20s}{mem}")

    print("\n" + "=" * 60)
    print("  診断完了")
    print("=" * 60)


if __name__ == "__main__":
    diagnose_numpy()

9. ユースケース別ガイド

ユースケース1: 画像処理 — 「画像は3次元配列だ」

画像は (高さ, 幅, チャンネル) の3次元ndarrayとして扱える。

import numpy as np
from PIL import Image

# 画像を読み込んでNumPy配列に変換
img = np.array(Image.open("photo.jpg"))
print(f"shape: {img.shape}")    # 例: (1080, 1920, 3) — フルHD RGB
print(f"dtype: {img.dtype}")    # uint8 (0-255)

# グレースケール変換(ベクトル化演算で一発)
gray = np.dot(img[..., :3], [0.2989, 0.5870, 0.1140]).astype(np.uint8)

# 明るさ調整(ブロードキャスティング)
brighter = np.clip(img.astype(np.int16) + 50, 0, 255).astype(np.uint8)

# 結果を保存
Image.fromarray(gray).save("gray.jpg")
Image.fromarray(brighter).save("brighter.jpg")

ユースケース2: 統計シミュレーション — 「モンテカルロ法で円周率を求める」

import numpy as np

def estimate_pi(n_samples: int = 1_000_000) -> float:
    """モンテカルロ法で円周率を推定する"""
    # 0〜1の一様乱数を生成(ベクトル化)
    x = np.random.rand(n_samples)
    y = np.random.rand(n_samples)

    # 原点からの距離が1以下の点を数える(ベクトル化)
    inside = (x**2 + y**2) <= 1.0

    # 円の面積 / 正方形の面積 = π/4
    pi_estimate = 4.0 * np.sum(inside) / n_samples
    return pi_estimate

# 実行
pi = estimate_pi(10_000_000)
print(f"推定値: {pi:.6f}")
print(f"誤差:   {abs(pi - np.pi):.6f}")

ユースケース3: 特徴量エンジニアリング — 「ML前処理の定石」

import numpy as np

# サンプルデータ(100サンプル × 5特徴量)
data = np.random.randn(100, 5) * [1, 10, 100, 1000, 10000] + [0, 50, 500, 5000, 50000]

# Min-Maxスケーリング(ブロードキャスティング活用)
data_min = data.min(axis=0)                    # shape: (5,)
data_max = data.max(axis=0)                    # shape: (5,)
scaled = (data - data_min) / (data_max - data_min)  # (100, 5)にブロードキャスト

print(f"スケーリング前: min={data.min(axis=0)[:3]}, max={data.max(axis=0)[:3]}")
print(f"スケーリング後: min={scaled.min(axis=0)[:3]}, max={scaled.max(axis=0)[:3]}")

# 標準化(Z-score正規化)
mean = data.mean(axis=0)
std = data.std(axis=0)
standardized = (data - mean) / std

print(f"標準化後: mean≈{standardized.mean(axis=0).round(6)[:3]}")
print(f"標準化後: std≈{standardized.std(axis=0).round(4)[:3]}")

# 外れ値検出(3σルール)
outliers = np.abs(standardized) > 3
print(f"外れ値の数: {outliers.sum()} / {data.size}個中")

10. 学習ロードマップ — NumPyの先にある世界

レベル 目安期間 到達状態
Lv.1 基礎 1〜2週間 配列の生成、整形、基本操作ができる
Lv.2 実践 2〜4週間 ベクトル化とブロードキャストを駆使してfor文を排除できる
Lv.3 データ分析 1〜2ヶ月 pandasと組み合わせて実務レベルのデータ処理ができる
Lv.4 AI/ML 2ヶ月〜 NumPyの知識をPyTorchやGPU計算に応用できる

まとめ

この記事では、NumPyの仕組みを「工場の生産ライン」の比喩で解き明かしてきた。

NumPyのndarrayは、Pythonリスト(手作業テーブル)とは根本的に異なるベルトコンベアだ。同じ規格の製品が隙間なく並び、C言語で書かれた高速な機械が一括処理する。「for文で回すな」の本質は、手作業テーブルで1個ずつ処理するのをやめて、ベルトコンベアに載せろという意味だった。

ブロードキャスティングは、サイズの異なるラインを自動拡張装置で揃える仕組み。viewは監視カメラ(元データと連動)、copyは写真(独立したスナップショット)。そしてメモリレイアウトは製品の並び順であり、アクセスパターンと揃えることでCPUキャッシュの効率が劇的に変わる。

個人的な体験として、筆者がRTX 5090でローカルLLMのベンチマークスクリプトを書いた際、最初はfor文でトークン生成速度を集計していた。「なんか遅いな」と思ってNumPyのベクトル化に書き換えたら処理時間が1/100になって草。数百万トークンの統計処理がfor文で回せるわけがなかったのだ。

NumPyは「とりあえず使えるライブラリ」ではなく、AI/MLスタック全体を支える基盤技術だ。ここで学んだndarray、ベクトル化、ブロードキャスティング、メモリレイアウトの知識は、PyTorchのテンソル操作、CUDAGPU計算、そしてTransformerベースのLLMの理解にそのまま繋がっていく。


参考文献


NumPyの先にあるAI/MLスタックを学びたい方は、こちらのシリーズもどうぞ:


筆者のXアカウントはこちら(@geneLab_999)

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?