Pythonデータ分析 #3 選択とフィルタ — loc、iloc、ブールインデックス

読了 7分

データを読み込んだら、次にやることは決まっています。全体から必要な部分だけを選び出すことです。「この列だけ」「この条件に合う行だけ」「両方を同時に」といった選択が、実際の分析コードの半分を占めます。今回は pandas の選択ツールである列選択、lociloc、ブールインデックスを順に扱い、誰もが一度は出会う SettingWithCopyWarning まで整理します。#2 読み込みと探索では CSV を読み込んでデータの姿を眺めました。今回はどこでもすぐ追試できるように、小さな DataFrame をコードで作ってから始めます。

サンプルデータ
import pandas as pd

df = pd.DataFrame({
    "name": ["味噌ラーメン", "親子丼", "焼肉定食", "ざるそば", "パスタ", "ピザ"],
    "category": ["和食", "和食", "和食", "和食", "洋食", "洋食"],
    "price": [900, 1000, 1500, 1100, 1400, 1800],
    "qty": [42, 35, 21, 18, 27, 30],
})
print(df)
#      name category  price  qty
# 0  味噌ラーメン     和食    900   42
# 1    親子丼     和食   1000   35
# 2   焼肉定食     和食   1500   21
# 3   ざるそば     和食   1100   18
# 4    パスタ     洋食   1400   27
# 5     ピザ     洋食   1800   30

列選択: 角かっこ1つと2つの違い #

いちばん基本になるのは列選択です。ただし、角かっこを 1 つ使うか 2 つ使うかで、結果の型が変わります。

Series vs DataFrame
df["price"]            # Series (1次元)
df[["name", "price"]]  # DataFrame (2次元の表)

df["price"]Series を返します。1 次元の配列にインデックスが付いた形で、.mean() のような集計をそのまま掛けられます。一方 df[["price"]] は、列が 1 本だけでも DataFrame なので表の形が保たれます。この違いを知らないと、同じ列を選んだのにあるコードは動き、あるコードは動かないという状況に遭遇します。1 本の列の値を扱うなら角かっこ 1 つ、表の形を保つなら角かっこ 2 つです。

locとiloc: ラベルと位置 #

行を選ぶときは lociloc を使います。両者の区別は 1 行でまとまります。locラベル(インデックスの値)で、iloc位置(整数の順番)で選びます。たとえば df.loc[2]df.iloc[2] は、どちらも焼肉定食の行を返します。今はインデックスが 0 から順に振られているので同じに見えますが、フィルタリングを経るとラベルと位置がずれます。

フィルタ後は2つが異なります
sub = df[df["category"] == "洋食"]   # ラベル4、5の行だけが残る
sub.loc[4]    # ラベル4: パスタ
sub.iloc[0]   # 最初の行: パスタ
sub.loc[0]    # KeyError! ラベル0の行はありません

「何番目の行」が必要なら iloc、「インデックス値が何の行」が必要なら loc です。カンマを使うと行と列を同時に選べます。カンマの前が行、後ろが列です。

行と列の同時選択
df.loc[1:3, ["name", "price"]]   # ラベル1〜3の行のname、price列
df.iloc[1:3, 0:2]                # 位置1〜2の行の0〜1番目の列

ここに落とし穴が 1 つあります。loc のスライスは 末尾のラベルを含みiloc のスライスは Python のリストと同じように末尾の位置を含みません。そのため df.loc[1:3] は 3 行、df.iloc[1:3] は 2 行になります。

ブールインデックス: 条件式がマスクになります #

今回の核心です。pandas で「条件に合う行だけ」を表現するモデルは単純です。列に比較演算を掛けると True/False からなる Series が返り、これを マスク と呼びます。

条件式の結果はマスク
df["price"] >= 1200
# 0    False
# 1    False
# 2     True
# 3    False
# 4     True
# 5     True
# Name: price, dtype: bool

このマスクを角かっこに入れた df[df["price"] >= 1200] がそのままフィルタです。True の行、つまり焼肉定食・パスタ・ピザだけが残ります。条件を組み合わせるときは andornot ではなく &|~ を使い、各条件を必ずかっこで囲む 必要があります。

