グループ化と集計 — グループごとに1つの答えを出す
「支店ごと」「カテゴリごと」「日ごと」といった問いを1行のgroupbyで解決する方法:キー・集計値・集計操作の3要素、名前付き集計(named agg)による複数値の一括算出、sizeとcountの違い、2つのキーでのグループ化、そして「平均の平均」という罠について学びます。
どうやって学びますか?
解決する問題
店のオーナーから1行のメッセージが届きました。「今週は各支店でそれぞれ何個売れたかな?」
手元にはorders.csvがあります——8件の注文がそれぞれ1行ずつ入っています。これまでの4つの章で学んだことを使えば、すでに答えることができます。1つの支店をフィルタリングし、その列を合計し、それを繰り返すという方法です。
import pandas as pd
orders = pd.read_csv("orders.csv")
for branch in ["north", "south", "east"]:
units = orders[orders["branch"] == branch]["quantity"].sum()
print(branch, units)north 48
south 6
east 27数値は合っています。しかし、この手法には3つの問題があり、データ量が増えるにつれてその問題は深刻になります。
第1に、事前に支店を知っておく必要があったという点です。支店のリストを自分で手入力しました。来週のファイルに新しいwest支店からの注文が含まれていた場合、このループはその支店を認識できません——エラーで失敗するわけではなく、単にwestを除外してしまうため、合計値が全体の数値と一致しなくなります。
第2に、新しい問いごとに新しいループが必要になるという点です。「カテゴリごとの売上」を求めるには、手入力した別のリストに対する2つ目のループが必要になります。「支店およびカテゴリごとの販売数量」となると、二重ループ(ループの中のループ)になってしまいます。
第3に、結果が表(テーブル)になっていないという点です。出力されるのは単なるテキストの行です。表として再構築しない限り、ソートしたり、フィルタリングしたり、別の集計結果の隣に並べたりすることができません。
本来やりたいのは、1行で次のように指示することです。行を支店ごとに分割し、それぞれの塊の中で数量を合計し、その結果を表として返してほしい。 これを実現するのがgroupbyであり、pandasにおけるほぼすべての集計処理——レポート作成、ダッシュボードの数値算出、「売上の内訳はどこから来ているのか?」という問いへの最初の回答——はこれを使って書かれます。
この章を終える頃には
- 「支店ごとの販売数量」のような問いを、キー、値の列、集計操作という3つの要素に分解し、グループ化する前にキーを確認できるようになる
df.groupby(key)[column].sum()を書き、キーがインデックスとなった結果のSeriesを読み取れるようになる.agg()や名前付き集計(named aggregation)を使って、複数の集計値を一度に算出できるようになるsize()とcount()の違いを理解し、欠損したキーや欠損値がどのように扱われるかを判断できるようになる- 2つの列でグループ化し、その結果を平坦化(flatten)したり変形(reshape)したりできるようになり、「平均の平均」という誤りを回避できるようになる
前提条件: 行のソート。
使用するファイル
これまでの章と同じorders.csv——小さな店の8件の注文データです。まだ手元にない場合は、プロジェクトフォルダ内にまったく同じ内容でこのファイルを作成してください。
order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.01行が1件の注文を表します。どの支店からの注文か、どの商品か、そのカテゴリ、何個売れたか(数量)、そして1個あたりの単価が含まれています。
コードを書く前に
グループ集計に関する問いには、すべて共通の骨格があります。groupbyと入力する前に、まず問いを分解してみましょう。
1. 問いを3つの要素に分解して言語化する
グループ集計の問いには常に3つの要素が含まれており、問いの言葉遣いを見ればどれがどれであるかが分かります。
- キー(Key) —— グループを定義する値を持つ列。「〜ごと」「〜別」「各〜について」の後に続く言葉です。「支店ごとの数量」であれば
branchがこれにあたります。 - 値(Value) —— 集計される列。測定の対象となるものです。ここでは
quantityです。 - 集計操作(Aggregation) —— 複数の値をどのように1つにまとめるか。「合計」「平均」「件数」「最大」などです。ここでは
sumです。
パターンを定着させるために、いくつか例を見てみましょう。
- 「カテゴリごとの売上」—— キー:
category、値:revenue、集計操作:sum - 「各支店における1注文あたりの平均数量」—— キー:
branch、値:quantity、集計操作:mean - 「日ごとの注文数」—— キー:
date、値の列:なし(行数をカウント)、集計操作:size - 「商品ごとの1回の最大注文数量」—— キー:
product、値:quantity、集計操作:max
これら3つの要素をすべて挙げられない場合、その問いはまだコードを書ける段階ではありません。「どの支店が一番優れているか?」には、値も集計操作もありません——数量で一番なのか?売上で一番なのか?それとも注文数で一番なのか?計算する前に確認してください。3つの基準それぞれで異なる支店が答えになることもあります。
2. グループ化する前に入力データを確認する
グループ化を行うと個々の行が見えなくなります。8件の注文が3つの合計値にまとまってしまうと、合計値の中に紛れ込んだ異常な行には気付けなくなります。そのため、確認は事前に行います。
import pandas as pd
orders = pd.read_csv("orders.csv")
print(orders.shape)
print(orders.dtypes)(8, 7)
order_id int64
date str
branch str
product str
category str
quantity int64
price float64
dtype: objectここで重要なのは2点です。値の列であるquantityはint64——数値型なので、sumはテキストを結合するのではなく数値を加算します。キーの列であるbranchはstrであり、キーとして問題ありません。
次に、キーの実際の値を確認します。これは多くの人が飛ばしがちですが、最も重要なチェックです。
print(orders["branch"].value_counts())
print(orders[["branch", "quantity"]].isna().sum())branch
north 4
south 2
east 2
Name: count, dtype: int64
branch 0
quantity 0
dtype: int64一意な支店は3つあり、どちらの列にも欠損値はありません。groupbyは一意な値ごとに、文字単位で完全に一致するものごとに1つのグループを作成します——そのため、"north"と"north "は2つの異なるグループになってしまいます。誤ったレポートになってしまう前にそれに気付けるのが、value_counts()です。
このチェックを怠るとどうなるかを見てみましょう。いずれもnorth支店を意味する3つの行があります——1つは正しく入力され、1つは末尾に空白があり、1つは先頭が大文字になっています。
messy = pd.DataFrame({"branch": ["north", "north ", "North"], "quantity": [12, 2, 30]})
print(messy.groupby("branch")["quantity"].sum())
messy["branch"] = messy["branch"].str.strip().str.lower()
print(messy.groupby("branch")["quantity"].sum())branch
North 30
north 12
north 2
Name: quantity, dtype: int64
branch
north 44
Name: quantity, dtype: int64エラーは出ず、本来1つであるべきところに3つの「支店」ができてしまいました。個々のグループの数値はpandasに渡されたデータに対して正しく計算されていますが、レポートとしては誤りです。グループ化する前にキーをクレンジング(前後の空白を除去し、小文字に統一)することで、それらが1つのグループにまとまり、本来の合計値である44が得られます。
3. 出力結果の見取り図を描く
結果がどのような形になるべきかをあらかじめ決めておけば、期待と異なる結果になったときに気付くことができます。
branch units
east ?
north ?
south ?先ほどのチェックから、出力に関してすでに3つの事実が分かっています。ちょうど3行になること(一意な支店ごとに1行)、支店はアルファベット順に出力されること(groupbyはキーをソートします)、そして3つの数値を足すと列全体の合計値と一致する必要があることです。最後の点は、後から結果を検証するための証拠(検算)になります。
4. 適切な手法を選択する
- 1つの列からグループごとに1つの数値を求める:
df.groupby(key)[col].sum()(またはmean、maxなど) - 1つの列から複数の数値を求める:
df.groupby(key)[col].agg(["sum", "mean"]) - 複数の列から独自の列名をつけて複数の数値を求める:
df.groupby(key).agg(name=(col, "sum"), ...) - 各グループの行数を求める:
df.groupby(key).size() - 売上(revenue)のようにまだ存在しない列:先に列を作成してからグループ化する
それではコードを見ていきましょう。
分割、適用、結合
groupbyは順番に3つの処理を行います。グループ集計の問いはすべてこの3つのステップで構成されているため、それぞれの名前を知っておく価値があります。
- 分割(Split):行をキーの一意な値ごとに細かく分ける
- 適用(Apply):それぞれの塊に対して個別に計算を適用する
- 結合(Combine):結果を、キーをインデックスとする1つの新しいオブジェクトに結合する
分割のステップだけを単独で確認することもできます。groupbyオブジェクトはループで反復処理することができ、各キーとそれに対応する表の塊を取り出すことができます。
for branch, part in orders.groupby("branch"):
print(branch, part.shape, list(part["order_id"]))east (2, 7) [1004, 1007]
north (4, 7) [1001, 1003, 1005, 1008]
south (2, 7) [1002, 1006]それぞれの塊は、全7列を持つ通常のDataFrameです——northの塊には4行があり、他の2つには2行あります。支店名を手作業で入力する必要は誰にもありませんでした。pandasがデータの中から見つけ出したのです。来週新しいwest支店からの注文が入ったとしても、単に4つ目の塊ができるだけです。
実際のコードでこのようにループ処理を行うことは滅多にありません。ここでお見せしたのはイメージを明確にするためです。以降で説明する内容はすべて「それぞれの塊に対して何らかの処理を行い、その答えを結合する」という操作になります。
グループごとに1つの数値
3つの要素で整理した「支店ごとの数量」という問い——キー:branch、値:quantity、集計操作:sum——は、まったく同じ順序で記述します。
units = orders.groupby("branch")["quantity"].sum()
print(units)branch
east 27
north 48
south 6
Name: quantity, dtype: int64左から右へと読んでみてください。branchでグループ化し、quantityを取り出し、合計する。 ループのときと同じ数値が、手入力の支店リストなしで、1行で得られました。
ここで見取り図と突き合わせて確認してみましょう。3行になっているか——なっています。アルファベット順になっているか——なっています。そして検算です。
print(units.sum(), orders["quantity"].sum())81 81グループの合計が全体の合計と一致しているため、失われた行も二重にカウントされた行もありません。この1行のチェックは習慣にする価値があります。グループ化を行ったときは必ず、結果の合計と入力データの合計を比較してください。
結果はSeriesになり、キーがそのインデックスになる
print(type(units))
print(units.index)
print(units["north"])<class 'pandas.Series'>
Index(['east', 'north', 'south'], dtype='str', name='branch')
48結合のステップでは、支店は列ではなくインデックスに配置されました。出力において数値の上にbranchという行が独立して表示されているのはそのためです——これはインデックスの名前です。また、第4章の.locとまったく同様に、units["north"]のように支店名で値を検索できることも意味します。
これは通常のSeriesであるため、これまでの章で学んだすべての操作がそのまま適用できます。ソートすれば「どの支店が最も多く売上げたか」に答えることができます。
print(units.sort_values(ascending=False))
print(units.idxmax())branch
north 48
east 27
south 6
Name: quantity, dtype: int64
northidxmax()は最大値を持つラベル——数値ではなく支店名——を返します。「どこがトップか」を知りたい場合、通常必要になるのはこちらです。
なぜ最初に列を選ぶのか
["quantity"]を省略して、pandasにすべてを合計させたくなるかもしれません。
print(orders.groupby("branch").sum())order_id date ... quantity price
branch ...
east 2011 2024-03-022024-03-04 ... 27 135.0
north 4017 2024-03-012024-03-022024-03-032024-03-04 ... 48 993.0
south 2008 2024-03-012024-03-03 ... 6 910.0
[3 rows x 6 columns]エラーは発生しません——しかし、含まれる数値のほぼすべてが無意味です。order_idが加算され、注文番号があたかも金額であるかのように扱われてしまいました。テキストの「合計」は連結を意味するため、dateの文字列は端から端までつなぎ合わされました。priceも合計されています。northの993.0は4つの異なる商品の単価を単純に足し合わせたものであり、何の指標にもなりません。
orders.groupby("branch").sum()はエラーを発生させません。ID番号を合算し、日付をつなぎ合わせ、単価を合計して、一見するとまともなレポートのような見た目の結果を返してきます。意味があるのはquantityだけであり、それだけを指定すればよかったのです。これが、集計操作の前に値の列を選ぶというルールになっている理由です。sumは各データ型における加算の定義に従って処理を行いますが、大半の列にとってそれは誰も求めていない計算です。
meanはより厳格であり、無意味な結果を出力する代わりに処理を拒否します。
print(orders.groupby("branch").mean())TypeError: dtype 'str' does not support operation 'mean'このエラーは、同じ過ちが表面化したものです。テキスト列の平均を計算するように指示されたと解釈してください。修正方法はnumeric_only=Trueを指定することではありません——それでは依然としてorder_idやpriceの平均が計算されてしまいます——どの列を対象にしているのかを明示することです。
複数の数値を一度に集計する:.agg()
1つの集計値だけで十分なケースは滅多にありません。「各支店はどれだけの数量を販売し、1注文あたりの平均はいくらで、注文数は何件だったか?」は、同じ列に対する3つの集計操作です。それらの名前のリストを.agg()に渡します。
print(orders.groupby("branch")["quantity"].agg(["sum", "mean", "count"]))sum mean count
branch
east 27 13.5 2
north 48 12.0 4
south 6 3.0 2今度は結果がDataFrameになりました——集計操作ごとに1つの列があり、支店ごとに1つの行があります。これは合計値だけでは見えなかった事実を教えてくれます。eastの販売数量はnorthの半分強にすぎませんが、1注文あたりの平均は大きくなっています。north支店は注文数が多く、east支店は注文数が少ないものの、1回あたりの注文規模がやや大きいのです。
リスト内の名前は文字列です。"sum"、"mean"、"count"、"min"、"max"、"median"、"nunique"(一意な値の個数)、"first"、"last"などがあります。これらは単一の列に対して使ってきたものと同じメソッドであり、それぞれの塊に対して適用されます。
名前付き集計(Named aggregation):独自の列名をつける
実際の業務レポートでは、異なる列を異なる方法で集計し、わかりやすい列名を付けたいことがよくあります。名前付き集計(Named aggregation)を使えば、1回の呼び出しでその両方を行うことができます。各キーワードが出力列の名前になり、その値として(input column, aggregation)のペアを指定します。
売上(revenue)はレポートに含めるべき代表的な項目ですが、まだ列としては存在していません——各行の数量と単価を掛け合わせたものです。列の作成については次の章で詳しく扱いますが、現時点では1行書くだけで十分です。これは計画ステップ4の実践でもあります。値の列が存在しない場合は、グループ化する前に作成しておく。
orders["revenue"] = orders["quantity"] * orders["price"]
summary = orders.groupby("branch").agg(
orders=("order_id", "count"),
units=("quantity", "sum"),
revenue=("revenue", "sum"),
)
print(summary)orders units revenue
branch
east 2 27 1140.0
north 4 48 2600.0
south 2 6 1150.0これならそのまま提出できるレポートになります。aggの呼び出しの各行は、文章のように読むことができます。ordersはorder_idの件数、unitsはquantityの合計、revenueはrevenueの合計。 出力列の名前はsumやcountのように自動で付けられるのではなく、自分で選んだものになるため、後から名前を変更する必要もありません。
そして、これは先ほどの回答を一変させます。数量ベースではeastが明確に2位でしたが、売上ベースではsouthが僅差で上回ります——850のバッグ1個が、ペン20本の売上を上回るからです。ステップ1で値を明確にするよう強調したのはこのためです。数量ベースの「最高の支店」と、売上ベースの「最高の支店」は異なる問いなのです。
結果はDataFrameなので、通常通りソートできます。
print(summary.sort_values("revenue", ascending=False))orders units revenue
branch
north 4 48 2600.0
south 2 6 1150.0
east 2 27 1140.0件数のカウント:size()とcount()の違い
「支店ごとの注文数は何件か」というのは1つの問いのように聞こえます。しかしpandasには2つの答え方があり、データに欠損がある場合にはその結果が明確に分かれます。
size()は、各グループの中身に関わらず行数を数えます。count()は、各グループにおける特定の列の欠損していない値の個数を数えます。
データに欠損がない綺麗なファイルでは両者は一致します。違いを確認するために、ordersとは別に、欠損した支店が1件、欠損した数量が1件含まれる小さな表を手動で作成してみましょう。
gaps = pd.DataFrame(
{
"order_id": [1001, 1002, 1003, 1004, 1005],
"branch": ["north", "south", None, "east", "north"],
"quantity": [12, 5, 2, None, 30],
}
)
print(gaps)order_id branch quantity
0 1001 north 12.0
1 1002 south 5.0
2 1003 NaN 2.0
3 1004 east NaN
4 1005 north 30.0(欠損値が含まれているため、quantityはfloat型に変換されました——その理由は第6章で説明しました。)
print(gaps.groupby("branch").size())
print(gaps.groupby("branch")["quantity"].count())branch
east 1
north 2
south 1
dtype: int64
branch
east 0
north 2
south 1
Name: quantity, dtype: int64eastには1件の注文がありますが(size = 1)、判明している数量は0件です(count = 0)。どちらも事実であり、異なる問いに答えています。「注文が何件あるか」を知りたい場合はsize()を使い、「実際に数値が分かっている注文は何件あるか」を知りたい場合はcount()を使います。
欠損データが暗黙のうちに行う2つのこと
先ほどの出力をもう一度見てみてください。支店が空欄だった注文1003は、どちらの出力にも含まれていません。5件の注文を入力したのに、出力されたのは4件でした。
print(gaps.groupby("branch").size().sum(), len(gaps))4 5デフォルトでは、groupbyはキーが欠損している行を除外します。「不明」という名前のグループは作成されないため、その行はすべての集計結果から消滅してしまいます。先ほどの合計チェックを行えば、この現象にすぐ気付くことができます。もしそうした行も無視できない場合——支店が不明であっても注文であることには変わりありません——dropna=Falseを指定して結果に含めるよう要求します。
print(gaps.groupby("branch", dropna=False)["quantity"].sum())branch
east 0.0
north 42.0
south 5.0
NaN 2.0
Name: quantity, dtype: float64これで4つのグループがすべて揃い、どの支店か特定できなかった2個の数量がNaNグループにまとめられました。
2つ目の暗黙の挙動も、同じ出力の中に現れています。eastの値が0.0になっている点です。east支店の売上が0だったわけではありません。唯一の数量が不明だっただけです。sumは「足し合わせる値が存在しない」状態を0として処理します。もし「不明」なものは「不明」のままにしておきたいのであれば、min_count=1(少なくとも1つの有効な値が存在する場合にのみ合計を算出する)を指定します。
print(gaps.groupby("branch", dropna=False)["quantity"].sum(min_count=1))branch
east NaN
north 42.0
south 5.0
NaN 2.0
Name: quantity, dtype: float64どちらが正しいかは問いの内容によって異なり、まさにそこが重要な点です。偶然任せにするのではなく、計画の段階で判断してください。いずれにせよ、両方の問題に対する根本的な解決策は通常もっと前にあります——グループ化する前に欠損値をクレンジング(第6章)しておくことです。
2つのキー:グループの中のグループ
「支店およびカテゴリごとの売上」には2つのキーがあります。リストを渡します。
by_both = orders.groupby(["branch", "category"])["revenue"].sum()
print(by_both)branch category
east accessories 840.0
stationery 300.0
north accessories 2180.0
stationery 420.0
south accessories 850.0
stationery 300.0
Name: revenue, dtype: float64データ内に実際に存在する組み合わせごとに1行が作成されます——ここでは6行です。インデックスはbranchとcategoryの2つの階層を持つようになりました。pandasではこれをMultiIndexと呼び、見やすさを保つために外側の階層はブロックごとに1回だけ出力されます。eastの下の空白は「ここもeastである」ことを意味します。
直感的に期待される方法で値を選択できます。
print(by_both.loc["north"])
print(by_both.loc[("north", "stationery")])category
accessories 2180.0
stationery 420.0
Name: revenue, dtype: float64
420.01つのラベルを指定するとnorthのすべてが取得でき、両方のラベルのタプルを指定すると1つの数値が取得できます。
通常の列に戻す
MultiIndexはコンパクトですが、その後の多くの処理——CSVへの保存、マスクを使ったフィルタリング、後の章で学ぶ他の表との結合など——においては、通常の列になっている方が扱いやすいです。これには2つの方法があり、どちらも同じ表が得られます。
flat = orders.groupby(["branch", "category"], as_index=False)["revenue"].sum()
print(flat)
print(flat.equals(by_both.reset_index()))branch category revenue
0 east accessories 840.0
1 east stationery 300.0
2 north accessories 2180.0
3 north stationery 420.0
4 south accessories 850.0
5 south stationery 300.0
Trueas_index=Falseは、そもそもキーをインデックスに移動させないようgroupbyに指示します。一方、reset_index()は後からインデックスを列に戻します。コードの文脈に合わせて、読みやすい方を使ってください。
人間が見やすい形に変形する:unstack()
人間が結果を読む場合、縦に長いリストよりも表形式(グリッド)の方が把握しやすいことがあります——縦軸に支店、横軸にカテゴリを並べる形です。
print(by_both.unstack())category accessories stationery
branch
east 840.0 300.0
north 2180.0 420.0
south 850.0 300.0unstack()は内側のインデックス階層であるcategoryを取り出し、その値を列へと変換します。数値は先ほどの6つとまったく同じです。もしあるカテゴリを一度も販売していない支店があった場合、そのセルはNaNになりますが、unstack(fill_value=0)を使えば代わりに0を埋めることができます。
「平均の平均」という罠
つい考えてしまいがちな問いがあります。各支店において、商品1個あたりの平均価格はいくらだろうか? 手っ取り早い答えは、price列の平均を計算することです。
print(orders.groupby("branch")["price"].mean())branch
east 67.50
north 248.25
south 455.00
Name: price, dtype: float64これは注文明細行に記載された価格の平均であり、各行が1回ずつカウントされています——south支店のバッグ1個(単価850が1個)が、ノート(単価60が5個)とまったく同じ重みで扱われています。しかし、顧客は各明細行を1つずつ買ったわけではありません。個々の商品(ユニット)を買ったのです。販売された商品1個あたりの平均価格は、合計売上を合計数量で割ったものであり、合計値から計算しなければなりません。
per_unit = orders.groupby("branch").agg(
units=("quantity", "sum"),
revenue=("revenue", "sum"),
)
per_unit["avg_unit_price"] = (per_unit["revenue"] / per_unit["units"]).round(2)
print(per_unit)units revenue avg_unit_price
branch
east 27 1140.0 42.22
north 48 2600.0 54.17
south 6 1150.0 191.67southの数値は455から約192へと下がりました。算数としてどちらかの計算が「間違っている」わけではありませんが、問いに答えているのは一方だけです。ルールは次の通りです。求めたいものが比率である場合は、分子と分母を個別に集計してから割り算を行う。 すでに平均値であるものや、すでに1個あたりの単価であるものを平均すると、それが1個の注文であれ30個の注文であれ、すべての行が均等に重み付けされてしまいます。
完全な例
branch_report.pyは、オーナーの問いに正しく答えます——支店ごとに、注文数、販売数量、売上、全社売上に占める割合、そして数量ベースで最も売れた商品を算出し、合計値の検算を行います。
import pandas as pd
orders = pd.read_csv("orders.csv")
# 1. Check the input before grouping hides anything.
print("Rows, columns:", orders.shape)
print("Branches:", sorted(orders["branch"].unique()))
print("Missing in key/value:", int(orders[["branch", "quantity", "price"]].isna().sum().sum()))
print()
# 2. Create the value column that does not exist yet.
orders["revenue"] = orders["quantity"] * orders["price"]
# 3. One grouped call, one row per branch.
report = orders.groupby("branch").agg(
orders=("order_id", "count"),
units=("quantity", "sum"),
revenue=("revenue", "sum"),
)
report["share_pct"] = (report["revenue"] / report["revenue"].sum() * 100).round(1)
# 4. Best product per branch: group by both keys, then keep each branch's top row.
by_product = orders.groupby(["branch", "product"], as_index=False)["quantity"].sum()
top = by_product.sort_values("quantity", ascending=False).drop_duplicates("branch")
report["top_product"] = top.set_index("branch")["product"]
report = report.sort_values("revenue", ascending=False)
print(report)
print()
# 5. Prove nothing was lost or double-counted.
print("Revenue check:", report["revenue"].sum(), "==", orders["revenue"].sum())
print("Orders check :", report["orders"].sum(), "==", len(orders))Rows, columns: (8, 7)
Branches: ['east', 'north', 'south']
Missing in key/value: 0
orders units revenue share_pct top_product
branch
north 4 48 2600.0 53.2 eraser
south 2 6 1150.0 23.5 notebook
east 2 27 1140.0 23.3 pen
Revenue check: 4890.0 == 4890.0
Orders check : 8 == 8なぜこのように書かれているのか:
- チェックを先頭で行い、結果を出力している。 一意な支店が3つあり、欠損値が0件であることこそが、このレポートが信頼できる前提条件です。来週のファイルに4つの支店が含まれていたり2件の欠損があったりした場合、いずれかの合計値を読む前に、一番上の出力でそれに気付くことができます。
- グループ化する前に
revenueを作成している。 行ごとにquantity * priceを計算してからグループ化して合計することで、正確な売上が得られます。後から合計数量に平均価格を掛けるのは、再び「平均の平均」という罠に陥ることになります。 - 1回の
agg呼び出しで、列名を指定している。 各出力列は1回だけ宣言され、レポートの読み手が見る名前が付けられます。4つ目の集計指標を追加するのも、コードを1行追加するだけです。 share_pctは入力データではなく、集計結果に対して計算している。 グループ化した後、reportは支店をインデックスとする通常のDataFrameになるため、通常の列に対する算術演算がそのまま機能します。割合の合計は100.0になります。- トップ商品の算出には、2つのキーでのグループ化とその後のソートを用いている。
groupby(["branch", "product"])によって支店と商品のペアごとの数量が得られます。quantityの降順でソートし、支店ごとに最初の行だけを残す(drop_duplicates("branch"))ことで、各支店のベストセラーが残ります。top.set_index("branch")["product"]を代入することで、位置ではなく支店ラベルに基づいて商品が揃えられます——インデックスが自動的に突き合わせを行ってくれます。 - 最後の2行が検算である。 各グループの売上と注文数の合計は、入力データの合計と一致しています。もし支店が抜け落ちていたり、ファイル内に空欄の支店があったりした場合、数値が一致しなくなるため、レポートを提出する前に気付くことができます。
問題が起きたとき
KeyError: 'Branch' キーの列名がファイル内の表記と異なっています——ここでは先頭が大文字のBになっています。列名は大文字と小文字を区別し、groupbyは即座にそれをチェックします。list(orders.columns)を出力して名前を正確にコピーしてください。リスト形式で出力すれば、'branch 'のような末尾の空白にも気付くことができます。
KeyError: 'Column not found: Quantity' 1段階進んだ後の同じミスです。キーには問題ありませんでしたが、groupby("branch")["Quantity"]で指定した値の列が存在しません。単に名前が表示されるだけでなく「Column not found」というメッセージが出ることから、失敗したのはgroupbyの後の列選択であることが分かります。
KeyError: "Label(s) ['qty'] do not exist" 名前付き集計において、各ペアの1つ目の要素は実在する列でなければなりません。units=("qty", "sum")はqtyという列が存在しないためエラーになります。左側の出力名(units)は何でも構いませんが、右側の入力列名は自由には決められません。
ValueError: Cannot subset columns with a tuple with more than one element. Use a list instead. orders.groupby("branch")["quantity", "price"]のように記述した場合に発生します。1組の角括弧の中に2つの名前を入れるとタプルになります。複数の列を選択するには、第4章とまったく同様に二重の角括弧によるリストが必要です(orders.groupby("branch")[["quantity", "price"]].sum())。
TypeError: dtype 'str' does not support operation 'mean' テキスト列に対して集計操作が実行された場合に発生します——列が選択されていないか(groupby("branch").mean())、選択した列が文字列として読み込まれていたかのいずれかです。後者の場合、数値を期待していた箇所でinfo()がstrを表示します。第6章で学んだpd.to_numeric(..., errors="coerce")で修復してください。
結果を読み取る際のKeyError: 'quantity' units = orders.groupby("branch")["quantity"].sum()の後では、支店名がunitsのインデックスになっており、quantityは単なるその名前にすぎません。units["quantity"]はquantityという名前の支店を探すためエラーになります。特定の支店を取得するにはunits["north"]を使い、全体の合計を取得するにはunits.sum()を使います。
ステップ 4 / 6 — 予想する
理解度をチェック
何が出力されますか?
from io import StringIO
import pandas as pd
# Stands in for orders.csv, so this snippet runs on its own.
RAW = """order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0
"""
orders = pd.read_csv(StringIO(RAW))
print(orders.groupby("branch")["quantity"].sum())- A branch quantity 0 east 27 1 north 48 2 south 6
- B81
- Cbranch north 48 south 6 east 27 Name: quantity, dtype: int64
- Dbranch east 27 north 48 south 6 Name: quantity, dtype: int64
支店ごとの合計数量と合計価格を求めることが目的です。何が起きますか?
from io import StringIO
import pandas as pd
# Stands in for orders.csv, so this snippet runs on its own.
RAW = """order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0
"""
orders = pd.read_csv(StringIO(RAW))
by_branch = orders.groupby("branch")["quantity", "price"].sum()- A`ValueError: Cannot subset columns with a tuple with more than one element. Use a list instead.`
- B正常に動作し、2つの列を持つ表が返される
- C`KeyError: ('quantity', 'price')`
- Dquantity列のみが返される
groupbyの後のsize()とcount()の違いは何ですか?
- A違いはない。同じ処理に対する2つの名前にすぎない
- B`size()`は各グループが使用しているメモリ量を測定する
- C`size()`は欠損値を含めて各グループの行数を数え、`count()`は欠損していない値のみを数える
- D`count()`は行数を数え、`size()`は一意な値の個数を数える
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
オーナーは今度は、カテゴリ別および日別で同様の回答を求めています。orders.csvを使って、次の4つの処理を正確に行うcategory_report.pyを作成してください。
- ファイルを読み込み、
shape、ソートされた一意なカテゴリのリスト、およびcategory、quantity、priceを合わせた欠損値の個数を出力する revenue列を作成し、カテゴリごとに1行を持つ1つの表を作成する。売上の高い順にソートし、次の列を含める:orders(注文数)、units(合計数量)、revenue(合計売上)、avg_order_units(1注文あたりの平均数量)、avg_unit_price(売上を数量で割って小数第2位に四捨五入した値)- 日ごとの売上を出力し、続いて最も売上の高かった日を1日だけ出力する
- 検算行を出力する:カテゴリ別テーブルの売上合計、日別テーブルの売上合計、そしてファイル全体の売上合計
コードを書く前に、紙の上で計画を立ててください。条件2と3について、各列のキー、値、集計操作を挙げ、それぞれの結果が何行になるかを書き出してみましょう。
正しく書けていれば、プログラムは次とまったく同じ出力を返します。
Rows, columns: (8, 7)
Categories: ['accessories', 'stationery']
Missing: 0
orders units revenue avg_order_units avg_unit_price
category
accessories 4 14 3870.0 3.50 276.43
stationery 4 67 1020.0 16.75 15.22
date
2024-03-01 480.0
2024-03-02 2540.0
2024-03-03 1090.0
2024-03-04 780.0
Name: revenue, dtype: float64
Best day: 2024-03-02 with 2540.0
Proof: 4890.0 4890.0 4890.0その後、意図的にコードを壊してみて、その都度何が起こるかを確認してください。
- 注文1001のカテゴリを
"Stationery"(先頭が大文字のS)に変更してみてください。表は何行になりましたか?検算は依然として通りますか? unitsの"sum"を"mean"に置き換えてみてください。どの数値が変化し、それは何の問いに答えるものになりましたか?- 代わりに
priceの平均としてavg_unit_priceを計算してみてください。どちらのカテゴリの数値が最も大きく変動し、それはなぜでしょうか?
解答
まず計画を立てる。
- 条件2:キーは
category。orders=order_idの件数(count)、units=quantityの合計(sum)、revenue=revenueの合計(sum)、avg_order_units=quantityの平均(mean)、avg_unit_price= 1つの列の集計操作ではなく比率であるため、後からrevenueとunitsを使って計算します。ファイルには2つのカテゴリがあるため、表は2行になります。 - 条件3:キーは
date、値はrevenue、集計操作はsum。一意な日付は4つあるため4行になります。最も売上の高かった日は最大値のインデックスラベルであり、idxmax()で取得します。 - 検算:両方の表の売上合計は、ファイルの合計である4890.0と一致する必要があります。
category_report.py:
import pandas as pd
orders = pd.read_csv("orders.csv")
print("Rows, columns:", orders.shape)
print("Categories:", sorted(orders["category"].unique()))
print("Missing:", int(orders[["category", "quantity", "price"]].isna().sum().sum()))
print()
orders["revenue"] = orders["quantity"] * orders["price"]
table = orders.groupby("category").agg(
orders=("order_id", "count"),
units=("quantity", "sum"),
revenue=("revenue", "sum"),
avg_order_units=("quantity", "mean"),
)
table["avg_unit_price"] = (table["revenue"] / table["units"]).round(2)
table = table.sort_values("revenue", ascending=False)
print(table)
print()
daily = orders.groupby("date")["revenue"].sum()
print(daily)
print("Best day:", daily.idxmax(), "with", daily.max())
print()
print("Proof:", table["revenue"].sum(), daily.sum(), orders["revenue"].sum())Rows, columns: (8, 7)
Categories: ['accessories', 'stationery']
Missing: 0
orders units revenue avg_order_units avg_unit_price
category
accessories 4 14 3870.0 3.50 276.43
stationery 4 67 1020.0 16.75 15.22
date
2024-03-01 480.0
2024-03-02 2540.0
2024-03-03 1090.0
2024-03-04 780.0
Name: revenue, dtype: float64
Best day: 2024-03-02 with 2540.0
Proof: 4890.0 4890.0 4890.0計画通り2行と4行になり、3つの売上合計値はすべて一致しました。どちらか一方の列だけでは見えなかった事実が、この表によって明らかになっている点に注目してください。stationeryは5倍近い数量を販売していますが、accessoriesはその4倍近くの金額を売り上げています。
3つの実験が示すもの。
- 1行のカテゴリを
"Stationery"にすると、表は3行になります——Stationeryが1件の注文を持つ独自のグループになるためです。行が失われたわけではなく単に誤って分類されただけなので、検算は依然としてパスします。冒頭に一意なカテゴリの行を出力したのはそのためです。出力には3つのカテゴリが表示されるため、いずれかの数値を読む前に問題に気付くことができます。 "mean"を指定すると、unitsはavg_order_unitsと同じ3.5と16.75になります。これは「どれだけ販売したか」ではなく、「典型的な注文規模はどれくらいか」に答えるものになってしまいます。また、売上を誤った数値で割ることになるため、avg_unit_priceも変化します。priceの平均を計算すると、accessoriesは(850 + 120 + 850 + 120) / 4 = 485.0となり、実際の276.43を大幅に上回ってしまいます。1個および2個のバッグの注文が、7個および4個のボトルの注文と同じ重みでカウントされてしまうためです。stationeryも15.22から24.5へと上昇しますが、価格帯が全体的に低いため変化が目立たないにすぎません。注文明細行に対して1個あたりの単価を平均することが「罠」であり、合計値同士で割り算を行うことがその解決策です。