「Jupyter Notebookで大量のデータを結合していたら、突然カーネルが死んだ(Dead Kernel)……」
データ分析の現場でよくある光景ですが、その原因の多くはマシンスペック不足ではなく、Pandasの書き方(アンチパターン)にあります。
pandas で複数の DataFrame を結合するとき、pd.concat() を使うのはお馴染みの方法です。
しかし、その呼び方次第でパフォーマンスが劇的に変わることをご存知でしょうか?
ループ処理の中で1行ずつ pd.concat() を呼ぶコードは、初学者が書きがちなアンチパターンのひとつです。
本記事では、10,000行×100列のデータを使って、ループ内 concat と一括 concat の性能差を実測してみます。
【NG例】パターンA:ループ内で pd.concat
# ──────────────────────────────────────────────
# 方法A: ループ内で毎回 concat(アンチパターン)
# ──────────────────────────────────────────────
def method_a_concat_in_loop():
"""ループの各イテレーションで pd.concat を呼ぶ方法"""
rss_history = []
tracemalloc_history = []
result = pd.DataFrame(columns=COLUMNS)
for i in range(NUM_ROWS):
row = generate_row()
result = pd.concat([result, row], ignore_index=True)
if i % SAMPLE_INTERVAL == 0 or i == NUM_ROWS - 1:
rss_history.append((i, get_current_rss_mb()))
tracemalloc_history.append((i, tracemalloc.get_traced_memory()[0] / 1024 / 1024))
return result, rss_history, tracemalloc_history
ループの各イテレーションで pd.concat() を呼び、結果を result に再代入しています。
一見シンプルですが、毎回 DataFrame 全体のコピーが発生するため、データが大きくなるほど急激に遅くなります。
回数を重ねるごとに、メモリ上には「古いデータ」「新しいデータ」「結合中のデータ」が混在し、一時的に膨大な領域を占有します。
100 回も繰り返すと、最終的なデータ量の何十倍もの負荷がかかり、Jupyterが耐えきれなくなって落ちる可能性があります。
【推奨】パターンB:リストに溜めて一括結合
# ──────────────────────────────────────────────
# 方法B: リストに溜めて一括 concat(推奨パターン)
# ──────────────────────────────────────────────
def method_b_list_then_concat():
"""リストに DataFrame を追加し、最後に pd.concat を呼ぶ方法"""
rss_history = []
tracemalloc_history = []
frames = []
for i in range(NUM_ROWS):
row = generate_row()
frames.append(row)
if i % SAMPLE_INTERVAL == 0 or i == NUM_ROWS - 1:
rss_history.append((i, get_current_rss_mb()))
tracemalloc_history.append((i, tracemalloc.get_traced_memory()[0] / 1024 / 1024))
result = pd.concat(frames, ignore_index=True)
rss_history.append((NUM_ROWS, get_current_rss_mb()))
tracemalloc_history.append((NUM_ROWS, tracemalloc.get_traced_memory()[0] / 1024 / 1024))
return result, rss_history, tracemalloc_history各行の DataFrame をリストに append していき、ループ終了後に1回だけ pd.concat() を呼びます。これにより中間コピーが発生せず、非常に高速です。
Pythonのリストへの append は、データそのものをコピーするのではなく、メモリ上の「場所(参照)」を記録するだけなので、負荷がほぼゼロです。
最後に一度だけ pd.concat を実行することで、データのコピーが1回で済むため、メモリ消費を最小限に抑えられ、処理時間も圧倒的に短縮されます。
検証:実行時間とメモリ比較
| パターンA(ループ内 concat) | パターンB(リスト→一括 concat) | |
|---|---|---|
| 実行時間 | 41.152 秒 | 7.129 秒 |
| racemalloc ピーク | 16.10 MB | 52.22 MB |
| RSS 実行後 | 185.83 MB | 132.75 MB |
まとめ
巨大データを扱う時の鉄則として、以下のことを覚えておきましょう!
- ループ内での
pd.concatは絶対避ける。 - データフレームは「リスト」に溜めてから一括処理する。
- 結合後は
delとgc.collect()で不要なメモリを即座に解放する。
これだけで、今まで落ちていた処理が嘘のようにスムーズに動くはずです。
ぜひご自身の環境で試してみてください!
付録:ソースコード一覧
"""
pandas.concat パフォーマンス比較ベンチマーク v2
tracemalloc に加え、OS レベルのプロセスメモリ (RSS) も計測することで、
メモリフラグメンテーションの影響を可視化する。
比較対象:
方法A: ループ内で毎回 pd.concat を呼ぶ(アンチパターン)
方法B: リストに溜めてからループ外で一括 pd.concat(推奨パターン)
データ仕様: 10,000行 × 100列
"""
import time
import tracemalloc
import resource # macOS/Linux のプロセスメモリ計測
import gc
import numpy as np
import pandas as pd
# ──────────────────────────────────────────────
# 設定
# ──────────────────────────────────────────────
NUM_ROWS = 10_000
NUM_COLS = 100
COLUMNS = [f"col_{i}" for i in range(NUM_COLS)]
SAMPLE_POINTS = 20 # メモリ推移を記録するサンプル数
SAMPLE_INTERVAL = NUM_ROWS // SAMPLE_POINTS
def generate_row():
"""1行分のデータを DataFrame として生成する"""
data = np.random.rand(1, NUM_COLS)
return pd.DataFrame(data, columns=COLUMNS)
def get_rss_mb():
"""現在のプロセスの RSS (Resident Set Size) を MB で返す"""
# resource.getrusage は macOS では bytes, Linux では KB
usage = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
import sys
if sys.platform == "darwin":
return usage / 1024 / 1024 # macOS: bytes → MB
else:
return usage / 1024 # Linux: KB → MB
def get_current_rss_mb():
"""現在のRSSを取得(/proc/self/statm または psutil)"""
import sys
try:
import psutil
process = psutil.Process()
return process.memory_info().rss / 1024 / 1024
except ImportError:
if sys.platform == "darwin":
import subprocess
pid = str(__import__('os').getpid())
result = subprocess.run(['ps', '-o', 'rss=', '-p', pid],
capture_output=True, text=True)
return int(result.stdout.strip()) / 1024 # KB → MB
else:
with open('/proc/self/statm', 'r') as f:
pages = int(f.read().split()[1])
return pages * resource.getpagesize() / 1024 / 1024
# ──────────────────────────────────────────────
# 方法A: ループ内で毎回 concat(アンチパターン)
# ──────────────────────────────────────────────
def method_a_concat_in_loop():
"""ループの各イテレーションで pd.concat を呼ぶ方法"""
rss_history = []
tracemalloc_history = []
result = pd.DataFrame(columns=COLUMNS)
for i in range(NUM_ROWS):
row = generate_row()
result = pd.concat([result, row], ignore_index=True)
if i % SAMPLE_INTERVAL == 0 or i == NUM_ROWS - 1:
rss_history.append((i, get_current_rss_mb()))
tracemalloc_history.append((i, tracemalloc.get_traced_memory()[0] / 1024 / 1024))
return result, rss_history, tracemalloc_history
# ──────────────────────────────────────────────
# 方法B: リストに溜めて一括 concat(推奨パターン)
# ──────────────────────────────────────────────
def method_b_list_then_concat():
"""リストに DataFrame を追加し、最後に pd.concat を呼ぶ方法"""
rss_history = []
tracemalloc_history = []
frames = []
for i in range(NUM_ROWS):
row = generate_row()
frames.append(row)
if i % SAMPLE_INTERVAL == 0 or i == NUM_ROWS - 1:
rss_history.append((i, get_current_rss_mb()))
tracemalloc_history.append((i, tracemalloc.get_traced_memory()[0] / 1024 / 1024))
result = pd.concat(frames, ignore_index=True)
rss_history.append((NUM_ROWS, get_current_rss_mb()))
tracemalloc_history.append((NUM_ROWS, tracemalloc.get_traced_memory()[0] / 1024 / 1024))
return result, rss_history, tracemalloc_history
# ──────────────────────────────────────────────
# ベンチマーク実行関数
# ──────────────────────────────────────────────
def run_benchmark(func, label):
"""関数の実行時間とメモリ使用量を計測する"""
gc.collect()
tracemalloc.start()
rss_before = get_current_rss_mb()
start_time = time.perf_counter()
df, rss_history, tracemalloc_history = func()
elapsed = time.perf_counter() - start_time
current_mem, peak_mem = tracemalloc.get_traced_memory()
tracemalloc.stop()
rss_after = get_current_rss_mb()
rss_peak = get_rss_mb() # プロセス生存期間中の最大 RSS
print(f"\n{'='*60}")
print(f" {label}")
print(f"{'='*60}")
print(f" 実行時間 : {elapsed:>10.3f} 秒")
print(f" tracemalloc ピーク : {peak_mem / 1024 / 1024:>10.2f} MB")
print(f" tracemalloc 現在 : {current_mem / 1024 / 1024:>10.2f} MB")
print(f" RSS (実行前) : {rss_before:>10.2f} MB")
print(f" RSS (実行後) : {rss_after:>10.2f} MB")
print(f" RSS (プロセス最大) : {rss_peak:>10.2f} MB")
print(f" DataFrame サイズ : {df.memory_usage(deep=True).sum() / 1024 / 1024:>10.2f} MB")
print(f" DataFrame shape : {df.shape}")
print(f"\n --- メモリ推移 (RSS) ---")
for step, rss in rss_history:
bar = "█" * int(rss / 2)
print(f" {step:>6} 行目: {rss:>8.1f} MB {bar}")
return {
"label": label,
"elapsed_sec": round(elapsed, 3),
"tracemalloc_peak_mb": round(peak_mem / 1024 / 1024, 2),
"rss_before_mb": round(rss_before, 2),
"rss_after_mb": round(rss_after, 2),
"rss_peak_mb": round(rss_peak, 2),
"rss_history": rss_history,
}
# ──────────────────────────────────────────────
# メイン
# ──────────────────────────────────────────────
if __name__ == "__main__":
print(f"Python version : {__import__('sys').version}")
print(f"pandas version : {pd.__version__}")
print(f"numpy version : {np.__version__}")
print(f"\nデータ仕様: {NUM_ROWS:,} 行 × {NUM_COLS} 列")
# --- 方法A ---
np.random.seed(42)
result_a = run_benchmark(method_a_concat_in_loop, "方法A: ループ内 concat(アンチパターン)")
# GC してメモリを解放してから方法Bを実行
gc.collect()
# --- 方法B ---
np.random.seed(42)
result_b = run_benchmark(method_b_list_then_concat, "方法B: リスト → 一括 concat(推奨)")
# ──────────────────────────────────────────
# 比較サマリー
# ──────────────────────────────────────────
print(f"\n{'='*60}")
print(f" 比較サマリー")
print(f"{'='*60}")
speedup = result_a["elapsed_sec"] / result_b["elapsed_sec"] if result_b["elapsed_sec"] > 0 else float("inf")
print(f"\n {'':>26} {'方法A':>12} {'方法B':>12}")
print(f" {'─'*52}")
print(f" {'実行時間 (秒)':>26} {result_a['elapsed_sec']:>12.3f} {result_b['elapsed_sec']:>12.3f}")
print(f" {'tracemalloc ピーク (MB)':>26} {result_a['tracemalloc_peak_mb']:>12.2f} {result_b.get('tracemalloc_peak_mb', 0):>12.2f}")
print(f" {'RSS 実行前 (MB)':>26} {result_a['rss_before_mb']:>12.2f} {result_b['rss_before_mb']:>12.2f}")
print(f" {'RSS 実行後 (MB)':>26} {result_a['rss_after_mb']:>12.2f} {result_b['rss_after_mb']:>12.2f}")
print(f"\n 速度向上: 方法B は方法A の約 {speedup:.1f} 倍高速")
print(f"\n ※ tracemalloc は Python アロケータ経由のメモリのみ計測。")
print(f" 実際のプロセスメモリ (RSS) はメモリフラグメンテーションの")
print(f" 影響を受けるため、方法A の方が実質的にメモリ問題を起こしやすい。")



コメント