条件の組み合わせとかっこ
df[(df["category"] == "和食") & (df["price"] >= 1200)]  # かつ
df[(df["price"] <= 900) | (df["qty"] >= 30)]            # または
df[~(df["category"] == "洋食")]                         # 否定

かっこが必須なのは、演算子の優先順位のためです。&>= より先に評価されるので、かっこを外すと条件が変な形で結ばれて TypeError になります。and を使うと「The truth value of a Series is ambiguous」という ValueError になります。どちらもブールインデックスで最もよく出会うエラーなので、条件ごとにかっこを付ける習慣をつけるのが良いです。

条件を磨くツール: isin, between, str.contains #

よく使う条件パターンには専用メソッドがあります。

isin, between, str.contains
df[df["category"].isin(["和食", "中華"])]    # 値がリストの中にあるか
df[df["price"].between(1000, 1500)]          # 範囲の中にあるか (両端を含む)
df[df["name"].str.contains("ラーメン")]      # 文字列にパターンが含まれるか

isin は、同じ列の比較を | で並べるより短くて読みやすいです。between は両端を含む範囲条件で、str.contains は文字列の列専用で部分一致を検査します。3 つとも結果はマスクなので、&|~ の組み合わせにそのまま混ぜて使えます。

query: 可読性が欲しいとき #

条件が長くなると、かっこと df[...] の繰り返しで式が読みにくくなります。そんなとき query メソッドが代わりになります。条件を文字列で書き、andor をそのまま使えます。

queryメソッド
df.query("price >= 1200 and category == '洋食'")
min_price = 1000
df.query("price >= @min_price and qty > 20")   # 外部変数は@で参照

同じ条件をブールインデックスで書くと df[(df["price"] >= 1200) & (df["category"] == "洋食")] になりますが、query の方が明らかに目に入りやすいです。ただし条件が文字列なので IDE の自動補完や型検査が届かず、列名に空白があるとバッククォートで囲む必要があるという制約があります。基本はブールインデックスで固めて、条件が 3 つを超えるコードで query を可読性のツールとして取り出す、くらいがバランスの良い使い方です。

SettingWithCopyWarning: ビューとコピー #

選択とフィルタを使っていると、必ず一度はこの警告に出会います。

警告が出るコード
cheap = df[df["price"] < 1200]
cheap["price"] = 0
# SettingWithCopyWarning: A value is trying to be set on a copy of a slice...

原因は、df[...] で切り出した結果が元データの ビュー(同じデータを覗く窓)なのか コピー なのかを pandas が保証しない、という点にあります。ビューなら cheap への変更が df まで変えてしまい、コピーなら cheap だけが変わります。どちらになるかは状況によって変わるので、代入の結果が予測できません。警告は「この代入が元データに反映されるか分からない」というシグナルです。解決策は意図によって 2 つに分かれます。

意図別の解決策
# 元データを直すのが目的なら: 切り出してから代入せず、.locで一度に
df.loc[df["price"] < 1200, "price"] = 0

# 部分集合を独立したデータとして使うなら: 最初からcopy()を明示
cheap = df[df["price"] < 1200].copy()
cheap["price"] = 0   # 警告なし、dfはそのまま

ちなみに pandas 3.0 からは Copy-on-Write がデフォルトの動作になり、上のような連鎖代入は警告の代わりに「元データには一切反映されない」ことが確定します。曖昧さが消える方向ですが、「元データの修正は .loc で一度に、部分集合は copy() で」という習慣はどのバージョンでもそのまま有効です。

まとめ #

今回扱った内容です。

  • 列選択は角かっこ 1 つなら Series、2 つなら DataFrame
  • loc はラベル、iloc は位置で、フィルタ後は 2 つがずれます
  • 条件式はマスクになり、組み合わせは &|~ にかっこが必須
  • isinbetweenstr.contains でよく使う条件を短く表現
  • 条件が長くなったら query が可読性の代替手段
  • SettingWithCopyWarning はビューとコピーの曖昧さへのシグナルで、解決策は .loc での一括代入と copy()

次回(#4 変形と欠損値)では、選び出したデータを変える段階に進みます。新しい列の作成、apply とベクトル演算、そして実務データの半分を占める欠損値処理(dropnafillna)を扱います。

X