Pythonデータ分析 #5 グループと結合: groupby、pivot_table、merge

読了 7分

データ分析で受ける質問は、結局のところ似た形に収束します。「支店別の売上はいくらか」「月別にどう変化したか」「支店別・月別にクロスで見るとどうか」といった質問です。1 行ずつではなく まとまり単位の要約 を尋ねる質問であり、pandas でこれに答える道具が groupbypivot_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 段階を描いておくのがよいです。

単一キーのgroupby
sales.groupby("支店")["売上"].sum()
# 支店
# 名古屋    150
# 大阪      295
# 東京      405

"支店" の値が同じ行どうしをまとめ、各まとまりの 売上 列に sum を適用した結果です。キーをリストで渡すと複数キーのグループになり、結果のインデックスは支店と月の 2 段(MultiIndex)になります。この形が扱いにくければ、後ろに .reset_index() を付けて普通の列に戻せます。

複数キーのgroupby
sales.groupby(["支店", "月"])["売上"].sum()
# 支店   月
# 名古屋  1月     70
#        2月     80
# 大阪    1月     90
# ...
# 東京    3月    150

agg: 複数の統計量を一度に #

合計だけでなく平均と件数も一緒に見たければ、agg に関数名をリストで渡します。

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 はまさにあの機能です。行軸と列軸を指定してクロス表を作ります。

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つの表を横に結合する #

支店長の名前が入った表が別にあり、売上表に支店長の列を付けたい状況を想定します。

mergeの基本(inner join)
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 引数です。

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 で積む方がコードがはるかに短くなります。

結合後はまず行数を確認 #

mergeconcat を使った直後は、行数を確認する習慣を付けるのがよいです。結合は静かに間違う演算だからです。行が消えても、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 で棒グラフと折れ線グラフを描き、日本語フォントの文字化けを解決する方法まで扱います。

X