Pythonデータ分析 #5 グループと結合: groupby、pivot_table、merge
データ分析で受ける質問は、結局のところ似た形に収束します。「支店別の売上はいくらか」「月別にどう変化したか」「支店別・月別にクロスで見るとどうか」といった質問です。1 行ずつではなく まとまり単位の要約 を尋ねる質問であり、pandas でこれに答える道具が groupby と pivot_table です。答えに必要な情報が 2 つの表に分かれているなら、まず merge で結合する必要があります。#4 変形と欠損値 でデータをきれいにしたので、今回はそのデータをまとめ、結合して質問に答える段階を扱います。
- #1 スタート
- #2 読み込みと探索
- #3 選択とフィルタ
- #4 変形と欠損値
- #5 グループと結合 ← 今回
- #6 可視化
- #7 polars入門(まとめ)
サンプルデータ: 支店別・月別の売上 #
今回の記事全体で使う小さな売上表を作ります。単位は百万円で、名古屋支店は 3 月のデータがまだありません。
import pandas as pd
sales = pd.DataFrame({
"支店": ["東京", "東京", "東京", "大阪", "大阪", "大阪", "名古屋", "名古屋"],
"月": ["1月", "2月", "3月", "1月", "2月", "3月", "1月", "2月"],
"売上": [120, 135, 150, 90, 95, 110, 70, 80],
})groupbyの思考モデル: 分割し、適用し、結合する #
groupby は内部的に 3 つの段階で動作します。キーの値が同じ行どうしを 分割し(split)、各まとまりに集計関数を 適用し(apply)、結果を 1 つの表に 結合します(combine)。コードは 1 行ですが、頭の中ではこの 3 段階を描いておくのがよいです。
sales.groupby("支店")["売上"].sum()
# 支店
# 名古屋 150
# 大阪 295
# 東京 405"支店" の値が同じ行どうしをまとめ、各まとまりの 売上 列に sum を適用した結果です。キーをリストで渡すと複数キーのグループになり、結果のインデックスは支店と月の 2 段(MultiIndex)になります。この形が扱いにくければ、後ろに .reset_index() を付けて普通の列に戻せます。
sales.groupby(["支店", "月"])["売上"].sum()
# 支店 月
# 名古屋 1月 70
# 2月 80
# 大阪 1月 90
# ...
# 東京 3月 150agg: 複数の統計量を一度に #
合計だけでなく平均と件数も一緒に見たければ、agg に関数名をリストで渡します。
sales.groupby("支店")["売上"].agg(["sum", "mean", "count"])
# sum mean count
# 支店
# 名古屋 150 75.000000 2
# 大阪 295 98.333333 3
# 東京 405 135.000000 3結果の列名を自分で決めたければ、sales.groupby("支店").agg(売上合計=("売上", "sum"), 平均売上=("売上", "mean")) のように 名前=("列", "関数") の形を使います。レポートにそのまま入る列名が得られるため、実務ではこちらの方をよく使うことになります。
pivot_table: Excelのピボットテーブルと同じもの #
Excel でピボットテーブルを使ったことがあるなら、pivot_table はまさにあの機能です。行軸と列軸を指定してクロス表を作ります。
sales.pivot_table(index="支店", columns="月", values="売上", aggfunc="sum")
# 月 1月 2月 3月
# 支店
# 名古屋 70.0 80.0 NaN
# 大阪 90.0 95.0 110.0
# 東京 120.0 135.0 150.0中身は複数キーの groupby の結果と同じで、違いは形です。groupby は縦に長い結果を返し、pivot_table は 2 つ目のキーを列方向に展開して、人が見やすい格子を作ります。名古屋支店の 3 月のマスが NaN で空いていることも一目で分かります。空欄を 0 で埋めたければ fill_value=0 引数を追加すれば済みます。要約すると、計算は groupby、人に見せるクロス表は pivot_table と使い分ければよいです。
merge: 2つの表を横に結合する #
支店長の名前が入った表が別にあり、売上表に支店長の列を付けたい状況を想定します。
managers = pd.DataFrame({
"支店": ["東京", "大阪", "福岡"],
"支店長": ["佐藤", "鈴木", "高橋"],
})
pd.merge(sales, managers, on="支店")
# 支店 月 売上 支店長
# 0 東京 1月 120 佐藤
# 1 東京 2月 135 佐藤
# ...
# 5 大阪 3月 110 鈴木merge は SQL の JOIN と同じ概念で、デフォルトの動作は inner join です。両方の表に存在するキーだけを残すため、売上表にしかない名古屋と、支店長表にしかない福岡が結果から静かに消えました。8 行が 6 行になったわけです。左側の表の行をすべて保存するには how="left" を使います。pd.merge(sales, managers, on="支店", how="left") に変えると名古屋の 2 行が生き残り、支店長の列は NaN になります。売上データが基準で支店長が付加情報なら、left が正しい選択です。
キーが重複すると行が増えます。 merge の本当の罠は、行が消える側よりも 増える側 です。支店長表に東京が 2 回入っていると、東京の売上行 1 つごとに支店長の行 2 つがそれぞれ組み合わされ、結果が膨れ上がります。8 行の売上表が 11 行になる、という具合です。こうして結合した表で集計すると、東京の売上が 2 倍に計上されます。この罠をコードで防ぐ道具が validate 引数です。
pd.merge(sales, managers, on="支店", how="left", validate="many_to_one")
# MergeError: Merge keys are not unique in right dataset; not a many-to-one merge「売上の複数行が支店長の 1 行に対応する」という期待を宣言しておけば、期待が崩れた瞬間に pandas がエラーで知らせてくれます。間違った数字がレポートまで流れていくより、エラーの方がずっとましです。
concat: 同じ形式を縦に積む #
merge が列を横に付ける道具だとすれば、concat は同じ形式の表を縦に積む道具です。月ごとにファイルが別々になっている、よくある状況で使います。
from pathlib import Path
frames = [pd.read_csv(p) for p in sorted(Path("data").glob("sales_*.csv"))]
total = pd.concat(frames, ignore_index=True)ignore_index=True は、各ファイルが持っていた 0, 1, 2 のインデックスを捨てて振り直すオプションです。フォルダのファイルを glob で集めて処理するパターンは、Python自動化 #2 Excel自動化 で扱った取りまとめの流れと同じです。あの記事では openpyxl でセルを直接移しましたが、集計が目的ならこのように pandas で積む方がコードがはるかに短くなります。
結合後はまず行数を確認 #
merge と concat を使った直後は、行数を確認する習慣を付けるのがよいです。結合は静かに間違う演算だからです。行が消えても、2 倍に膨れても、エラーなしで結果が返ってきます。assert 1 行で十分です。left join 後に行数が元と違えばキーの重複を、concat 後に合計が合わなければ抜けたファイルを疑えばよいです。
assert len(merged) == len(sales) # left join: 左側の行数が保存されるはずです
assert len(total) == sum(len(f) for f in frames) # concat: 積む前の合計と一致するはずですまとめ #
今回扱った内容です。
groupbyは分割し、適用し、結合する 3 段階で動作し、aggで複数の統計量を一度に求めますpivot_tableは Excel のピボットテーブルと同じクロス表を作り、計算自体はgroupbyと同じですmergeは SQL の JOIN に対応し、inner は行を減らし、キーの重複は行を増やします。validateでキーの関係を宣言しておくと安全ですconcatは同じ形式の表を縦に積み、結合直後はassertで行数を確認します
次回(#6 可視化)では、今回作った集計結果を図に移します。pandas の plot メソッドと matplotlib で棒グラフと折れ線グラフを描き、日本語フォントの文字化けを解決する方法まで扱います。