DataFrameの結合 — concatによる積み重ね、mergeによる照合
複数の表を結合する方法:concatを使って同じ構造の月別データを積み重ね、mergeを使って別の表の情報を追加します。マージの種類(how)の選択、一致しなかった行の確認、そして行数を密かに増殖させてしまう重複キーの検知について学びます。
どうやって学びますか?
解決する問題
店のデータが1つのファイルだけで完結することはありません。現実の開発現場でも、常にデータは分かれています。
3月の注文データは、第4章から使ってきた表であるorders.csvに入っています。今週、4月の注文データが別のエクスポートファイルorders_april.csvとして届きました。また、各商品の店への仕入れ原価(コスト)や、どの仕入先(サプライヤー)が販売しているかは、仕入れチームが管理する専用のファイルproducts.csvに記録されています。注文ファイルに原価を記録する人はいません。原価は注文ではなく、商品に紐づく情報だからです。
ここで、店のオーナーから1つの質問を受けました。「3月と4月を通じて、仕入先ごとにいくらの利益をもたらしてくれたのか知りたい。」
1つのファイルだけでは、この問いに答えることはできません。利益を計算するには、(注文データにある)数量と販売価格、(商品ファイルにある)原価、そして(同じく商品ファイルにある)仕入先の情報が必要です。しかも、注文データは2つの月に分かれています。
表計算ソフト(Excelなど)であれば、3月のデータの下に4月の行をコピー&ペーストし、新しい列にVLOOKUP関数を書いて各行の原価を取得し、下へオートフィルしてうまくいくことを祈るでしょう。pandasなら、その2つの作業をたった2行のコードで実現できます。
- 同じ種類の行を持つ表(3月と4月)を縦に積み重ねて(スタックして)、1つの長い表にする:
pd.concat - 共通の列をもとに、一方の表の各行をもう一方の表の正しい行と照合し(マッチさせ)(各注文とその商品を紐づけ)、相手の表の列を取り込む:
pd.merge
コード自体は非常に短いです。この章で本当に重要になるのは、「エラーも出ないまま静かに発生する問題」への対処です。マージを行うと、参照先の表にない商品を含む注文が何事もなかったかのように除外(ドロップ)されてしまったり、参照先の表に同じ商品が2回記載されていることで注文が静かに重複してしまったりすることがあります。どちらのケースでも、一見ごく自然に見える間違った合計値が算出されてしまいます。そのため、この章の大部分は、マージの前後に何行になるべきかを正確に把握し、それを実際に検証する方法に費やされます。
この章を終える頃には
- 行を縦に積み重ねる操作(
concat)と、キーをもとに照合する操作(merge)の2つを正しく見分けられるようになる pd.concatで表を縦に積み重ね、ignore_index=Trueで重複したインデックスを修正し、列が一致しない場合に何が起きるかを理解できるようになるpd.mergeで共通のキーを使って2つの表を結合し、意図を持ってhow="inner"、"left"、"right"、"outer"を選択できるようになるindicator=Trueを使ってマージ結果を検証(監査)し、validate="many_to_one"でマージを保護できるようになる- 重複キーによって引き起こされる行の急増(行の爆発)を発見し、修正できるようになる
- 名前の異なるキー同士を
left_on/right_onでマージし、列名の衝突をsuffixesで適切に処理できるようになる - キーの型不一致やキー列の欠落といったマージ時のエラーと、何も警告を出さないサイレントな不具合を認識できるようになる
前提条件: 列の追加。
まずファイルを作る
この章では4つの小さなファイルを使用します。作成するスクリプトと同じ場所に配置してください。
orders.csv —— 3月のデータ。これまでの章と同じファイルです。
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.0orders_april.csv —— 4月のデータ。列構成は同じです。ホッチキス(stapler)に注目してください。店は4月からこの商品の販売を開始しました。
order_id,date,branch,product,category,quantity,price
1009,2024-04-01,north,pen,stationery,15,15.0
1010,2024-04-01,east,notebook,stationery,3,60.0
1011,2024-04-02,south,stapler,stationery,6,95.0
1012,2024-04-02,north,bag,accessories,1,850.0products.csv —— 仕入れチームが管理する、商品1つにつき1行のファイルです。現実の現場と同じように、意図的に2つの「ズレ」を含めています。新商品のホッチキス(stapler)がまだ追加されていないことと、店が一度も販売したことのないマーカー(marker)が含まれていることです。
product,cost,supplier
pen,9.0,Alpha
notebook,40.0,Alpha
bag,600.0,Bravo
bottle,80.0,Bravo
eraser,5.0,Alpha
marker,25.0,Alphabranches.csv —— 店の3つの支店をそれぞれ誰が管理しているかのデータです。別の担当者が作成したファイルであるため、列名がbranchではなくbranch_nameになっています。
branch_name,manager
north,Mira
south,Omar
east,Lenaコードを書く前に
複数の表を結合する作業は、手軽に書いた1行のコードが、自信満々の間違った答えを最も生み出しやすい場面です。そのため、まずは計画を立てることから始めます。計画の大部分は、データを目で見て確認するだけで答えられる問いです。
1. やりたいことを1文で言葉にする
「3月と4月を合わせた、仕入先ごとの利益」。この1文だけで、最終的な表の姿が見えてきます。仕入先ごとに1行、各行に1つの数値が入る表です。仕入先はAlphaとBravoの2社が存在するため、答えは約2行になるはずです。もし結果が3行や1行になったとしたら、途中のどこかで何かが狂ったということです。
2. 各ペアに必要な操作を決める
表の結合には2種類しかありません。判定基準はシンプルです。「2つの表は同じ種類の行を保持しているか、それとも同じ対象に関する異なる情報を保持しているか?」
- 同じ列、異なる行 —— 3月の注文 + 4月の注文。
pd.concat([a, b])を使用します。結果は縦方向(下)に伸びます。同じ列のまま、行数が増えます。 - 共通のキー列、異なる情報 ——
productで紐づけられた注文 + 商品。pd.merge(a, b, on="product")を使用します。結果は横方向に広がります。同じ行数のまま、列数が増えます。
3月と4月はどちらも「注文」データであり、同じ列でより多くの行があるため、縦に積み重ねます。注文と商品は商品名で結び付けられた異なる対象であるため、照合して結合します。
3. 結合する前にすべての表を確認する
第3章で、ファイルを読み込んだらすぐに確認する習慣を学びました。複数のファイルを扱う場合、この習慣はもはや必須です。結果の形状(shape)を予測するには、それぞれの表の形状を知る必要があるからです。
import pandas as pd
march = pd.read_csv("orders.csv")
april = pd.read_csv("orders_april.csv")
products = pd.read_csv("products.csv")
for name, df in [("march", march), ("april", april), ("products", products)]:
print(name, df.shape, list(df.columns))march (8, 7) ['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price']
april (4, 7) ['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price']
products (6, 3) ['product', 'cost', 'supplier']3月と4月にはまったく同じ7つの列が同じ順序で並んでおり、縦に積み重ねるのに適しています。productsはこれらとちょうど1つの列名productを共有しています。これがキーであり、照合の基準となる列です。
キーが両側の表で同じデータ型であることを確認してください。一方の表では数値、もう一方では文字列になっているようなキーは一致させることができません。
print(march["product"].dtype, products["product"].dtype)str strどちらもstrです。比較可能です。
4. 参照側のキーが一意であることを確認する
これは、この章で最も重要なチェック項目です。各注文には1つの原価が必要です。もしproducts.csvにpenが2回記載されていると、penのすべての注文が2回一致し、結果の中に2回ずつ出現してしまいます。
print(products["product"].is_unique)
print(products["product"].duplicated().sum())True
0is_uniqueはTrueであり、重複は0件です。商品1つにつき1行となっています。これで、各注文が高々1つの商品行としか一致しないことが保証されます。
5. 相手が見つからないキーを確認する
マージを実行する前に問いかけてみましょう。products.csvに商品が存在しない注文はあるでしょうか? 誰も注文しなかった商品はあるでしょうか? 第5章(行のフィルタリング)で学んだisinを使えば、その両方に答えが出せます。
orders = pd.concat([march, april], ignore_index=True)
no_cost = ~orders["product"].isin(products["product"])
print(orders.loc[no_cost, ["order_id", "product"]])
never_sold = ~products["product"].isin(orders["product"])
print(products.loc[never_sold, "product"].tolist())order_id product
10 1011 stapler
['marker']1件の注文 —— ホッチキス(1011) —— はファイルに原価の記録がありません。また、マーカーという1つの商品は一度も売れませんでした。これで、マージ処理が何を扱うことになるのかを、マージを実行する前に把握できました。
6. 結果を予測し、方針を決める
予測を数値として書き出してみましょう。
ordersには 8 + 4 = 12行あります。productsは商品1つにつき1行なので、照合によって注文が増殖することはありません。- すべての注文を残す場合、マージ後の表は12行になり、そのうち1行(ホッチキス)には原価がありません。
- 一致した注文だけを残す場合、11行になります。
どちらを望むでしょうか? 利益レポートにおいて、ホッチキスの注文を黙って切り捨ててしまえば、売上を過小に報告することになってしまいます。注文自体は保持した上で、原価が欠損していることを確認し、その対処を判断する方がはるかに適切です。そしてこれこそが、引数how=によって下す選択そのものです。マージを行った後、最初にprintすべきなのは形状(shape)であり、この事前予測と突き合わせます。
これが計画の全容です。やりたいことを1文で言葉にする、ペアごとの操作を決める、各表を確認する、キーの一意性を確認する、不一致のキーを確認する、予測行数を出す。この章の残りは、それを実行するためのツールの解説です。
pd.concatで行を縦に積み重ねる
pd.concatは表のリストを受け取り、それらを縦に積み重ねて結合します。
import pandas as pd
march = pd.read_csv("orders.csv")
april = pd.read_csv("orders_april.csv")
both = pd.concat([march, april])
print(both.shape)
print(both[["order_id", "date", "product", "quantity"]])(12, 7)
order_id date product quantity
0 1001 2024-03-01 pen 12
1 1002 2024-03-01 notebook 5
2 1003 2024-03-02 bag 2
3 1004 2024-03-02 bottle 7
4 1005 2024-03-03 eraser 30
5 1006 2024-03-03 bag 1
6 1007 2024-03-04 pen 20
7 1008 2024-03-04 bottle 4
0 1009 2024-04-01 pen 15
1 1010 2024-04-01 notebook 3
2 1011 2024-04-02 stapler 6
3 1012 2024-04-02 bag 112行になり、同じ7つの列が維持されています。pandasは位置ではなく名前で列を揃えます。仮に4月のファイルの列の並び順が異なっていたとしても、正しい列ヘッダーの下に配置されます。
しかし、左端のインデックスを見てください。0から7まで進んだ後、再び0から始まっています。concatは各表が持っていた元のインデックスをそのまま保持するため、0から3のラベルが2回出現しています。これは見た目だけの問題ではありません。
print(both.loc[0, ["order_id", "date"]])order_id date
0 1001 2024-03-01
0 1009 2024-04-01行0を要求したのに、2つの行が返されてしまいました。「1つのラベルは1行を表す」と想定しているすべてのコード(loc、後続のインデックスによるマージ、単一セルの更新など)が、意図しない動作を引き起こすことになります。これはコードで検出できます。
print(both.index.is_unique)Falseignore_index=True —— 行番号を振り直す
元のインデックスが単なる行番号(これまでのすべてのread_csvで見られたRangeIndex)である場合、そこには何の意味もありません。古いインデックスは破棄し、concatに0から番号を振り直させましょう。
orders = pd.concat([march, april], ignore_index=True)
print(orders.index.is_unique)
print(orders[["order_id", "date", "product"]].tail(5))True
order_id date product
7 1008 2024-03-04 bottle
8 1009 2024-04-01 pen
9 1010 2024-04-01 notebook
10 1011 2024-04-02 stapler
11 1012 2024-04-02 bagこれでラベルは0から11まで、それぞれちょうど1回ずつ並びます。複数のファイルを縦に積み重ねる際は、ignore_index=Trueを基本のデフォルトにしてください。インデックスに意味がある場合(たとえばset_index("order_id")の後で、注文IDがすでに一意である場合など)にのみ、このオプションを外します。
各行の由来を記録しておく
積み重ねた後、表の中のどの行がどの月に属しているかを示す情報は何も残りません。ここでは日付から推測できますが、現場では手掛かりが何もないこともしばしばあります。前章で学んだテクニックを使って、結合する前に列を追加しておけば、その情報は行と一緒に保持されます。
march["month"] = "March"
april["month"] = "April"
orders = pd.concat([march, april], ignore_index=True)
print(orders["month"].value_counts())month
March 8
April 4
Name: count, dtype: int64この件数は検証としても役立ちます。8 + 4 = 12であり、何も失われていないことが確認できます。
列が一致しない場合
concatは名前によって列を揃えます。一部の表にしか存在しない列であっても、結合結果の表にはそのまま現れます。そして、その列を持っていなかった表の行にはNaNが入ります。手作業で入力された単発の注文データがあり、discount(割引)列はあるものの、date、product、category、priceの列がなかったとしましょう。
manual = pd.DataFrame({
"order_id": [2001],
"branch": ["north"],
"quantity": [3],
"discount": [0.1],
})
mixed = pd.concat([march.head(2), manual], ignore_index=True)
print(mixed[["order_id", "branch", "price", "discount"]])
print(mixed.isna().sum())order_id branch price discount
0 1001 north 15.0 NaN
1 1002 south 60.0 NaN
2 2001 north NaN 0.1
order_id 0
date 1
branch 0
product 1
category 1
quantity 0
price 1
month 1
discount 2
dtype: int64エラーにはなりません。結果にはすべての列の和集合(ユニオン)が含まれ、隙間はNaNで埋められます。だからこそ、縦の積み重ねを行った後には、第6章(欠損データ)で学んだisna().sum()による確認が欠かせません。1つのファイルで列名の綴りが異なっていた場合(Quantityとquantityなど)、エラーで止まるのではなく、半分が空の2つの列が作られてしまいます。concatの後の列数がどの入力ファイルよりも多い場合、何かが正しく揃っていません。
axis=1 —— そして通常はそれを使うべきではない理由
pd.concat([a, b], axis=1)は表を横に並べ、行をインデックスのラベルで一致させます。「注文データに商品の列を追加したい」というときに使いたくなるかもしれませんが、これは商品名を一切考慮しません。それぞれの行に何が入っていようと、一方の表の行0がもう一方の表の行0の隣に配置されてしまいます。特定の列をもとに行を一致させるには、mergeを使用します。
pd.mergeで行を照合して結合する
mergeは2つの表と1つのキーを受け取ります。左の表のすべての行について、同じキー値を持つ右の表の行を探し出し、それらの列を結合します。
products = pd.read_csv("products.csv")
merged = pd.merge(orders, products, on="product")
print(merged.shape)
print(merged[["order_id", "product", "quantity", "price", "cost", "supplier"]])(11, 10)
order_id product quantity price cost supplier
0 1001 pen 12 15.0 9.0 Alpha
1 1002 notebook 5 60.0 40.0 Alpha
2 1003 bag 2 850.0 600.0 Bravo
3 1004 bottle 7 120.0 80.0 Bravo
4 1005 eraser 30 8.0 5.0 Alpha
5 1006 bag 1 850.0 600.0 Bravo
6 1007 pen 20 15.0 9.0 Alpha
7 1008 bottle 4 120.0 80.0 Bravo
8 1009 pen 15 15.0 9.0 Alpha
9 1010 notebook 3 60.0 40.0 Alpha
10 1012 bag 1 850.0 600.0 Bravoループ処理を書くこともなく、どちらのファイルの行順序にも左右されずに、すべての注文の横に原価と仕入先が並びました。キー列であるproductは両側の表で共通しているため、1回だけ現れます。
ここで、形状(shape)を先ほどの計画と比較してみましょう。すべてを保持した場合12行になると予測していましたが、結果は11行になっています。ホッチキスの注文1011が消えています。警告もエラーも一切ありません。
これはバグではありません。デフォルトのマージ方式であるhow="inner"が、その仕様どおりに動作した結果です。すなわち、両方の表に存在するキーのみを残したのです。ホッチキスはproductsに存在しないため、その注文は残りません。マーカーはordersに存在しないため、やはり表示されません。もし事前に12行と予測していなければ、売上が1件抜け落ちていることに気付くことはなかったでしょう。
4種類のマージ
引数how=は、相手側に一致する行が存在しないキーをどう扱うかを決定します。2つの表のキーの集合をイメージしてください。
how="inner"(デフォルト):両方の表に存在するキーのみを保持します。どちらか一方にしか存在しない行は除外されます。完全なレコードのみが必要な場合に使用します。how="left":左の表のすべての行を保持します。相手が見つからない左の行には、右の表の列にNaNが入ります。相手のない右の行は除外されます。メインの表に情報を肉付けする際に使用し、最も一般的な選択肢です。how="right":その鏡像です。右の表のすべての行を保持し、相手のない左の行は除外されます。how="outer":どちらか一方にでも存在するすべてのキーを保持し、欠けている部分をNaNで埋めます。2つのリストを互いに突き合わせて検証(監査)する際に使用します。
自問すべき問いは、「行が絶対に消えては困る表はどちらか?」です。ここでは注文データです。すべての注文は実際の売上であり、商品ファイルは単なる参照用(ルックアップ)です。したがって、適切なマージは注文データを左に配置したleftマージです。
merged = pd.merge(orders, products, on="product", how="left")
print(merged.shape)
print(merged[["order_id", "product", "cost", "supplier"]])(12, 10)
order_id product cost supplier
0 1001 pen 9.0 Alpha
1 1002 notebook 40.0 Alpha
2 1003 bag 600.0 Bravo
3 1004 bottle 80.0 Bravo
4 1005 eraser 5.0 Alpha
5 1006 bag 600.0 Bravo
6 1007 pen 9.0 Alpha
7 1008 bottle 80.0 Bravo
8 1009 pen 9.0 Alpha
9 1010 notebook 40.0 Alpha
10 1011 stapler NaN NaN
11 1012 bag 600.0 Bravo予測どおり12行になりました。ホッチキスの注文もそのまま残り、原価と仕入先にはNaNが入っています。これはデータの実態に即しています。原価が不明であることがひと目で分かります。見えないところで勝手に行が消えるよりも、目に見える欠損値がある方がはるかに安全です。ここからは、第6章(欠損データ)のツールが役立ちます。isna().sum()で欠損の数を数えたり、仕入れチームから原価を聞いて穴埋めしたり、その注文だけを別個に報告したりできます。
rightマージはその鏡像であり、すべての商品が保持され、注文のないマーカーも残ります。
right = pd.merge(orders, products, on="product", how="right")
print(right.shape)
print(right[["order_id", "product", "cost"]].tail(3))(12, 10)
order_id product cost
9 1008.0 bottle 80.0
10 1005.0 eraser 5.0
11 NaN marker 25.0order_idが整数から1008.0や1005.0などの浮動小数点数に変化していることに注目してください。マーカーの行には注文IDが存在しないため、この列にNaNが入ったのです。第6章で確認したとおり、整数の列に1つでもNaNが入ると型はfloat64に変換されます。マージ後にID列に突然.0が付くようになったら、相手が見つからなかった行がある証拠です。
indicator=True —— すべての行の由来を確認する
outerマージは両側のすべてのデータを保持します。ここにindicator=Trueを追加すると、pandasは各行のキーが両方の表にあったのか、左側だけだったのか、右側だけだったのかを示す_merge列を追加してくれます。
audit = pd.merge(orders, products, on="product", how="outer", indicator=True)
print(audit[["order_id", "product", "cost", "_merge"]])
print(audit["_merge"].value_counts())order_id product cost _merge
0 1003.0 bag 600.0 both
1 1006.0 bag 600.0 both
2 1012.0 bag 600.0 both
3 1004.0 bottle 80.0 both
4 1008.0 bottle 80.0 both
5 1005.0 eraser 5.0 both
6 NaN marker 25.0 right_only
7 1002.0 notebook 40.0 both
8 1010.0 notebook 40.0 both
9 1001.0 pen 9.0 both
10 1007.0 pen 9.0 both
11 1009.0 pen 9.0 both
12 1011.0 stapler NaN left_only
_merge
both 11
left_only 1
right_only 1
Name: count, dtype: int64これはマージ自体の「通知表」のようなものです。11行が一致し、1件の注文には商品の行がなく(left_only:ホッチキス)、1つの商品には注文がありませんでした(right_only:マーカー)。これは計画のステップ5で確認したのと同じ情報であり、マージ処理そのものによって算出されます。
また、outerマージでは、注文データの元の並び順を保つのではなく、行がキー(bag、bottle、eraser、…)順にソートされていることにも注意してください。innerマージとleftマージは左の表の順序を保持しますが、outerマージはその順序を保証しません。順序が重要な場合は、第7章のようにマージ後にソートを行ってください。
_merge列は通常のデータ列なので、フィルタリングに使用できます。問題のある行だけを一覧表示するのも、たった1行で書けます。
problems = audit[audit["_merge"] != "both"]
print(problems[["order_id", "product", "_merge"]])order_id product _merge
6 NaN marker right_only
12 1011.0 stapler left_only自分で作成したのではないデータをマージするときは、常にこのような検証を行ってください。問題リストが空であれば、2つのファイルの内容が完全に一致している証拠になります。
潜む危険:重複キー
ここまでの例では、productsには商品1つにつき1行しかありませんでした。ここで、仕入れチームが少し高い原価で2社目のペンの仕入先を追加し、単に行を末尾に追加したため、penが2回記載されたと仮定してみましょう。
products_dup = pd.concat(
[products, pd.DataFrame({"product": ["pen"], "cost": [10.0], "supplier": ["Gamma"]})],
ignore_index=True,
)
print(products_dup["product"].is_unique)
exploded = pd.merge(march, products_dup, on="product", how="left")
print(march.shape, "->", exploded.shape)
print(exploded[["order_id", "product", "quantity", "cost", "supplier"]])False
(8, 8) -> (10, 10)
order_id product quantity cost supplier
0 1001 pen 12 9.0 Alpha
1 1001 pen 12 10.0 Gamma
2 1002 notebook 5 40.0 Alpha
3 1003 bag 2 600.0 Bravo
4 1004 bottle 7 80.0 Bravo
5 1005 eraser 30 5.0 Alpha
6 1006 bag 1 600.0 Bravo
7 1007 pen 20 9.0 Alpha
8 1007 pen 20 10.0 Gamma
9 1008 bottle 4 80.0 Bravo3月の注文は8件でしたが、マージ結果は10行になりました。注文1001と1007(2件のペンの注文)が、ペンの行ごとに1回ずつ、計2回現れています。エラーは何一つ発生していません。しかし、この状態で数量を合計すると、ペンが2重にカウントされてしまい、一度も売れていない余分なペン32本分が加算されてしまいます。
print(march["quantity"].sum(), exploded["quantity"].sum())81 11381のはずが113になってしまいました。これは行の爆発(row explosion)と呼ばれ、結果が一見ごく自然に見えるため、最も深刻な被害を生みやすいマージミスです。その背景にあるルールはこうです。マージは、一致する左の表のすべての行と、一致する右の表のすべての行をペアにします。1対1や多対1の結合であれば行数は変わりませんが、両方の表でキーが重複していると、行数が掛け算のように増殖してしまいます。
マージによって勝手に行が増えても、誰も教えてくれません。唯一の症状は合計値が過大になることですが、過大な合計値を見て不審に思える人は滅多にいません。すべてのマージの前後でlen()を比較してください。これこそが、この問題を見破る唯一の確実なチェック方法です。validate= —— pandasに自動チェックさせる
期待される対応関係をあらかじめ宣言しておけば、データがそのルールを破っていた場合にpandasがマージを拒否してくれます。多くの注文が1つの商品を共有するため、このマージは多対1(many-to-one)である必要があります。
try:
pd.merge(march, products_dup, on="product", how="left", validate="many_to_one")
except Exception as error:
print(type(error).__name__ + ":", error)MergeError: Merge keys are not unique in right dataset; not a many-to-one merge
Duplicates in right:
product
pen ...MergeErrorが発生し、原因となった重複キーまで表示してくれました。指定できる値には他に"one_to_one"、"one_to_many"、"many_to_many"があります。validate=を指定する手間はごくわずかであり、静かな誤答を明確なエラーに変えてくれます。参照テーブルを使ったマージには、常にこの指定を入れましょう。
修正:どちらの重複が正しいかを決める
エラーは「何かが間違っている」という事実を教えてくれますが、「何が正しいか」を決めるのは人間の仕事です。まずは重複している行を確認しましょう。
print(products_dup[products_dup["product"].duplicated(keep=False)])product cost supplier
0 pen 9.0 Alpha
6 pen 10.0 Gammaduplicated(keep=False)を指定すると、2番目以降だけでなく、すべての重複行にフラグが立ちます。ここからはビジネス上の判断になります。最新の行で古い行を置き換えるのであれば、最後の行を残します。
products_clean = products_dup.drop_duplicates(subset="product", keep="last")
fixed = pd.merge(march, products_clean, on="product", how="left", validate="many_to_one")
print(fixed.shape)
print(fixed.loc[fixed["product"] == "pen", ["order_id", "cost", "supplier"]])(8, 10)
order_id cost supplier
0 1001 10.0 Gamma
6 1007 10.0 Gamma注文ごとに1行、元の8行に戻りました。もし両方の仕入先が実際にペンを販売しており、注文ごとにどちらの仕入先から購入したかが記録されているのであれば、注文データ側にもsupplier列が必要であり、マージキーはon=["product", "supplier"]の2つの列になります。コードがその判断を下すことはできません。計画の段階でそれを決める必要があります。
キー列の名前が異なる場合
branches.csvでは列名がbranch_nameとなっていますが、注文データ側ではbranchになっています。on=を指定するには両方の表で列名が一致している必要があるため、left_on=とright_on=を使って左右それぞれのキー列名を指定します。
branches = pd.read_csv("branches.csv")
with_manager = pd.merge(orders, branches, left_on="branch", right_on="branch_name", how="left")
print(list(with_manager.columns))['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'month', 'branch_name', 'manager']pandasは人間がそれらを同じ列とみなしていることを判断できないため、両方のキー列がそのまま残されます。branchとbranch_nameにはまったく同じ値が入っているため、一方を削除します。
with_manager = with_manager.drop(columns="branch_name")
print(with_manager[["order_id", "branch", "manager"]].head(4))order_id branch manager
0 1001 north Mira
1 1002 south Omar
2 1003 north Mira
3 1004 east Lenaもう1つの方法は、マージする前に名前を変更しておくことです。branches.rename(columns={"branch_name": "branch"})を実行しておけば、あとは通常のon="branch"を使用できます。どちらの方法でも問題ありませんが、事前にリネームしておけば、結合後に不要な列を削除する手間が省けて結果がすっきりします。
両方の表に同じ名前の列がある場合
キー以外の列が両方の表に存在する場合、pandasは1つの表の中にpriceという名前の列を2つ置くことができないため、サフィックス(接尾辞)として_x(左)と_y(右)を付けて名前を変更します。定価(リストプライス)の表があり、その列名もpriceになっている場合を考えてみましょう。
list_prices = pd.DataFrame({"product": ["pen", "bag"], "price": [14.0, 800.0]})
compared = pd.merge(march, list_prices, on="product")
print(compared[["order_id", "product", "price_x", "price_y"]])order_id product price_x price_y
0 1001 pen 15.0 14.0
1 1003 bag 850.0 800.0
2 1006 bag 850.0 800.0
3 1007 pen 15.0 14.0price_xとprice_yでも動作はしますが、3週間も経てばどちらがどちらだったか誰も覚えていません。suffixes=を使って、自分自身で明確な名前を付けましょう。
compared = pd.merge(march, list_prices, on="product", suffixes=("_sold", "_list"))
print(compared[["order_id", "price_sold", "price_list"]])order_id price_sold price_list
0 1001 15.0 14.0
1 1003 850.0 800.0
2 1006 850.0 800.0
3 1007 15.0 14.0これで各列の意味が明確になり、注文ごとの値引き額もprice_list - price_soldとしてシンプルに計算できます。(なお、この後にcompared["price"]を要求するコードを書くとKeyErrorになります。マージ後はその通りの名前の列が存在しなくなるためです。意図を持って列名を選ぶべき理由はここにもあります。)
join —— インデックスによるマージの概要
DataFrameには.join()メソッドも用意されています。これは右の表のインデックスをキーとして使用するマージであり、参照先の表がすでにそのキーでインデックス化されている場合に便利です。
by_product = products.set_index("product")
joined = orders.join(by_product, on="product")
print(joined[["order_id", "product", "cost", "supplier"]].head(3))order_id product cost supplier
0 1001 pen 9.0 Alpha
1 1002 notebook 40.0 Alpha
2 1003 bag 600.0 Bravojoinはデフォルトでhow="left"になります。これはpd.merge(..., how="left")と同じ働きをします。mergeの方がより汎用的なツールであり、このコースの残りの部分でも一貫してmergeを使用します。
完全な例
オーナーからの質問に戻りましょう。「3月と4月を通じて、すべての注文を漏れなく考慮した上で、仕入先ごとにいくらの利益をもたらしたか?」です。
supplier_profit.py:
import pandas as pd
march = pd.read_csv("orders.csv")
april = pd.read_csv("orders_april.csv")
products = pd.read_csv("products.csv")
# 1. Stack the two months, remembering where each row came from.
march["month"] = "March"
april["month"] = "April"
orders = pd.concat([march, april], ignore_index=True)
print("orders:", orders.shape)
# 2. The lookup key must be unique, or the merge will duplicate orders.
assert products["product"].is_unique
# 3. Attach cost and supplier to every order. Keep every order.
sales = pd.merge(
orders, products, on="product", how="left",
validate="many_to_one", indicator=True,
)
print("after merge:", sales.shape)
assert len(sales) == len(orders)
# 4. Report the orders that found no product row, then set them aside.
unmatched = sales[sales["_merge"] == "left_only"]
print("no cost on file:", unmatched["order_id"].tolist(), unmatched["product"].tolist())
sales = sales[sales["_merge"] == "both"].drop(columns="_merge")
# 5. Profit per row, then per supplier and month.
sales["profit"] = sales["quantity"] * (sales["price"] - sales["cost"])
report = (
sales.groupby(["supplier", "month"], as_index=False)["profit"]
.sum()
.sort_values(["supplier", "profit"], ascending=[True, False])
.reset_index(drop=True)
)
print()
print(report)
print()
print(sales.groupby("supplier")["profit"].sum().sort_values(ascending=False))orders: (12, 8)
after merge: (12, 11)
no cost on file: [1011] ['stapler']
supplier month profit
0 Alpha March 382.0
1 Alpha April 150.0
2 Bravo March 1190.0
3 Bravo April 250.0
supplier
Bravo 1440.0
Alpha 532.0
Name: profit, dtype: float64なぜこのように書かれているのか、理由を見ていきましょう。
- 結合ステップの直後に必ずshapeを出力している。
orders: (12, 8)によって縦の積み重ね(8 + 4行、7列 +month列)が正しく行われたことを確認しています。after merge: (12, 11)によって、除外や重複が発生していないことを確認しています(新しく追加された3列はcost、supplier、_mergeです)。shapeの確認は、各ステップが計画通りに進んだことを証明する最も手軽で確実な手段です。 - 前提条件をコードとして記述している。
assert products["product"].is_uniqueとvalidate="many_to_one"は、ともに行の爆発を防ぐ防壁です。assert len(sales) == len(orders)は、計画で立てた予測通りの行数であることを検証しています。もし来月、仕入れチームから送られてきた商品ファイルに重複が含まれていたとしても、スクリプトは水増しされた利益を出力することなく安全に停止します。 how="left"でホッチキスの注文を維持し、indicator=Trueでそれを特定している。 スクリプトは注文を黙って切り捨てることはせず、[1011] ['stapler']と出力します。これにより、仕入れチームが原価を登録するまで、4月の合計値から売上1件分が保留されていることをオーナーに伝えることができます。この出力行はデバッグ出力ではなく、レポート結果の重要な一部です。concatの前にmonth列を追加している。 これを行わない場合、レポートで月ごとに分割するために日付文字列をパースして変換する必要が生じます。それでも可能ですが、同じ結果を得るためにより多くのコードを書くことになります。- 利益の計算はマージの後に実行している。 利益の計算には両方のファイルの列が必要だからです。結合(本章)→ 列の追加(第9章)→ グループ化(第8章)→ ソート(第7章)という流れが、データ処理の自然な順序です。
結果として、Bravoが1,440、Alphaが532の利益をもたらしたことが分かりました(4月の1件の売上は原価待ちの状態です)。Bravoの商品は販売点数こそ少ないものの、バッグやボトルはペンや消しゴムに比べて1点あたりの利益率がはるかに高いことが分かります。
問題が起きたとき
ValueError: You are trying to merge on int64 and str columns for key 'order_id'. If you wish to proceed you should use pd.concat
キーのデータ型が左右の表で異なっています。ここに、注文IDの先頭に#が付いた状態でエクスポートされた配送ファイルがあります。これをdeliveries.csvとして保存してください(「やってみよう」セクションでも使用します。第6章の欠損データの回で作成した同名の配達員ログが残っている場合は、上書きしてください。これは別のファイルです)。
order_id,status
#1001,delivered
#1002,delivered
#1003,returned
#1005,delivered
#1006,pendingimport pandas as pd
march = pd.read_csv("orders.csv")
deliveries = pd.read_csv("deliveries.csv")
print(march["order_id"].dtype, deliveries["order_id"].dtype)
try:
pd.merge(march, deliveries, on="order_id", how="left")
except ValueError as error:
print("ValueError:", error)int64 str
ValueError: You are trying to merge on int64 and str columns for key 'order_id'. If you wish to proceed you should use pd.concat1001と"#1001"は異なる値であり、pandasが気を利かせて推測することはありません(なお、エラーメッセージ内のpd.concatに関するヒントは別の状況向けのものなので無視してかまいません)。解決策は、キーを同じデータ型かつ同じテキストに揃えることです。#を削除してから変換します。
deliveries["order_id"] = deliveries["order_id"].str.removeprefix("#").astype("int64")
status = pd.merge(march, deliveries, on="order_id", how="left")
print(status[["order_id", "product", "status"]])order_id product status
0 1001 pen delivered
1 1002 notebook delivered
2 1003 bag returned
3 1004 bottle NaN
4 1005 eraser delivered
5 1006 bag pending
6 1007 pen NaN
7 1008 bottle NaN配達記録のない注文は、statusがNaNになります。leftマージによってそれらの行が保持されたためです。「どの注文がまだ配達されていないか?」を調べる際には、まさにこの動作が必要になります。
MergeError: Merge keys are not unique in right dataset; not a many-to-one merge validate="many_to_one"を指定しているにもかかわらず、右の表でキーが重複しています。エラーメッセージに重複しているキーが一覧表示されます。エラーを消すためにvalidateを削除してはいけません。「潜む危険:重複キー」のセクションで行ったように、duplicated(keep=False)を使って重複している行を確認し、どちらが正しい行なのかを判断してください。
KeyError: 'branch' on=で指定した列名が、いずれかの表に存在しません。
branches = pd.read_csv("branches.csv")
try:
pd.merge(march, branches, on="branch")
except KeyError as error:
print("KeyError:", error)KeyError: 'branch'branchesではその列がbranch_nameになっています。両方の表に対してlist(df.columns)を出力して確認してください。left_on="branch", right_on="branch_name"を使用するか、どちらか一方の列名を変更します。なお、列名の末尾に余分なスペースが入っている場合("branch "など)も同じエラーが発生し、これはリスト形式で出力して初めて発見できます。
マージは実行できたが、新しい列がすべてNaNになっている エラーは出ないものの、すべての行が一致しなかった状態です。キーが一見同じに見えても実際には一致していません。末尾にスペースが含まれる"north "、大文字の"South"、あるいは一方のファイルでは文字列で他方ではプレフィックス付きのIDになっているなどの原因が考えられます。マージの前にisinで確認してください。
branches_messy = pd.DataFrame({"branch_name": ["north ", "South", "east"], "manager": ["Mira", "Omar", "Lena"]})
print(march["branch"].isin(branches_messy["branch_name"]).sum(), "of", len(march), "orders match")
branches_messy["branch_name"] = branches_messy["branch_name"].str.strip().str.lower()
print(march["branch"].isin(branches_messy["branch_name"]).sum(), "of", len(march), "orders match")2 of 8 orders match
8 of 8 orders matchクレンジング前は8件中2件しか一致しませんでしたが、クレンジング後は8件中8件すべてが一致しました。マージを行う前に、両方のキーに対して同じクレンジング処理(str.strip()および大文字小文字の統一)を適用してください。
マージ後に行数が増えてしまった 右の表に重複キーが存在します。上記の「潜む危険:重複キー」を参照してください。すべてのマージの前後でlen()を比較し、validate=を活用しましょう。
マージ後に行が消えてしまった デフォルトのhow="inner"を使用したため、相手が見つからなかったキーが除外されました。メインの表を左に配置したhow="left"を使用し、indicator=Trueで一致しなかった行を確認してください。
TypeError: concat() takes 1 positional argument but 2 were given
try:
pd.concat(march, deliveries)
except TypeError as error:
print("TypeError:", error)TypeError: concat() takes 1 positional argument but 2 were givenconcatが受け取る位置引数は1つ(表のリスト)だけです。pd.concat([march, april])のように記述してください。角括弧(リスト)で囲むのがポイントです。
concatの後にインデックスが重複し、locで2行返されてしまう 各入力ファイルが保持していた0, 1, 2, …がそのまま残っています。ignore_index=Trueを追加してください。
ステップ 4 / 6 — 予想する
理解度をチェック
3月には8件、4月には4件の注文があります。何が出力されますか?
from io import StringIO
import pandas as pd
# Stand in for orders.csv, orders_april.csv and products.csv,
# so this snippet runs on its own.
MARCH = """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
"""
APRIL = """order_id,date,branch,product,category,quantity,price
1009,2024-04-01,north,pen,stationery,15,15.0
1010,2024-04-01,east,notebook,stationery,3,60.0
1011,2024-04-02,south,stapler,stationery,6,95.0
1012,2024-04-02,north,bag,accessories,1,850.0
"""
PRODUCTS = """product,cost,supplier
pen,9.0,Alpha
notebook,40.0,Alpha
bag,600.0,Bravo
bottle,80.0,Bravo
eraser,5.0,Alpha
marker,25.0,Alpha
"""
march = pd.read_csv(StringIO(MARCH))
april = pd.read_csv(StringIO(APRIL))
products = pd.read_csv(StringIO(PRODUCTS))
both = pd.concat([march, april])
print(both.shape, both.index[-1])- A(12, 7) 11
- B(12, 14) 3
- C(12, 7) 3
- D(8, 7) 7
誰かが商品リストにpenの2行目を追加しました。何が出力されますか?
from io import StringIO
import pandas as pd
# Stand in for orders.csv, orders_april.csv and products.csv,
# so this snippet runs on its own.
MARCH = """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
"""
APRIL = """order_id,date,branch,product,category,quantity,price
1009,2024-04-01,north,pen,stationery,15,15.0
1010,2024-04-01,east,notebook,stationery,3,60.0
1011,2024-04-02,south,stapler,stationery,6,95.0
1012,2024-04-02,north,bag,accessories,1,850.0
"""
PRODUCTS = """product,cost,supplier
pen,9.0,Alpha
notebook,40.0,Alpha
bag,600.0,Bravo
bottle,80.0,Bravo
eraser,5.0,Alpha
marker,25.0,Alpha
"""
march = pd.read_csv(StringIO(MARCH))
april = pd.read_csv(StringIO(APRIL))
products = pd.read_csv(StringIO(PRODUCTS))
products = pd.concat([products, pd.DataFrame({"product": ["pen"], "cost": [10.0], "supplier": ["Charlie"]})])
report = pd.merge(march, products, on="product", how="left")
print(len(march), len(report))- A8 6
- B8 8
- C重複キーに関する`MergeError`
- D8 10 —— penの各注文が両方のpen行に一致して2重になり、エラーも発生しなかったため
3月のすべての注文についてその商品の原価(cost)が必要であり、商品がリストに存在しない場合でも注文が失われてはいけません。どのhow=を使用しますか?
- A`how="outer"`
- B`how="right"`
- C`how="inner"`
- D`how="left"`(注文を左の表とする)
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
配送チームからdeliveries.csv(「問題が起きたとき」に掲載)が送られてきました。記録がある注文について1行ずつ記載されており、注文IDは#1001のように書かれています。オーナーは3月と4月を合わせた配送レポートを求めています。
次の条件を満たすdelivery_report.pyを作成してください。
- 両月のすべての注文を保持する —— 注文1件につきちょうど1行とし、行数に対する
assertで検証し、マージはvalidate=で保護してindicator=Trueで監査する。 - キーを確実に一致させる —— 配送データのキーを
order_idと同じ型にクレンジングし、配送ファイル内で重複している注文があればスクリプトを停止させる。 - 欠損は「記録なし(no record)」を意味する —— 配送記録のない注文のステータスには、黙って
NaNを入れるのではなく、"no record"を設定する。 - 次の順序で出力する:積み重ね後のshape、マージ後のshape、
_mergeの件数、ステータスごとの注文数、配送記録のない注文(order_id、branch、product。order_idでソート)、支店ごとの配達完了(delivered)注文数。
正しく作成できていれば、出力は正確に次のようになります。
orders: (12, 7)
after merge: (12, 9)
_merge
left_only 7
both 5
right_only 0
Name: count, dtype: int64
status
no record 7
delivered 3
returned 1
pending 1
Name: count, dtype: int64
order_id branch product
3 1004 east bottle
6 1007 east pen
7 1008 north bottle
8 1009 north pen
9 1010 east notebook
10 1011 south stapler
11 1012 north bag
branch
north 2
south 1
dtype: int64コードを書く前に、ファイルの内容だけからこの出力の各数値を説明してみてください。なぜ積み重ねた後に12行になるのか、なぜマージ後も12行のままなのか、そしてなぜ7件の注文が記録なしになるのか。もし説明できないなら、まだ計画は完了していません。
その後、わざと2回エラーを起こしてみましょう。
.str.removeprefix("#")を削除する。 どのようなエラーが発生し、どの行でエラーが出ますか?deliveries.csvに#1003の2行目を追加する(例:#1003,delivered)。validate=はどのような動作をしますか? それがない場合、レポートはどうなっていたでしょうか?
解答
計画。 解決したい問いは「3月と4月のすべての注文について、配送ステータスはどうなっているか?」です。したがって、結果は注文ごとに1行、計12行になります。3月と4月はまったく同じ列構成なので、concatとignore_index=Trueで縦に積み重ねます(8 + 4 = 12)。配送ファイルは注文IDをキーとしていますが、#付きの文字列になっているため、整数のorder_idと一致させるにはクレンジングが必要です。各注文は配送ファイル内で高々1回しか出現しないはずなので、対応関係は1対1(one-to-one)です(各注文には高々1つの配送行、各配送行には1つの注文)。すべての注文を保持する必要があるため、注文データを左に配置し、マージ方式はhow="left"にします。配送行は5行存在し、すべて3月のものなので、12 − 5 = 7件の注文が記録なしになるはずです。
delivery_report.py:
import pandas as pd
march = pd.read_csv("orders.csv")
april = pd.read_csv("orders_april.csv")
deliveries = pd.read_csv("deliveries.csv")
# 1. Stack the months.
orders = pd.concat([march, april], ignore_index=True)
print("orders:", orders.shape)
# 2. Clean the key, then check it.
deliveries["order_id"] = deliveries["order_id"].str.removeprefix("#").astype("int64")
assert deliveries["order_id"].is_unique, "an order appears twice in deliveries.csv"
# 3. Keep every order.
report = pd.merge(
orders, deliveries, on="order_id", how="left",
validate="one_to_one", indicator=True,
)
print("after merge:", report.shape)
assert len(report) == len(orders)
print(report["_merge"].value_counts())
# 4. Missing status means the delivery team has no record.
report["status"] = report["status"].fillna("no record")
# 5. Orders per status.
print()
print(report["status"].value_counts())
# 6. Orders with no record.
missing = report.loc[report["status"] == "no record", ["order_id", "branch", "product"]]
print()
print(missing.sort_values("order_id"))
# 7. Delivered orders per branch.
delivered = report[report["status"] == "delivered"]
print()
print(delivered.groupby("branch").size())orders: (12, 7)
after merge: (12, 9)
_merge
left_only 7
both 5
right_only 0
Name: count, dtype: int64
status
no record 7
delivered 3
returned 1
pending 1
Name: count, dtype: int64
order_id branch product
3 1004 east bottle
6 1007 east pen
7 1008 north bottle
8 1009 north pen
9 1010 east notebook
10 1011 south stapler
11 1012 north bag
branch
north 2
south 1
dtype: int64すべての予測が的中しました。積み重ね後は12行、マージ後も12行、記録なしは7件です。_mergeの件数からもそれが裏付けられます(bothが5件、left_onlyが7件、right_onlyが0件。leftマージでright_onlyが生成されることはありません)。
設計上の選択についてのポイント:
"many_to_one"ではなくvalidate="one_to_one"を指定した。 注文データ側も注文IDが一意であるため、両側が一意になります。より厳格な検証を行うことで、注文データ側の注文IDの重複(たとえば4月のファイルに誤って3月の注文が混入した場合など)も検知できます。is_uniqueのassertをマージの前にメッセージ付きで記述した。 マージの専門用語ではなく、日常のわかりやすい言葉で何が起きたのかを通知するためです。fillna("no record")はマージの後に適用し、決して前には適用しない。 配達記録のない注文がleftマージによって保持されて初めて、欠損ステータスが発生するからです。- east支店には配達完了の注文がないため、最後の表には一切現れません。グループ化処理は、データ内に実在するグループのみを出力します。もしオーナーがeast支店に対しても0という数値を求めるのであれば、支店リストとこの結果を
how="left"でマージし、fillna(0)を適用する必要があります。
2つの意図的なエラーについて。 removeprefixがない場合、"#1001"を数値に変換できないため、まずastype("int64")の行でValueError: invalid literal for int() with base 10: '#1001'が発生します。エラーはクレンジングの行を直接指し示しており、まさに修正すべき場所が明確です。仮にastypeも外した場合、マージ処理自体がValueError: You are trying to merge on int64 and str columns for key 'order_id'を発生させます。#1003の2行目を追加した場合、assertによってスクリプトが停止し、AssertionError: an order appears twice in deliveries.csvが表示されます。このassertを取り外すと、validate="one_to_one"がMergeError: Merge keys are not unique in right dataset; not a one-to-one mergeを発生させます。どちらの防壁もなかった場合、レポートは12件の注文に対して13行を表示し、注文1003を2回(returnedとして1回、deliveredとして1回)カウントしてしまい、それ以降のすべての合計値が静かに狂っていたことになります。