プログラミング

PandasでJupyterが落ちる原因!?ループ内のpd.concatを「リスト結合」に変えてメモリ不足を解消する方法

ogp プログラミング

「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 MB52.22 MB
RSS 実行後185.83 MB132.75 MB

 

 

まとめ

巨大データを扱う時の鉄則として、以下のことを覚えておきましょう!

  • ループ内での pd.concat は絶対避ける。
  • データフレームは「リスト」に溜めてから一括処理する。
  • 結合後は delgc.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 の方が実質的にメモリ問題を起こしやすい。")

コメント