この記事の対象読者
-
pd.read_csv()は使えるが、その先の操作で毎回ググっている方 -
DataFrameとSeriesの違いを明確に説明できない方 -
groupbyやmergeの仕組みを「なんとなく」で使っている方 - Excelでのデータ処理に限界を感じ始めた方
この記事で得られること
この記事を読むと、以下のことが理解できます:
- DataFrameの「正体」— 内部でNumPyの配列がどう組み合わさっているか
- loc / iloc の「使い分け」— なぜ2種類あるのか、何が違うのか
- groupbyの「仕組み」— split-apply-combineパターンの全体像
- mergeの「ルール」— SQLのJOINと同じ考え方で理解する
- メソッドチェーンの「書き方」— 可読性と効率を両立するpandasの作法
この記事で扱わないこと
- pandasの全API網羅(公式ドキュメントを参照)
- 時系列データの高度な処理(resample、rolling等の詳細)
- Polars、Dask等の代替ライブラリとの比較
本記事ではpandas 2.x系を前提としています。pandas 1.x系とはCopy-on-Write等の挙動が異なる箇所があります。
本記事の比喩について
この記事では、pandasの仕組みを**「病院のカルテ管理システム」**に見立てて解説する。
| pandasの概念 | 病院での対応物 |
|---|---|
| DataFrame | カルテ棚 — 全患者のカルテが整然と並んでいる |
| 各行(row) | 1人分のカルテ — 患者1人の全情報が1枚にまとまっている |
| 各列(column) | カルテの記入項目 — 名前、年齢、血圧、診断名など |
| Index | カルテ番号 — 患者を一意に特定するID |
| Series | 全患者の特定項目だけを抜き出したリスト — 「全員の血圧一覧」等 |
| loc | カルテ番号で検索 — 「ID:P-0042のカルテを出してください」 |
| iloc | 棚の位置で検索 — 「上から3番目のカルテを出してください」 |
| groupby | 診療科ごとに患者を分類 — 内科の患者だけ集める |
| merge | 2つの台帳を突き合わせ — カルテと検査結果を患者IDで紐付け |
| query / フィルタ | 条件検索 — 「血圧140以上の患者を全員抽出」 |
| pivot_table | 集計レポート — 診療科別・月別の患者数一覧表 |
1. DataFrameの「正体」— 中身はNumPyの集合体
1.1 DataFrameは「カルテ棚」
pandasのDataFrameは、一見するとExcelの表に似ている。だがその内部構造はまるで異なる。
import pandas as pd
import numpy as np
df = pd.DataFrame({
"name": ["田中", "佐藤", "鈴木", "高橋"],
"age": [32, 45, 28, 51],
"bp_sys": [120, 145, 110, 138], # 収縮期血圧
"bp_dia": [80, 95, 70, 88], # 拡張期血圧
"diagnosis": ["正常", "高血圧", "正常", "要観察"]
})
print(df)
出力:
name age bp_sys bp_dia diagnosis
0 田中 32 120 80 正常
1 佐藤 45 145 95 高血圧
2 鈴木 28 110 70 正常
3 高橋 51 138 88 要観察
病院のカルテ棚に4人分のカルテが並んでいる状態だ。各カルテ(行)には名前、年齢、血圧、診断名が記入されている。
1.2 内部構造 — 列ごとに別々のNumPy配列
ここが最も重要なポイントだ。DataFrameは行単位ではなく、列単位でデータを管理している。
カルテ棚に見えるDataFrameの実態は、項目ごとに整理された引き出しだ。「年齢」の引き出しにはint64のNumPy配列が、「名前」の引き出しにはobject型のNumPy配列が入っている。
# 各列のデータ型を確認
print(df.dtypes)
# name object
# age int64
# bp_sys int64
# bp_dia int64
# diagnosis object
# 列を取り出すとSeriesになる
age_series = df["age"]
print(type(age_series)) # <class 'pandas.core.series.Series'>
print(type(age_series.values)) # <class 'numpy.ndarray'>
DataFrameが列単位で管理されている事実は、パフォーマンスに直結します。列方向の操作(特定の列の合計、平均)は高速ですが、行方向の操作(1行ずつ処理するiterrows)は低速です。これは前回の記事で解説したNumPyのメモリレイアウトと同じ原理です。
1.3 Series — 「全患者の血圧一覧」
SeriesはDataFrameの1列分に相当するデータ構造だ。カルテ棚から「全患者の収縮期血圧」だけを抜き出したリストに対応する。
bp = df["bp_sys"]
print(bp)
# 0 120
# 1 145
# 2 110
# 3 138
# Name: bp_sys, dtype: int64
# Seriesは Index + 値 のペア
print(bp.index) # RangeIndex(start=0, stop=4, step=1)
print(bp.values) # [120 145 110 138] — NumPy配列
print(bp.name) # bp_sys
SeriesとDataFrameの関係を整理しておこう。
| 比較項目 | Series | DataFrame |
|---|---|---|
| 次元 | 1次元(1列) | 2次元(複数列) |
| 病院の比喩 | 全患者の特定項目一覧 | カルテ棚全体 |
| NumPyでの対応 | 1次元ndarray | 列ごとのndarray集合 |
| 取得方法 | df["列名"] |
pd.read_csv() 等 |
DataFrameの内部構造がわかったところで、次はデータへのアクセス方法 — loc と iloc の使い分けを理解しよう。この2つを混同すると、意図しないデータが返ってきて首をかしげることになる。
2. loc と iloc — 2つの検索方法を使い分ける
2.1 なぜ2種類あるのか
病院のカルテ棚には2つの探し方がある。
-
カルテ番号で探す →
loc(ラベルベース) -
棚の上から何番目かで探す →
iloc(位置ベース)
通常は同じ結果になるが、Indexがデフォルトの連番(0, 1, 2, ...)でない場合に挙動が決定的に変わる。
# カルテ番号(Index)をカスタム設定
df_custom = df.set_index(pd.Index(["P-001", "P-002", "P-003", "P-004"]))
print(df_custom)
# name age bp_sys bp_dia diagnosis
# P-001 田中 32 120 80 正常
# P-002 佐藤 45 145 95 高血圧
# P-003 鈴木 28 110 70 正常
# P-004 高橋 51 138 88 要観察
# loc — カルテ番号で検索
print(df_custom.loc["P-002"])
# name 佐藤
# age 45
# bp_sys 145
# bp_dia 95
# diagnosis 高血圧
# iloc — 棚の上から何番目かで検索
print(df_custom.iloc[1])
# 同じ結果(2番目の行)
# 範囲指定での違い ← ここが罠!
print(df_custom.loc["P-001":"P-003"]) # P-001, P-002, P-003(終点を含む)
print(df_custom.iloc[0:3]) # 0, 1, 2(終点を含まない)
loc のスライスは終点を含み、iloc のスライスは終点を含みません。Pythonの通常のスライス list[0:3] は終点を含まないので、loc の挙動は直感に反します。
2.2 使い分けの判断基準
原則: 「何で」検索したいかで決める
| やりたいこと | 使うメソッド | 例 |
|---|---|---|
| ラベル名(列名・Index値)で検索 | loc |
df.loc["P-002", "age"] |
| 数値の位置で検索 | iloc |
df.iloc[1, 2] |
| 条件でフィルタ |
loc + ブール配列 |
df.loc[df["age"] > 40] |
| 先頭N行を取得 |
iloc または head
|
df.iloc[:5] / df.head(5)
|
# 実用パターン集
# パターン1: 条件フィルタ(最も頻出)
high_bp = df.loc[df["bp_sys"] >= 140]
print(high_bp)
# name age bp_sys bp_dia diagnosis
# 1 佐藤 45 145 95 高血圧
# パターン2: 複数条件のAND
risk = df.loc[(df["age"] >= 40) & (df["bp_sys"] >= 135)]
print(risk)
# パターン3: 特定の列だけ取得
names_ages = df.loc[:, ["name", "age"]]
# パターン4: 条件フィルタ + 特定の列
result = df.loc[df["bp_sys"] >= 130, ["name", "bp_sys", "diagnosis"]]
2.3 queryメソッド — SQLライクな条件検索
loc + ブール配列は強力だが、条件が複雑になると括弧だらけで読みにくくなる。query メソッドはSQLの WHERE 句に近い書き方ができる。
# locで書くと括弧が多くなる
result = df.loc[(df["age"] >= 40) & (df["bp_sys"] >= 130) & (df["diagnosis"] != "正常")]
# queryで書くとすっきり
result = df.query("age >= 40 and bp_sys >= 130 and diagnosis != '正常'")
# 変数も使える(@で参照)
threshold = 130
result = df.query("bp_sys >= @threshold")
locとilocの使い分けがわかったところで、次はpandasの真骨頂 — groupby の仕組みを解き明かそう。「診療科ごとの患者数を数える」のような集計が、内部でどう動いているかを理解すれば、複雑な集計も恐くなくなる。
3. groupby — split-apply-combineの全体像
3.1 groupbyは「診療科ごとに患者を分類する」操作
groupby は、pandasで最も強力かつ最も誤解されやすい機能だ。
病院のカルテ棚で「診断名ごとに患者を分類して、それぞれの平均年齢を出してください」と頼まれたとする。やることは3ステップだ。
これがsplit-apply-combineパターンだ。pandasの groupby はこの3ステップを1行で実現する。
# 診断名ごとの平均年齢
result = df.groupby("diagnosis")["age"].mean()
print(result)
# diagnosis
# 正常 30.0
# 要観察 51.0
# 高血圧 45.0
# Name: age, dtype: float64
3.2 内部で何が起きているか
groupby オブジェクトは遅延評価だ。.groupby() を呼んだ時点ではまだ何も計算されない。集約関数(mean(), sum(), count() 等)を呼んだ瞬間に初めてsplit-apply-combineが実行される。
# groupbyオブジェクトの中身を覗く
grouped = df.groupby("diagnosis")
# まだ何も計算されていない
print(type(grouped)) # <class 'pandas.core.groupby.generic.DataFrameGroupBy'>
# グループを確認
for name, group in grouped:
print(f"\n--- {name} ---")
print(group)
# --- 正常 ---
# name age bp_sys bp_dia diagnosis
# 0 田中 32 120 80 正常
# 2 鈴木 28 110 70 正常
#
# --- 要観察 ---
# name age bp_sys bp_dia diagnosis
# 3 高橋 51 138 88 要観察
#
# --- 高血圧 ---
# name age bp_sys bp_dia diagnosis
# 1 佐藤 45 145 95 高血圧
3.3 集約関数の使い分け
| 関数 | 意味 | 病院での例 |
|---|---|---|
mean() |
平均 | 診断名ごとの平均年齢 |
sum() |
合計 | 診療科ごとの総患者数 |
count() |
件数 | 各診断名の患者数 |
min() / max()
|
最小/最大 | 最年少/最年長の患者 |
std() |
標準偏差 | 血圧のばらつき |
first() / last()
|
先頭/末尾 | 最初/最後に登録された患者 |
agg() |
複数の集約を同時適用 | 平均と標準偏差を同時に算出 |
# aggで複数の集約を同時に適用
result = df.groupby("diagnosis").agg(
avg_age=("age", "mean"),
max_bp=("bp_sys", "max"),
count=("name", "count")
)
print(result)
# avg_age max_bp count
# diagnosis
# 正常 30.0 120 2
# 要観察 51.0 138 1
# 高血圧 45.0 145 1
3.4 transform — 「グループの値を元の行に戻す」
agg はグループごとに1行に集約するが、transform は元の行数を保ったままグループ内の計算結果を返す。
# 各患者の血圧が、同じ診断名グループ内の平均からどれだけ離れているか
df["bp_deviation"] = df.groupby("diagnosis")["bp_sys"].transform(
lambda x: x - x.mean()
)
print(df[["name", "diagnosis", "bp_sys", "bp_deviation"]])
# name diagnosis bp_sys bp_deviation
# 0 田中 正常 120 5.0
# 1 佐藤 高血圧 145 0.0
# 2 鈴木 正常 110 -5.0
# 3 高橋 要観察 138 0.0
カルテ棚の比喩で言えば、agg は「各診療科の集計レポートを作る」操作、transform は「各患者のカルテに所属科の平均値を書き加える」操作だ。
transform はグループごとの正規化やZスコア計算で頻出します。df.groupby("group")["value"].transform(lambda x: (x - x.mean()) / x.std()) は定型パターンとして覚えておくと便利です。
groupbyの全体像が掴めたところで、次はもう一つの核心機能 — merge を解説する。「2つのテーブルを結合する」操作は、実務のデータ処理で避けて通れない。
4. merge — 2つのテーブルを結合する
4.1 mergeは「カルテと検査結果を突き合わせる」操作
病院では患者のカルテと、検査室から上がってくる検査結果は別々のシステムで管理されていることが多い。これを患者IDで紐付ける操作が merge だ。
# カルテデータ
patients = pd.DataFrame({
"patient_id": ["P-001", "P-002", "P-003", "P-004"],
"name": ["田中", "佐藤", "鈴木", "高橋"],
"age": [32, 45, 28, 51]
})
# 検査結果データ(P-004はまだ検査未実施、P-005は外部患者)
lab_results = pd.DataFrame({
"patient_id": ["P-001", "P-002", "P-003", "P-005"],
"blood_sugar": [95, 180, 88, 210],
"cholesterol": [190, 260, 170, 280]
})
4.2 4種類のmerge
# inner: 両方に存在する患者だけ
inner = pd.merge(patients, lab_results, on="patient_id", how="inner")
print(inner)
# patient_id name age blood_sugar cholesterol
# 0 P-001 田中 32 95 190
# 1 P-002 佐藤 45 180 260
# 2 P-003 鈴木 28 88 170
# left: カルテにある全患者(検査未実施はNaN)
left = pd.merge(patients, lab_results, on="patient_id", how="left")
print(left)
# patient_id name age blood_sugar cholesterol
# 0 P-001 田中 32 95.0 190.0
# 1 P-002 佐藤 45 180.0 260.0
# 2 P-003 鈴木 28 88.0 170.0
# 3 P-004 高橋 51 NaN NaN ← 検査未実施
# right: 検査結果がある全患者(外部患者のカルテはNaN)
right = pd.merge(patients, lab_results, on="patient_id", how="right")
# outer: とにかく全員(欠損はNaN)
outer = pd.merge(patients, lab_results, on="patient_id", how="outer")
4.3 SQLとの対応表
SQLを知っている方はこの対応表で一発で理解できる。
| pandas | SQL | 病院の比喩 |
|---|---|---|
how="inner" |
INNER JOIN |
両方の台帳にいる患者だけ |
how="left" |
LEFT JOIN |
左のカルテを基準に、検査結果があれば紐付け |
how="right" |
RIGHT JOIN |
右の検査結果を基準に、カルテがあれば紐付け |
how="outer" |
FULL OUTER JOIN |
全員。欠損はNULL(NaN) |
on="key" |
ON a.key = b.key |
突き合わせに使うキー列 |
# キー名が異なる場合
patients2 = patients.rename(columns={"patient_id": "pid"})
merged = pd.merge(patients2, lab_results, left_on="pid", right_on="patient_id")
# 複数キーでの結合
merged = pd.merge(df_a, df_b, on=["year", "month", "department"])
mergeで行数が予想以上に増えた場合、結合キーに重複がある可能性が高いです。df["key"].duplicated().sum() で結合前に重複チェックする習慣をつけましょう。1対多の結合は行が膨張します。
mergeを理解したところで、次はpandasの「書き方の作法」— メソッドチェーンについて学ぼう。可読性と効率を両立する、モダンなpandasコードの書き方だ。
5. メソッドチェーン — パイプラインで書く
5.1 なぜメソッドチェーンなのか
pandasの処理は、複数の操作を連続で適用することが多い。これを中間変数に毎回代入するか、メソッドチェーンで一気に書くかで可読性が大きく変わる。
病院の比喩で言えば、メソッドチェーンはベルトコンベア式の連続処理だ。カルテを取り出し → フィルタして → 集計して → ソートする、という一連の流れを途切れなく記述する。
# ❌ 中間変数だらけ(読みにくい)
df_filtered = df[df["age"] >= 30]
df_selected = df_filtered[["name", "age", "bp_sys"]]
df_sorted = df_selected.sort_values("bp_sys", ascending=False)
df_reset = df_sorted.reset_index(drop=True)
result = df_reset
# ✅ メソッドチェーン(処理の流れが一目でわかる)
result = (
df
.query("age >= 30")
.loc[:, ["name", "age", "bp_sys"]]
.sort_values("bp_sys", ascending=False)
.reset_index(drop=True)
)
5.2 assignで列を追加する
メソッドチェーンの中で新しい列を追加するには assign を使う。
result = (
df
.assign(
# 平均血圧の計算
bp_mean=lambda x: (x["bp_sys"] + x["bp_dia"]) / 2,
# 高血圧フラグ
is_hypertension=lambda x: x["bp_sys"] >= 140
)
.query("bp_mean >= 100")
.sort_values("bp_mean", ascending=False)
)
print(result[["name", "bp_sys", "bp_dia", "bp_mean", "is_hypertension"]])
5.3 pipeでカスタム関数を挟む
メソッドチェーンにビルトインメソッドでは実現できない処理を挟むには pipe を使う。
def add_risk_score(df):
"""リスクスコアを計算するカスタム関数"""
df = df.copy()
df["risk_score"] = (
(df["age"] / 100)
+ (df["bp_sys"] / 200)
+ (df["bp_dia"] / 150)
)
return df
def categorize_risk(df, threshold=1.0):
"""リスクカテゴリを付与"""
df = df.copy()
df["risk_category"] = np.where(
df["risk_score"] >= threshold, "高リスク", "低リスク"
)
return df
# pipeでカスタム関数をチェーンに組み込む
result = (
df
.pipe(add_risk_score)
.pipe(categorize_risk, threshold=0.95)
.sort_values("risk_score", ascending=False)
)
pipe を使えば、複雑なビジネスロジックもメソッドチェーンの中に自然に組み込めます。関数を小さく分割し、pipe で繋ぐスタイルはテストもしやすくなります。
メソッドチェーンの書き方がわかったところで、次は実務で最も差がつくテーマ — パフォーマンス最適化のポイントを解説しよう。「なぜ iterrows は遅いのか」の答えがここにある。
6. パフォーマンス — 「やってはいけない」操作と代替手段
6.1 pandasの速度を殺す3大パターン
パターン1: iterrowsで1行ずつ処理する
# ❌ 遅い(1行ずつPythonの世界でループ)
for idx, row in df.iterrows():
df.loc[idx, "bp_mean"] = (row["bp_sys"] + row["bp_dia"]) / 2
# ✅ 速い(ベクトル化演算 — NumPy/C言語レベルで一括処理)
df["bp_mean"] = (df["bp_sys"] + df["bp_dia"]) / 2
カルテ棚の比喩で言えば、iterrows はカルテを1枚ずつ手に取って記入する作業。ベクトル化は全カルテに一括でスタンプを押す機械だ。
パターン2: DataFrameに1行ずつappendする
# ❌ 非常に遅い(毎回DataFrameを再生成)
result = pd.DataFrame()
for i in range(10000):
new_row = pd.DataFrame({"a": [i], "b": [i * 2]})
result = pd.concat([result, new_row]) # 毎回コピーが発生
# ✅ 速い(リストに溜めてから一括変換)
rows = []
for i in range(10000):
rows.append({"a": i, "b": i * 2})
result = pd.DataFrame(rows)
パターン3: 文字列型の列に対する大量操作
# ❌ object型は遅い
df["name_upper"] = df["name"].str.upper() # これ自体は問題ないが...
# ✅ カテゴリ型に変換すると劇的に速くなるケースがある
df["diagnosis"] = df["diagnosis"].astype("category")
# 値の種類が少ない列(診断名、都道府県、性別等)はカテゴリ型が有利
6.2 速度比較ベンチマーク
import pandas as pd
import numpy as np
import time
# 100万行のデータ
n = 1_000_000
df_large = pd.DataFrame({
"a": np.random.rand(n),
"b": np.random.rand(n),
})
# --- iterrows ---
start = time.perf_counter()
results = []
for idx, row in df_large.head(10000).iterrows(): # 1万行だけでも...
results.append(row["a"] + row["b"])
elapsed_iter = time.perf_counter() - start
# --- apply ---
start = time.perf_counter()
_ = df_large.apply(lambda row: row["a"] + row["b"], axis=1)
elapsed_apply = time.perf_counter() - start
# --- ベクトル化 ---
start = time.perf_counter()
_ = df_large["a"] + df_large["b"]
elapsed_vec = time.perf_counter() - start
print(f"iterrows (1万行のみ): {elapsed_iter:.4f}秒")
print(f"apply (100万行): {elapsed_apply:.4f}秒")
print(f"ベクトル化 (100万行): {elapsed_vec:.6f}秒")
| 方法 | 100万行の処理時間 | 速度比 |
|---|---|---|
| iterrows | 推定 ~120秒 | 1x |
| apply(axis=1) | 約 5〜10秒 | ~15x |
| ベクトル化 | 約 0.002秒 | ~60,000x |
iterrowsとベクトル化で6万倍の差。草。これが「for文で回すな」の実態だ。
apply(axis=1) は iterrows よりマシですが、ベクトル化と比べると桁違いに遅いです。apply を使う前に「この処理はベクトル化できないか?」と自問する習慣をつけてください。
パフォーマンスの落とし穴を把握したところで、次は実務で頻出するCSV/Excel読み書きの実践テクニックをまとめよう。
7. データ入出力 — CSV / Excel / SQL を使いこなす
7.1 CSV読み込みの実用テクニック
# 基本の読み込み
df = pd.read_csv("data.csv")
# 文字コード指定(日本語CSVで頻出)
df = pd.read_csv("data.csv", encoding="cp932") # Windows Excel由来
# 列のデータ型を明示指定(メモリ節約 + 型エラー防止)
df = pd.read_csv("data.csv", dtype={
"patient_id": str, # 先頭ゼロ落ち防止("001" が 1 にならない)
"age": "Int64", # 欠損値許容の整数型
"bp_sys": float,
})
# 巨大ファイルを分割読み込み
for chunk in pd.read_csv("huge_data.csv", chunksize=100_000):
process(chunk) # 10万行ずつ処理
# 特定の列だけ読み込み(メモリ節約)
df = pd.read_csv("data.csv", usecols=["patient_id", "age", "bp_sys"])
# 日付列の自動パース
df = pd.read_csv("data.csv", parse_dates=["visit_date"])
7.2 出力
# CSV出力
df.to_csv("output.csv", index=False, encoding="utf-8-sig") # Excel互換UTF-8
# Excel出力
df.to_excel("output.xlsx", index=False, sheet_name="患者データ")
# 複数シートへの出力
with pd.ExcelWriter("report.xlsx") as writer:
df_patients.to_excel(writer, sheet_name="患者一覧", index=False)
df_summary.to_excel(writer, sheet_name="集計", index=False)
7.3 環境別設定ファイル例
# config_dev.yaml — 開発環境
data:
source: "sample_data.csv"
encoding: "utf-8"
chunk_size: 1000
dtype_overrides:
patient_id: str
age: Int64
output:
format: "csv"
path: "./output/dev/"
# config_prod.yaml — 本番環境
data:
source: "postgresql://db-prod:5432/hospital"
chunk_size: 100000
dtype_overrides:
patient_id: str
age: Int64
output:
format: "parquet"
path: "/data/prod/output/"
compression: "snappy"
# config_test.yaml — テスト環境
data:
source: "test_fixtures/test_data.csv"
encoding: "utf-8"
chunk_size: 100
dtype_overrides:
patient_id: str
age: Int64
output:
format: "csv"
path: "./output/test/"
# 設定ファイル読み込みユーティリティ
import yaml
from pathlib import Path
def load_config(env: str = "dev") -> dict:
config_path = Path(f"config_{env}.yaml")
with open(config_path) as f:
return yaml.safe_load(f)
def load_data(config: dict) -> pd.DataFrame:
source = config["data"]["source"]
if source.startswith("postgresql://"):
return pd.read_sql("SELECT * FROM patients", source)
else:
return pd.read_csv(
source,
encoding=config["data"].get("encoding", "utf-8"),
dtype=config["data"].get("dtype_overrides", None)
)
データ入出力のテクニックを押さえたところで、実務で遭遇しやすいエラーと対処法をまとめよう。
8. よくあるエラーと対処法
| # | エラー/症状 | 原因 | 対処法 |
|---|---|---|---|
| 1 | SettingWithCopyWarning |
スライスで取得したviewに対する代入 |
.copy() で明示コピーを作るか、.loc で直接代入 |
| 2 | KeyError: '列名' |
存在しない列名を指定 |
df.columns.tolist() で列名を確認。空白の混入に注意 |
| 3 | mergeで行数が爆増 | 結合キーに重複がある(1対多 or 多対多) | merge前に df["key"].duplicated().sum() で重複チェック |
| 4 | 数値列なのに sum() が文字列結合になる |
列がobject型(数値に見える文字列) |
pd.to_numeric(df["col"], errors="coerce") で変換 |
| 5 | CSVの先頭ゼロが消える |
"001" が整数 1 として読まれる |
dtype={"col": str} で文字列型を明示指定 |
| 6 |
read_csv で文字化け |
エンコーディング不一致 |
encoding="cp932" や encoding="shift_jis" を試す |
| 7 |
NaN が混在して計算結果が全部 NaN
|
欠損値の伝播 |
df["col"].fillna(0) で補完、または skipna=True を確認 |
| 8 |
groupby の結果でIndexがおかしい |
groupbyがキー列をIndexにする |
.reset_index() でIndexを列に戻す |
| 9 | メモリ不足で MemoryError
|
DataFrameが巨大すぎる |
chunksize で分割読込、dtypeで型を小さくする(float64→float32) |
| 10 |
apply が異常に遅い |
行方向の apply(axis=1) を使っている |
ベクトル化演算に書き換え。np.where や np.select を検討 |
エラー1の SettingWithCopyWarning はpandas 2.0以降でCopy-on-Write(CoW)が導入され、将来的に解消される方向です。ただし現時点では警告が出たら .copy() を付けるのが最も安全な対処法です。
9. pandas環境診断スクリプト
#!/usr/bin/env python3
"""
pandas環境診断スクリプト v1.0
- pandas/NumPyのバージョン確認
- バックエンド情報
- 基本的なベンチマーク
- CSVの読み込みテスト
"""
import sys
import time
def diagnose_pandas():
try:
import pandas as pd
import numpy as np
except ImportError as e:
print(f"❌ {e}")
print(" pip install pandas numpy でインストールしてください")
return
print("=" * 60)
print(" pandas 環境診断レポート")
print("=" * 60)
# 1. 基本情報
print("\n[1] バージョン情報")
print(f" pandas: {pd.__version__}")
print(f" NumPy: {np.__version__}")
print(f" Python: {sys.version.split()[0]}")
# 2. オプション依存ライブラリ
print("\n[2] オプション依存ライブラリ")
optional = {
"openpyxl": "Excel(.xlsx)読み書き",
"xlrd": "旧Excel(.xls)読み込み",
"sqlalchemy": "SQL読み書き",
"pyarrow": "Parquet/Arrow対応",
"fastparquet": "Parquet対応(代替)",
"matplotlib": "DataFrame.plot()",
"lxml": "HTML/XML読み込み",
}
for pkg, desc in optional.items():
try:
mod = __import__(pkg)
ver = getattr(mod, "__version__", "OK")
print(f" ✅ {pkg:15s} {ver:10s} ({desc})")
except ImportError:
print(f" ❌ {pkg:15s} {'':10s} ({desc})")
# 3. Copy-on-Write設定
print("\n[3] Copy-on-Write (CoW) 設定")
try:
cow_status = pd.options.mode.copy_on_write
print(f" CoW: {cow_status}")
except AttributeError:
print(" CoW: 未対応バージョン")
# 4. 簡易ベンチマーク
print("\n[4] 簡易ベンチマーク")
n = 1_000_000
# DataFrame生成
start = time.perf_counter()
df = pd.DataFrame({
"a": np.random.rand(n),
"b": np.random.rand(n),
"c": np.random.choice(["X", "Y", "Z"], n),
})
elapsed = time.perf_counter() - start
print(f" DataFrame生成(100万行): {elapsed:.4f}秒")
# groupby
start = time.perf_counter()
_ = df.groupby("c")["a"].mean()
elapsed = time.perf_counter() - start
print(f" groupby + mean: {elapsed:.4f}秒")
# ソート
start = time.perf_counter()
_ = df.sort_values("a")
elapsed = time.perf_counter() - start
print(f" sort_values: {elapsed:.4f}秒")
# merge
df2 = pd.DataFrame({
"c": ["X", "Y", "Z"],
"label": ["Alpha", "Beta", "Gamma"]
})
start = time.perf_counter()
_ = pd.merge(df, df2, on="c")
elapsed = time.perf_counter() - start
print(f" merge(100万行 × 3行): {elapsed:.4f}秒")
# 5. メモリ使用量
print("\n[5] メモリ使用量")
mem = df.memory_usage(deep=True)
total_mb = mem.sum() / (1024 ** 2)
print(f" DataFrame(100万行×3列): {total_mb:.1f} MB")
for col in df.columns:
col_mb = mem[col] / (1024 ** 2)
print(f" {col:5s} ({df[col].dtype}): {col_mb:.1f} MB")
# カテゴリ型の効果
df["c_cat"] = df["c"].astype("category")
mem_cat = df["c_cat"].memory_usage(deep=True) / (1024 ** 2)
mem_obj = mem["c"] / (1024 ** 2)
print(f"\n 文字列→カテゴリ型変換の効果:")
print(f" object型: {mem_obj:.1f} MB")
print(f" category型: {mem_cat:.1f} MB")
print(f" 削減率: {(1 - mem_cat / mem_obj) * 100:.0f}%")
print("\n" + "=" * 60)
print(" 診断完了")
print("=" * 60)
if __name__ == "__main__":
diagnose_pandas()
10. ユースケース別ガイド
ユースケース1: ログ分析 — 「アクセスログから傾向を掴む」
import pandas as pd
import numpy as np
# Webアクセスログの読み込み
df_log = pd.DataFrame({
"timestamp": pd.date_range("2026-04-01", periods=10000, freq="min"),
"path": np.random.choice(["/", "/about", "/api/data", "/login", "/404"], 10000),
"status": np.random.choice([200, 301, 404, 500], 10000, p=[0.85, 0.05, 0.08, 0.02]),
"response_ms": np.random.exponential(200, 10000).astype(int),
})
# 時間帯別のアクセス数(メソッドチェーン)
hourly_access = (
df_log
.assign(hour=lambda x: x["timestamp"].dt.hour)
.groupby("hour")
.agg(
total_requests=("path", "count"),
error_rate=("status", lambda x: (x >= 400).mean()),
avg_response_ms=("response_ms", "mean"),
p95_response_ms=("response_ms", lambda x: x.quantile(0.95)),
)
.round(3)
)
print(hourly_access.head())
ユースケース2: 売上データの集計 — 「pivot_tableでExcelピボットを再現」
# 売上データ
df_sales = pd.DataFrame({
"date": pd.date_range("2026-01-01", periods=365, freq="D").repeat(3),
"product": ["Widget", "Gadget", "Doohickey"] * 365,
"revenue": np.random.randint(1000, 50000, 365 * 3),
"quantity": np.random.randint(1, 100, 365 * 3),
})
df_sales["month"] = df_sales["date"].dt.to_period("M")
# Excelのピボットテーブルと同等の操作
pivot = pd.pivot_table(
df_sales,
values="revenue",
index="month",
columns="product",
aggfunc="sum",
margins=True, # 合計行/列を追加
margins_name="合計"
)
print(pivot.tail())
ユースケース3: 前処理パイプライン — 「ML用のデータクリーニング」
def preprocess_pipeline(df: pd.DataFrame) -> pd.DataFrame:
"""ML向けデータ前処理パイプライン"""
return (
df
# 1. 欠損値の処理
.dropna(subset=["patient_id"]) # IDがないレコードは除外
.fillna({"bp_sys": df["bp_sys"].median()}) # 血圧の欠損は中央値で補完
# 2. 型変換
.assign(
age=lambda x: x["age"].astype("Int64"),
diagnosis=lambda x: x["diagnosis"].astype("category"),
)
# 3. 外れ値の除去(3σルール)
.pipe(lambda x: x[np.abs(x["bp_sys"] - x["bp_sys"].mean()) <= 3 * x["bp_sys"].std()])
# 4. 特徴量の追加
.assign(
bp_mean=lambda x: (x["bp_sys"] + x["bp_dia"]) / 2,
age_group=lambda x: pd.cut(x["age"], bins=[0, 30, 50, 70, 120],
labels=["young", "middle", "senior", "elderly"]),
)
# 5. Indexのリセット
.reset_index(drop=True)
)
# 使用例
df_clean = preprocess_pipeline(df)
この前処理パイプラインの出力をPyTorchのDatasetに渡す場合は、.values でNumPy配列に変換してから torch.from_numpy() で変換します。
11. 学習ロードマップ — pandasの先にある世界
| レベル | 目安期間 | 到達状態 |
|---|---|---|
| Lv.1 基礎 | 1〜2週間 | CSV読み込みからフィルタリング、基本的な集計ができる |
| Lv.2 実践 | 2〜4週間 | groupby・merge・メソッドチェーンを駆使して実務データを処理できる |
| Lv.3 応用 | 1〜2ヶ月 | 時系列・大規模データ・ML前処理パイプラインを構築できる |
| Lv.4 発展 | 2ヶ月〜 | Polarsへの移行やMLパイプラインとの統合ができる |
まとめ
この記事では、pandasの仕組みを「病院のカルテ管理システム」の比喩で解き明かしてきた。
DataFrameはカルテ棚であり、内部は列ごとに独立したNumPy配列が並んでいる。だからこそ列方向の操作は高速で、行方向のループ(iterrows)は絶望的に遅い。loc はカルテ番号での検索、iloc は棚の位置番号での検索。この2つは似ているようで根本的に異なる。
groupby は患者を診療科ごとに分類するsplit-apply-combineパターンだった。merge はカルテと検査結果を患者IDで突き合わせる操作であり、SQLのJOINと同じ考え方で理解できる。そしてメソッドチェーンは、これらの操作をベルトコンベア式に連結する「モダンなpandasの書き方」だ。
個人的な体験として、筆者がローカルLLMのベンチマーク結果を分析する際、最初は愚直に iterrows で1行ずつ処理していた。100万トークン分のログ処理に20分かかって「これ終わらんのでは...」と白目になったが、ベクトル化 + groupbyに書き換えたら0.3秒で完了した...orz。6万倍速いとか意味がわからない。NumPyの回でも感じたが、pandasで「遅い」と思ったら、まず自分の書き方を疑うべきだと痛感した。
pandasは「CSVを読むためのライブラリ」ではない。構造化データに対するあらゆる操作を、Pythonの世界で完結させる基盤技術だ。ここで学んだgroupby、merge、メソッドチェーンの知識は、そのままSQLの理解にも、scikit-learnやPyTorchの前処理パイプライン構築にも直結していく。
参考文献
Python・NumPyの基礎から学びたい方は、こちらのシリーズもどうぞ:
筆者のXアカウントはこちら(@geneLab_999)