列の追加 — ファイルにない数値を計算して作る
既存の列から新しい列を作成する方法:列全体の算術演算とインデックスが値の位置を決める仕組み、assign、np.whereとnp.select、map、pd.cut、.strと.dt、行とグループを比較するtransform、そして最後の手段としてのapplyについて学びます。
どうやって学びますか?
解決する問題
店のオーナーからメッセージが届きました。「すべての注文について、いくらの売上になったのか、大口の注文だったのか、どのマネージャーが担当なのか、何曜日だったのかを確認したい。あと、それぞれの注文がその支店の売上全体に占める割合も知りたいな。」
手元にあるorders.csvを開いてみます。入っているのはquantity(数量)、price(価格)、branch(3つの店舗のうちどの支店か)、そしてdate(日付)です。彼女が求めている項目は、1つとしてファイルの中に存在しません。それらはすべて、すでにある列から計算して作り出す必要があります。
通常のPythonに慣れている人なら、自然と手が動いて行に対するループ処理を書くことでしょう。
import pandas as pd
orders = pd.read_csv("orders.csv")
revenues = []
for i in range(len(orders)):
revenues.append(orders.loc[i, "quantity"] * orders.loc[i, "price"])
orders["revenue"] = revenues
print(orders[["product", "quantity", "price", "revenue"]])product quantity price revenue
0 pen 12 15.0 180.0
1 notebook 5 60.0 300.0
2 bag 2 850.0 1700.0
3 bottle 7 120.0 840.0
4 eraser 30 8.0 240.0
5 bag 1 850.0 850.0
6 pen 20 15.0 300.0
7 bottle 4 120.0 480.0正しく動作します。しかし、これはこの章で改めるべき習慣でもあります。あとになって表面化する3つの理由があるからです。
第1に、遅いという点です。Pythonは行ごとにループ本体を1回ずつ実行するため、100万行のファイルであればインタープリタを100万往復することになります。第2に、コードが長いという点です。たった1つの掛け算に4行を要しており、要望されたリストにはさらにあと5つの列があります。そして第3に、見えにくい形で脆弱であるという点です。このループは位置を0, 1, 2, …と数え、それを.locを使ってラベルとして検索しています。これがうまく動くのは、位置とラベルがたまたま同じ数値である間だけです。第5章のように、まず表をフィルタリングしてnorth支店の注文だけに絞り込むと、両者はもはや一致しなくなります。
north = orders[orders["branch"] == "north"]
print(north.index.tolist())
revenues = []
for i in range(len(north)):
revenues.append(north.loc[i, "quantity"] * north.loc[i, "price"])[0, 2, 4, 7]
KeyError: 1north支店の行は元のラベル0, 2, 4, 7をそのまま保持しています。ループはラベル1(south支店の注文であり、northには含まれていない行)を要求するため、クラッシュしてしまいます。さらに厄介なバグになると、クラッシュすら起こさず、警告もなく間違った行を読み込んでしまうことさえあります。
pandasには、これとはまったく異なる考え方があります。列を1行ずつ計算するのではなく、列全体に対して1回でまとめて計算するというアプローチです。この章で学ぶのはまさにその考え方であり、一度腑に落ちれば、店のオーナーから頼まれたどの列もたった1行で書けるようになります。
この章を終える頃には
- 列全体の算術演算から新しい列を作成し、なぜループが不要なのかを説明できるようになる
Seriesを代入したときにpandasがインデックスによって値をどのように位置合わせするかを予測し、ズレによって生じるNaNを防げるようになるassign()を使って元の表を変更せずに列を追加できるようになるnp.whereやnp.selectを使って、行ごとに2つまたはそれ以上の値から選択できるようになる.map()で値を検索・置換し、pd.cutで数値を範囲ごとに分類し、.strや.dtでテキストや日付から部分データを取り出せるようになるgroupby(...).transform(...)を使って各行とそのグループを比較できるようになるapply(axis=1)を使うのが妥当な場面を判断し、なぜそれが最後の手段であるかを理解できるようになる- pandas 3におけるCopy-on-Writeが、フィルタリングされた表への列の追加に対して何を意味するのかを説明できるようになる
前提条件: グループ化と集計。
まずファイルを作る
この章のすべての例では、第4章から使っているのと同じorders.csvを読み込みます。まだ手元にない場合は、プロジェクトフォルダ内にまったく同じ内容でこのファイルを作成してください。
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一部の例では、pandasと一緒にインストールされたNumPyも使用します。import numpy as npと書くだけで準備完了です。
コードを書く前に
新しい列を作成することは、小さなプログラムを1つ書くようなものです。5分間の計画を立てておけば、「このNaNは一体どこから出てきたんだ?」と1時間も悩まずに済みます。
1. 列ごとに、やりたいことを1文で言葉にする。 店のオーナーから頼まれた各列を、計画の1行ずつに落とし込みます。
revenue—— その注文でいくらの売上があったか。quantity×priceから作成。size—— 売上が500以上なら"big"、そうでなければ"small"。revenueから作成。manager—— 注文があった支店の責任者は誰か。branchから対応表(ルックアップテーブル)を使って作成。weekday—— 注文が行われた曜日。dateから作成。branch_share—— その支店の売上合計に対する、この注文の売上の割合。revenueとbranchから作成。
すべてに共通する1つの点に注目してください。それは、1行につき1つの値があるということです。新しい列は、常に表とまったく同じ長さになります。これが第8章との違いです。第8章のgroupby(...).sum()ではグループごとに1つの値が得られました。計算しようとしている値の個数が表の行数より少ない場合、それはまだ列ではありません。
2. 手をつける前に入力データを確認する。 使えるツールは、処理の起点となる列のデータ型によって決まります。
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: object8行あります。この行数は処理が終わった後も8行のままでなければなりません。quantityはint64、priceはfloat64なので、これらを掛け合わせれば本物の数値計算が行われます。dateはstr —— 日付のように見えるテキストです。テキストには曜日という概念がないため、.dtを使う前に変換が必要になります。いまこのメモを残しておくことが、あとで発生するAttributeErrorを防ぐことにつながります。
3. 出力結果の見取り図を描き、1行分を手計算しておく。 コードを実行する前に、典型的な行と極端な行の2つを選び、どのような値になるべきかを決めておきます。
product quantity price revenue size
pen 12 15.0 180.0 small
bag 2 850.0 1700.0 big12 × 15.0 = 180.0であり、180は500未満なのでsmallです。手計算した行はテストの役割を果たします。コードを実行した後、0行目が180.0とsmallになっていなければ、どれほどそれらしく見えるコードであっても間違っていると分かります。
4. ルールの形式に合ったツールを選択する。 ほぼすべての新しい列は、以下のいずれかに当てはまります。
- 列同士や数値との算術演算 → 列全体に対する
+ - * /。例:quantity * price - 条件に応じた2つの値のいずれか →
np.where。例:"big"または"small" - 順番に評価される条件に応じた複数の値のいずれか →
np.select。例:"bulk"、"normal"、"few" - 固定の対応表からの値の検索・置換 →
.map(dict)。例: 支店名 → マネージャー名 - 数値がどの範囲に入るか →
pd.cut。例: 数量 → 区分(バンド) - テキストの一部 →
.strメソッド群。例: カテゴリの先頭数文字 - 日付の一部 →
pd.to_datetimeの後に.dt。例: 曜日 - その行自身のグループとの比較 →
groupby(...).transform(...)。例: 支店合計に対する割合 - 上記のいずれにも当てはまらない →
apply(axis=1)。例: 他の手法では扱えないほど不規則なルール
このリストを上から順に見ていき、最初に当てはまった行の手法を採用してください。この順序には意図があります。最初の8つは列全体を一度に処理しますが、最後の1つは行ごとに1回ずつPythonを呼び出す処理に逆戻りするからです。
5. 処理後に何を確認するかを決めておく。 毎回行うべきチェックは3つあります。行数が変わっていないこと、新しい列が期待どおりの型になっていること、そしてその列のisna().sum()が0(または理由を説明できる正確な個数)であることです。これに加えて、手計算した行も照合します。
列全体の算術演算
pandas流のやり方で売上(revenue)列を作成すると、次のようになります。
import pandas as pd
orders = pd.read_csv("orders.csv")
orders["revenue"] = orders["quantity"] * orders["price"]
print(orders[["product", "quantity", "price", "revenue"]])product quantity price revenue
0 pen 12 15.0 180.0
1 notebook 5 60.0 300.0
2 bag 2 850.0 1700.0
3 bottle 7 120.0 840.0
4 eraser 30 8.0 240.0
5 bag 1 850.0 850.0
6 pen 20 15.0 300.0
7 bottle 4 120.0 480.00行目には、手計算した値である180.0が表示されています。ループも、インデックスの計算もありません。
なぜこれで動くのか。 orders["quantity"]は8個の数値からなるSeriesであり、orders["price"]も同様です。2つのSeriesの間に*を置くと、pandasはインデックスのラベルによって値同士を対応付け(ラベル0とラベル0、ラベル1とラベル1など)、それぞれのペアを掛け合わせます。内部的にはループ処理が行われていますが、Pythonではなくpandasのコンパイル済みコードの中で実行されます。そのため非常に高速であり、ズレの原因となるカウンタ変数も存在しないため、間違った行を読み込んでしまう心配もありません。
そしてorders["revenue"] = ...は、列名に応じて次の2つのいずれかを行います。
revenueが存在しない場合、一番右端に新しい列を追加します。- すでに存在する場合、警告なく静かに上書きします。「本当に上書きしますか?」という確認はありません。
計算結果の型は算術演算の規則に従います。int64 × float64はfloat64になるため、revenueには180ではなく180.0と表示されます。
列と単一の数値を組み合わせることもできます。その数値はすべての行に対して適用されます——pandasではこれをブロードキャスト(broadcasting)と呼びます。
orders["price_with_vat"] = (orders["price"] * 1.15).round(2)
print(orders[["product", "price", "price_with_vat"]].head(4))product price price_with_vat
0 pen 15.0 17.25
1 notebook 60.0 69.00
2 bag 850.0 977.50
3 bottle 120.0 138.00丸括弧が重要です。掛け算の結果全体(これもまたSeriesです)に対して.round(2)が適用されます。ここでの各ステップは列を受け取って列を返すため、メソッドチェーンをそのままつなげていくことができます。
どの値がどこに入るかはインデックスが決める
ラベルによる対応付けは、算術演算だけのものではありません。Seriesを列に代入するときも、ラベルによって位置合わせが行われます。普段は新しい値が同じ表から作られ、ラベルが完全に一致しているため、この仕組みを意識することはほとんどありません。しかし、一致しないデータに出くわしたとき、その存在を強く実感することになります。
例として、north支店の注文だけの数量を保持する列を作りたいとしましょう。
north = orders[orders["branch"] == "north"]
orders["north_qty"] = north["quantity"]
print(orders[["branch", "quantity", "north_qty"]])branch quantity north_qty
0 north 12 12.0
1 south 5 NaN
2 north 2 2.0
3 east 7 NaN
4 north 30 30.0
5 south 1 NaN
6 east 20 NaN
7 north 4 4.0northにはラベル0, 2, 4, 7があります。pandasは同じラベルを持つ行にそれぞれの値を配置し、それ以外のすべての行にはNaN(「このラベルには値がない」)を割り当てました。そしてNaNは浮動小数点数(float)であるため、数量が本来整数であるにもかかわらず、列全体の型はfloat64に変わりました。これがまさにやりたかったことである場合もあれば、自分で意図せず作り出してしまった欠損値を探す長い旅の始まりになる場合もあります。どちらなのかを判断するには、代入は位置ではなくラベルで一致させるという規則を理解しておくことが不可欠です。
この同じ規則は、ミスから身を守ってもくれます。第7章で学んだソートはSeriesの並び順を変えますが、それぞれの値とラベルの結びつきは維持されます。そのため、ソートされたSeriesを表に代入し直しても、すべての値は元の正しい行に戻ります。
by_revenue = orders["revenue"].sort_values()
print(by_revenue.head(3))
orders["revenue_copy"] = by_revenue
print(orders[["product", "revenue", "revenue_copy"]].head(3))
print((orders["revenue_copy"] == orders["revenue"]).all())0 180.0
4 240.0
6 300.0
Name: revenue, dtype: float64
product revenue revenue_copy
0 pen 180.0 180.0
1 notebook 300.0 300.0
2 bag 1700.0 1700.0
TrueソートされたSeriesはラベル0、4、6の順に並んでいます——2番目の値である240.0は、1行目ではなく4行目のものです。それにもかかわらず、revenue_copyは1行ずつrevenueと完全に一致しており、最後の行の出力ですべての8行が一致していることが確認できます。pandasは並び順を無視してラベルを使用したのです。もし位置順に処理されていたら、1行目(ノート、300.0)に240.0が入ってしまっていたはずです。
通常のPythonのリストにはラベルがないため、位置順で使用されます——その場合、長さが厳密に一致していなければなりません。
try:
orders["rating"] = [5, 4, 3]
except ValueError as error:
print("ValueError:", error)ValueError: Length of values (3) does not match length of index (8)8行に対して3つの値を渡すと、適当に推測して埋めるのではなく、明確に拒否されます。これは極めて安全な動作であり、メッセージに見覚えをつけておくためにも一度見ておく価値があります。
実験用に作った列はもう不要です。drop(columns=...)を実行すると、それらの列を除いた表が返されます。
orders = orders.drop(columns=["north_qty", "revenue_copy", "price_with_vat"])
print(list(orders.columns))['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'revenue']assign():元の表を変更せずに新しい列を追加する
orders["revenue"] = ...と書くと、ordersそのものが変更されます。多くの場合、それは望みどおりの挙動です。しかし、そうしたくない場合もあります——いくつかの列を試しに作ってみたい場合や、レポート用の表を作成しつつ、読み込んだ元の表をそのまま残しておきたい場合などです。assign()は、列を追加した新しいDataFrameを返し、元の表には一切手を加えません。
import numpy as np
import pandas as pd
orders = pd.read_csv("orders.csv")
report = orders.assign(
revenue=orders["quantity"] * orders["price"],
vat=lambda d: (d["revenue"] * 0.15).round(2),
)
print(report[["product", "revenue", "vat"]].head(3))
print("revenue" in orders.columns)product revenue vat
0 pen 180.0 27.0
1 notebook 300.0 45.0
2 bag 1700.0 255.0
False注目すべき点が3つあります(先頭のimport numpy as npは次のセクションで使うためのものです)。
第1に、引数名がそのまま列名になります——revenue=...と指定するとrevenueという列が作成されます。そのため、assign()を使うときは列名がPythonの有効な変数名でなければなりません。
第2に、vatはlambda(無名関数)として書かれています。そう書く必要があるからです。vatはrevenueを必要としますが、revenueは元のordersには存在せず、新しく構築されている表の中にしか存在しません。lambda d: ...と書くことで、構築中の新しい表がdとして渡されるため、d["revenue"]を問題なく参照できます。1回のassign()の中で、列は上から下に向かって順番に追加されていくため、各lambdaはその上にある列を参照できます。
そして第3に、最後の行でFalseが出力されている点です。ordersにはrevenue列が追加されていません。知らないところで勝手に変更されることは一切ありませんでした。
np.where:2つの値のいずれか
「売上が500以上ならbig、そうでなければsmall」という処理は、各行に対するif/elseです。通常のif文を書こうとすると、またループ処理に戻ってしまいます。if文は単一のTrueまたはFalseを必要としますが、列全体を比較すると8個の結果が返されるためです(これは第5章で登場した「truth value of a Series is ambiguous」エラーの原因です)。
NumPyのwhereは、列全体に対して機能するif/elseです。条件がTrueである箇所(where)では1番目の値を採用し、それ以外の箇所では2番目の値を採用します。
report["size"] = np.where(report["revenue"] >= 500, "big", "small")
print(report[["product", "revenue", "size"]])product revenue size
0 pen 180.0 small
1 notebook 300.0 small
2 bag 1700.0 big
3 bottle 840.0 big
4 eraser 240.0 small
5 bag 850.0 big
6 pen 300.0 small
7 bottle 480.0 small0行目はsmall、2行目はbigとなっており、どちらも見取り図と一致しています。条件式は第5章で作成したブールマスクと同じものであり、np.whereはそれぞれのTrue/Falseを特定の値に変換しているだけです。境界値の確認も重要です。>= 500という条件は、売上がちょうど500の注文をbigとして扱うことを意味します。偶然そうなったのではなく、意図してそのように決定してください。
3つ以上の値:np.select
数量に応じて3つの区分に分ける場合を考えます——20個以上なら"bulk"、5個以上なら"normal"、それ以外は"few"とします。np.selectは、条件のリスト、対応する値のリスト、そしていずれの条件にも一致しない行に対するdefault値を受け取ります。
conditions = [
report["quantity"] >= 20,
report["quantity"] >= 5,
]
labels = ["bulk", "normal"]
report["tier"] = np.select(conditions, labels, default="few")
print(report[["product", "quantity", "tier"]])product quantity tier
0 pen 12 normal
1 notebook 5 normal
2 bag 2 few
3 bottle 7 normal
4 eraser 30 bulk
5 bag 1 few
6 pen 20 bulk
7 bottle 4 few最初にTrueになった条件が優先されます。 そのため、最も厳しい条件を先頭に書く必要があります。順序を入れ替えると、数量30の消しゴムの注文がどうなるかを確認してみましょう。
wrong = np.select(
[report["quantity"] >= 5, report["quantity"] >= 20],
["normal", "bulk"],
default="few",
)
print(wrong)['normal' 'normal' 'few' 'normal' 'normal' 'few' 'normal' 'few']すべての注文がnormalまたはfewになり、bulkが1つも存在しなくなりました。30も20も>= 5を満たしており、その条件が先にチェックされてしまったためです。エラーは出ず、単に間違った列が作られてしまいます——計画を立てる際の手計算の行に、極端な値を含めておくべき理由がここにあります。
.map():値を対応付ける(ルックアップ)
マネージャーは、何かを計算して求めるものではありません。すでに分かっている事実です——Ashaがnorth支店、Omarがsouth支店、Linaがeast支店を担当しています。ルールが「表から探す(ルックアップ)」である場合は、対応表を辞書として定義し、その辞書を通して列に.map()を適用します。
manager_of = {
"north": "Asha",
"south": "Omar",
"east": "Lina",
}
report["manager"] = report["branch"].map(manager_of)
print(report[["branch", "manager"]])
print(report["manager"].isna().sum())branch manager
0 north Asha
1 south Omar
2 north Asha
3 east Lina
4 north Asha
5 south Omar
6 east Lina
7 north Asha
0各支店名が辞書内の対応する値、つまりマネージャーの名前に置き換わりました。2つ目の出力は計画段階で決めておいたチェック項目ですが、これは見た目以上に重要です。キーが見つからない場合に.map()がどう動作するかによるものです。east支店がオープンする前に辞書が作成されていたと想像してみてください。
old_managers = {"north": "Asha", "south": "Omar"}
print(report["branch"].map(old_managers).isna().sum())2エラーは発生しません。east支店の2件の注文には、単にNaNが入っただけです。8行のデータならすぐに気付けますが、8万行のデータでは見落としてしまうでしょう。そしてその後のgroupby("manager")では、それらの注文が何も言われずに集計から除外されてしまいます(第8章で学んだとおり、NaNのキーは破棄されます)。.map()を使った後は、必ず直後にisna().sum()で確認してください。
pd.cut:数値がどの範囲に入るか
店は注文を数量ごとに区分(バンド)分けしたいと考えています——5個まではsmall、6個から15個まではmedium、15個を超えるものはlargeとします。これはnp.selectでも書けますが、「どの範囲に入るか」という処理には専用のツールがあります。pd.cutは、範囲の境界値(bins)と各範囲のラベルを受け取ります。
report["band"] = pd.cut(
report["quantity"],
bins=[0, 5, 15, np.inf],
labels=["small", "medium", "large"],
)
print(report[["product", "quantity", "band"]])product quantity band
0 pen 12 medium
1 notebook 5 small
2 bag 2 small
3 bottle 7 medium
4 eraser 30 large
5 bag 1 small
6 pen 20 large
7 bottle 4 small4つの境界値によって3つの範囲が作られます:(0, 5]、(5, 15]、そして(15, ∞]です。丸括弧は「その値を含まない」、角括弧は「その値を含む」を意味します——つまり、各範囲はその右端の値を含みます。数量がちょうど5であるノートの注文が、mediumではなくsmallになるのはそのためです。np.infは無限大を表し、どんな数量であってもこれを超えることはありません。
この処理の結果は、まだ見たことのないデータ型になります。
print(report["band"].dtype)
print(report["band"].cat.categories.tolist())category
['small', 'medium', 'large']category(カテゴリ型)の列は、small < medium < largeという順序関係を記憶しています。そのため、bandでソートすると、アルファベット順ではなくこの順序で並び替わります——レポートで通常求められるのはまさにこの順序です。
テキストと日付:.strと.dt
テキストの一部
第5章では、フィルタリングのために.str.containsを使用しました。列を作成する際にも、まったく同じ.strアクセサが役立ちます。Pythonでよく知られているすべての文字列メソッドを、すべての値に対して一度に適用できます。例えば、各注文にSTA-1001のような識別コード(カテゴリの先頭3文字の大文字、ハイフン、注文番号)を付けたいとしましょう。
report["code"] = (
report["category"].str[:3].str.upper()
+ "-"
+ report["order_id"].astype(str)
)
print(report[["category", "order_id", "code"]].head(3))category order_id code
0 stationery 1001 STA-1001
1 stationery 1002 STA-1002
2 accessories 1003 ACC-1003.str[:3]はすべての値をスライスし、.str.upper()はすべての値を大文字に変換し、テキスト列の間の+はそれらを行ごとにつなぎ合わせます。order_idは数値であり、数値をテキストに直接足し合わせることはできないため、まず.astype(str)でテキストに変換しています。
日付の一部
計画の段階で、dateがstr型であることに気付いていました。pd.to_datetimeで変換すれば、.dtアクセサが使えるようになります。
report["date"] = pd.to_datetime(report["date"])
print(report["date"].dtype)
report["weekday"] = report["date"].dt.day_name()
print(report[["date", "weekday"]].head(4))datetime64[us]
date weekday
0 2024-03-01 Friday
1 2024-03-01 Friday
2 2024-03-02 Saturday
3 2024-03-02 Saturdayこれで列はdatetime64型になりました——テキストではなく、本物の日付データです([us]はpandas 3が選択した精度を表しており、気にしなくて構いません)。本物の日付型になれば、.dtを使って.dt.day_name()、.dt.month、.dt.year、.dt.dayなどを取得できます。そしてこの変換を一度だけ行い、date列に代入し直しておけば、これ以降のすべての処理で文字列ではなく日付として扱えるようになります。
transform:行とグループの比較
最後の要望である「各注文の支店売上に対する割合」を計算するには、同じ行に2つの情報が必要です。その注文自身の売上と、その注文が属する支店の合計売上です。第8章では、次のようにして合計を求めました。
print(report.groupby("branch")["revenue"].sum())branch
east 1140.0
north 2600.0
south 1150.0
Name: revenue, dtype: float64しかし、これは支店ごとに1つの合計値、つまり3つの値であり、表には8つの行があります。8個の数値を3個の数値でそのまま割ることはできません。ここで本当に必要なのは、その支店のすべての行に繰り返し配置された支店合計です。それこそが、transformが返してくれるものです。
branch_total = report.groupby("branch")["revenue"].transform("sum")
print(branch_total)0 2600.0
1 1150.0
2 2600.0
3 1140.0
4 2600.0
5 1150.0
6 1140.0
7 2600.0
Name: revenue, dtype: float64同じグループ化、同じ"sum"ですが、結果は表自身のラベルを持つ8個の値になります——north支店のすべての行には2600が入り、south支店のすべての行には1150が入っています。これがあれば、あとは通常の列の割り算です。
report["branch_share"] = (report["revenue"] / branch_total).round(3)
print(report[["branch", "product", "revenue", "branch_share"]])branch product revenue branch_share
0 north pen 180.0 0.069
1 south notebook 300.0 0.261
2 north bag 1700.0 0.654
3 east bottle 840.0 0.737
4 north eraser 240.0 0.092
5 south bag 850.0 0.739
6 east pen 300.0 0.263
7 north bottle 480.0 0.185検算してみましょう。north支店のバッグの注文は1700 / 2600 = 0.654です。また、各支店内の割合を合計すると、必ず1にならなければなりません。
print(report.groupby("branch")["branch_share"].sum())branch
east 1.0
north 1.0
south 1.0
Name: branch_share, dtype: float64覚えておくべき規則は次のとおりです。aggやsumはグループを1行に縮約しますが、transformは元のすべての行に対して1つずつの値を返します。 集計表を作りたいときは前者、新しい列を作りたいときは後者を使用してください。
apply(axis=1):最後の手段
ときには、ルールが本当に不規則で複雑なこともあります。例えば配送料のルールが次のようになっているとしましょう。north支店(倉庫の隣にある本店)では売上が500以上なら無料、そうでなければ60。それ以外の支店では120ですが、売上が1000を超えれば無料になります。このようなルールは、通常のPython関数として記述し、各行に対してapplyを適用することができます。
def delivery_fee(row):
if row["branch"] == "north":
return 0 if row["revenue"] >= 500 else 60
return 0 if row["revenue"] > 1000 else 120
report["fee"] = report.apply(delivery_fee, axis=1)
print(report[["branch", "revenue", "fee"]])branch revenue fee
0 north 180.0 60
1 south 300.0 120
2 north 1700.0 0
3 east 840.0 120
4 north 240.0 60
5 south 850.0 120
6 east 300.0 120
7 north 480.0 60axis=1は「関数に1度に1つの行を渡す」ことを意味します。関数内部では、rowは列名をラベルとして持つSeriesになるため、row["branch"]のように列の値を取り出すことができます。
読みやすさは抜群であり、それこそがこの手法の唯一最大の魅力です。しかしその代償として、この章の冒頭で見たループ処理に逆戻りしてしまいます。pandasは行ごとにSeriesを構築し、行ごとに1回ずつPython関数を呼び出します。8行であれば一瞬ですが、100万行であれば瞬きほどの時間とコーヒー休憩ほどの時間の差になります。
ですから、applyに手を伸ばす前に、列全体を扱うツールでそのルールを表現できないかを検討してください。このルールは実際には「順番に評価される3つのケース」にすぎず、np.selectで書くことができます。
in_north = report["branch"] == "north"
fee_fast = np.select(
[in_north & (report["revenue"] >= 500), in_north, report["revenue"] > 1000],
[0, 60, 0],
default=120,
)
print((fee_fast == report["fee"]).all())True結果はまったく同じですが、行ごとのPython呼び出しは一切発生しません。外部の関数を呼び出す必要がある場合や、複数ステップにわたる複雑な文字列のパース処理など、どうしてもこの方法で書けないルールにのみapply(axis=1)を残しておきましょう。そして、applyを使おうとしたときは、「本当にもう一度考え直せないか?」というサインだと捉えてください。
フィルタリングされた表への列の追加:Copy-on-Write
まずフィルタリングを行ってから、その結果に列を追加するという処理はよく行われます。pandas 3ではこの処理が非常にシンプルになっています。古いチュートリアルには異なる説明が書かれていることが多いため、なぜシンプルになったのかを知っておく価値があります。
import pandas as pd
orders = pd.read_csv("orders.csv")
orders["revenue"] = orders["quantity"] * orders["price"]
north = orders[orders["branch"] == "north"]
north["revenue_k"] = north["revenue"] / 1000
print(north[["product", "revenue", "revenue_k"]])
print(list(orders.columns))product revenue revenue_k
0 pen 180.0 0.18
2 bag 1700.0 1.70
4 eraser 240.0 0.24
7 bottle 480.0 0.48
['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'revenue']警告は一切出ず、ordersにrevenue_k列が勝手に追加されることもありませんでした。pandas 3ではCopy-on-Writeが採用されています。フィルタリングや選択操作の結果はすべて、完全に独立した独自の表として振る舞います。northを変更しても、ordersが変更されることは絶対にありません。
古いコードやインターネット上の回答を見ると、これとまったく同じパターンの後にSettingWithCopyWarning(「A value is trying to be set on a copy of a slice from a DataFrame」)が表示され、orders[...].copy()と書くようにアドバイスされているのを目にするでしょう。その警告が存在したのは、古いバージョンのpandasでは2つの表の間でデータが共有される場合とされない場合があり、それがいつ起きるのかを簡単に見分けることができなかったためです。pandas 3ではその曖昧さが排除され、警告もなくなりました。今では.copy()を書く必要はありません(書いても害はありませんが不要です)。
ただし、Copy-on-Writeによってできなくなったことがあります。それは、2つのステップを連結してordersを変更することです。一部の行について元の表の値を変更したい場合、以下のような書き方をしても何も変わりません。
orders[orders["branch"] == "north"]["revenue"] = 0
print(orders["revenue"].tolist())[180.0, 300.0, 1700.0, 840.0, 240.0, 850.0, 300.0, 480.0]
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment.
Such chained assignment never works to update the original DataFrame or Series, because the intermediate object on which we are setting values always behaves as a copy (due to Copy-on-Write).
Try using '.loc[row_indexer, col_indexer] = value' instead, to perform the assignment in a single step.
See the documentation for a more detailed explanation: https://pandas.pydata.org/pandas-docs/stable/user_guide/copy_on_write.html#chained-assignment1つ目の角括弧で新しい表が作られ、2つ目の角括弧でその新しい表の中に値が設定され、そしてその新しい表はそのまま破棄されます。pandasはChainedAssignmentErrorで警告を発し、出力されたリストを見てもrevenueが変わっていないことが分かります——north支店の注文は依然として180.0、1700.0、240.0、480.0のままです(警告は標準エラー出力に出るため、ターミナル環境によってはリストの上に出力されることもあります)。元の表を変更したいときは、.loc[rows, column]を使って1つのステップで実行してください。
orders.loc[orders["branch"] == "north", "note"] = "main branch"
print(orders[["branch", "note"]])branch note
0 north main branch
1 south NaN
2 north main branch
3 east NaN
4 north main branch
5 south NaN
6 east NaN
7 north main branch条件に一致した行に値が設定されます。必要であれば列が自動的に作成され、一致しなかった行にはNaNが入ります。元の表に対する1ステップの処理であり、警告も一切出ません。
完全な例
enrich.pyは、計画(読み込み、確認、列の追加、再確認)に沿って、店のオーナーからの要望のすべてに答えます。
import numpy as np
import pandas as pd
MANAGER_OF = {
"north": "Asha",
"south": "Omar",
"east": "Lina",
}
orders = pd.read_csv("orders.csv")
rows_before = len(orders)
# 1. Conversions first, so later steps get the right types.
orders["date"] = pd.to_datetime(orders["date"])
# 2. New columns, one rule each.
orders["revenue"] = orders["quantity"] * orders["price"]
orders["size"] = np.where(orders["revenue"] >= 500, "big", "small")
orders["manager"] = orders["branch"].map(MANAGER_OF)
orders["weekday"] = orders["date"].dt.day_name()
branch_total = orders.groupby("branch")["revenue"].transform("sum")
orders["branch_share"] = (orders["revenue"] / branch_total).round(3)
# 3. Checks: same rows, nothing missing, the hand-worked row is right.
new_columns = ["revenue", "size", "manager", "weekday", "branch_share"]
assert len(orders) == rows_before
assert orders[new_columns].isna().sum().sum() == 0
assert orders.loc[0, "revenue"] == 180.0
print(orders[["order_id", "branch", "manager", "weekday", "revenue", "size", "branch_share"]])
print()
print(orders[new_columns].dtypes)order_id branch manager weekday revenue size branch_share
0 1001 north Asha Friday 180.0 small 0.069
1 1002 south Omar Friday 300.0 small 0.261
2 1003 north Asha Saturday 1700.0 big 0.654
3 1004 east Lina Saturday 840.0 big 0.737
4 1005 north Asha Sunday 240.0 small 0.092
5 1006 south Omar Sunday 850.0 big 0.739
6 1007 east Lina Monday 300.0 small 0.263
7 1008 north Asha Monday 480.0 small 0.185
revenue float64
size str
manager str
weekday str
branch_share float64
dtype: objectこのように書かれている理由:
- 変換を先頭で行っている。 何かが
dateを利用する前に本物の日付型へと変換しているため、あとで.dtがエラーになる心配がなく、後続の処理を書く人が変換を覚えておく必要もありません。 - 1行につき1つのルール。 各行が計画の1行ずつに対応しているため、数値がおかしいと感じたときにどの1行がその原因なのかを特定しやすくなっています。
- 対応表を先頭の名前付き定数として定義している。 west支店が新設された場合も、誰の目にも明らかな場所にある1行を変更するだけで済み、もし追加を忘れても
isnaのチェックによって明確にエラーで気付くことができます。 - チェックに
print文ではなくassertを使っている。 行数が変化したり、.map()が支店を見落としたり、0行目の値が180でなくなったりした場合、一見正常に見える間違ったレポートを出力するのではなく、プログラムを即座に停止させます。 - 最後に
dtypesを出力している。 データ型も結果の重要な一部だからです。revenueやbranch_shareは浮動小数点数(float)になり、テキスト列はstrになっている必要があります。
問題が起きたとき
KeyError: 'Price' 右辺の列名につづりの間違いがあるか、大文字と小文字が間違っています。list(orders.columns)を出力し、そこから正しい名前をコピーしてください。左辺にも注意が必要です。orders["revenu"] = ...と書いてもエラーは発生せず、意図した列の隣につづりを間違えた新しい列が作られてしまいます。「更新した」はずの列が変わっていない場合は、タイポによるよく似た名前の列ができていないか確認してください。
ValueError: Length of values (3) does not match length of index (8) 行数と一致しない長さのリスト(または配列)を代入しました。リストにはラベルがないため、pandasは位置を揃えることができず、処理を拒否します。表自体の列から値を生成するか、代入する前にlen(values) == len(df)を確認してください。
新しい列の一部の行がNaNだらけになり、型がfloat64に変わってしまった 表のラベルと部分的にしか一致しないSeriesを代入しました(多くの場合、フィルタリングされた表から作成したSeriesを代入した場合に発生します)。代入は位置ではなくラベルで一致させます。部分集合から作るのではなく、np.whereや.loc[mask, "col"] = ...などを使って表全体から新しい列を作成してください。
revenueに15.015.015.0…のような値が入っている、またはTypeError: can't multiply sequence by non-int of type 'float'が発生する いずれかの列が数値ではなくテキストになっています。intと文字列を掛けると、文字列が繰り返されます(Pythonの3 * "ab" == "ababab"という規則です)。pandasは何の文句も言わずにこれを行ごとに適用してしまいます。
import pandas as pd
from io import StringIO
RAW = """product,quantity,price
pen,12,15.0
bag,2,"1,200"
"""
bad = pd.read_csv(StringIO(RAW))
print(bad.dtypes)
print(bad["quantity"] * bad["price"])product str
quantity int64
price str
dtype: object
0 15.015.015.015.015.015.015.015.015.015.015.015.0
1 1,2001,200
dtype: str"1,200"に含まれる桁区切りのカンマによって、price列全体がstr型になってしまっていました。これこそが、算術演算を行う前にdtypesを確認すべきだと計画で述べた理由です。読み込み時にthousands=","を指定して修正するか、pd.to_numericを使って変換してください。
good = pd.read_csv(StringIO(RAW), thousands=",")
print(good.dtypes["price"])
print(good["quantity"] * good["price"])float64
0 180.0
1 2400.0
dtype: float64AttributeError: Can only use .dt accessor with datetimelike values 列がまだテキストのままです。先にdf["date"] = pd.to_datetime(df["date"])で変換してから、.dtを使用してください。
applyから発生するKeyError: 'quantity' axis=1の指定を忘れています。これを指定しないと、applyは関数に列全体を渡すため、row["quantity"]は列の中からquantityというラベルの行を探そうとして失敗します。axis=1を追加してください——そして、そもそも本当にapplyが必要なのかどうかを自分に問いかけてみてください。
IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer NaNを含む列に対して.astype(int)を呼び出しました。NumPyの整数型には「欠損」を保持する仕組みがありません。第6章で学んだように先に欠損値を埋めるか、欠損値を保持できるpandasのNull許容整数型(大文字のIを使った.astype("Int64"))を使用してください。
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. df[mask]["col"] = valueのように記述しました。Copy-on-Writeでは最初のステップで独立した表が作成されるため、元の表は決して変更されません。df.loc[mask, "col"] = valueのように1つのステップで記述してください。
.map()を使った列で、値が入るはずの場所にNaNがある 列内のキーが辞書に存在しません——新しい支店、異なるつづり、末尾の空白などが原因です。df.loc[df["new"].isna(), "key_column"].unique()で該当する値を見つけ出し、辞書または元データを修正してください。
ステップ 4 / 6 — 予想する
理解度をチェック
north支店の注文から作成した列を表全体に代入しました。何が出力されますか?
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))
north = orders[orders["branch"] == "north"]
orders["north_qty"] = north["quantity"]
print(orders["north_qty"].isna().sum(), orders["north_qty"].dtype)- A4 int64
- B0 int64
- C0 float64
- D4 float64
ordersにrevenue列を追加することが目的です。何が出力されますか?
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))
orders.assign(revenue=orders["quantity"] * orders["price"])
print("revenue" in orders.columns)- A`False` —— `assign`は新しい表を返し、`orders`は変更されず、結果が保持されなかったため
- B`KeyError: 'revenue'`が発生する
- C`ChainedAssignmentError`が発生する
- D`True`
各注文の支店合計数量に対する割合を、すべての行に保持する列が必要です。1行につき1つの値が得られるコード行はどれですか?
- Aorders["quantity"] / orders.groupby("branch")["quantity"].sum()
- Borders["quantity"] / orders.groupby("branch")["quantity"].transform("sum")
- Corders.groupby("branch")["quantity"].sum() / orders["quantity"]
- Dorders["quantity"] / orders["quantity"].sum()
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
これまでと同じorders.csvを使って、店のオーナーに割引レポートを提供するenrich_task.pyを作成してください。まず計画を立て(新しい列のリストを書き出し、0行目の値を手計算する)、その後に以下の列をこの順番で追加します。
revenue=quantity×pricediscount=quantityが10以上の場合はrevenueの10%、それ以外は0net=revenue−discountmanager—— この章で使った辞書を用いて、支店名から取得band——netの値に応じて:300以下は"low"、300超800以下は"mid"、800超は"high"weekday——dateから取得category_share—— 各注文のnetが、そのカテゴリのnet合計に占める割合(小数第2位に四捨五入)
処理前と処理後のshapeを出力し、その後order_id、product、quantity、net、band、weekday、category_shareを、netの降順(大きい順)でソートして出力してください。
プログラムが正しいと言える基準は以下のとおりです。
- 後述の期待される出力と完全に一致する出力が得られること。
- コードのどこにも
forループやapplyが存在せず、すべての列が列全体の演算によって一度に作成されていること。 - 辞書に存在しない支店がある場合、
NaNのまま処理を続けるのではなく、AssertionErrorでプログラムが停止すること。 - 処理前と処理後の両方で行数が8行であること。
期待される出力:
before: (8, 7)
after: (8, 14)
order_id product quantity net band weekday category_share
2 1003 bag 2 1700.0 high Saturday 0.44
5 1006 bag 1 850.0 high Sunday 0.22
3 1004 bottle 7 840.0 high Saturday 0.22
7 1008 bottle 4 480.0 mid Monday 0.12
1 1002 notebook 5 300.0 low Friday 0.32
6 1007 pen 20 270.0 low Monday 0.28
4 1005 eraser 30 216.0 low Sunday 0.23
0 1001 pen 12 162.0 low Friday 0.17実行する前に予測してみてください。どの注文のnetが最も高くなり、数量30の消しゴムの注文はどの区分(band)に入るでしょうか? その後、実際の出力と照合してください。
解答
計画。 手計算による0行目:ペンの注文は12 × 15.0 = 180.0です。数量が12なので割引額は18.0、netは162.0になります。162は300以下なのでlowです。消しゴムの注文:30 × 8.0 = 240.0、割引額24.0、netは216.0——数量としては最大の注文ですが、これもlowになります。最も高いnetになるのはバッグ2個の注文のはずです(1700.0、割引なし)。
revenueとnet—— 列全体の算術演算:単純な掛け算と引き算。discount——np.where:行ごとに2つの値から1つを選択。manager——.map(dict)の後にisna().sum()を実行:対応表による検索・置換と検証。band—— 境界値[0, 300, 800, inf]を指定したpd.cut:範囲による分類。各範囲は右端を含むため、ちょうど300はlow、ちょうど800はmidになります。weekday——pd.to_datetimeの後に.dt.day_name():日付の各要素を取得するには本物の日付型が必要。category_share——groupby("category")["net"].transform("sum"):グループとの比較により、行ごとに1つの値を取得。
enrich_task.py:
import numpy as np
import pandas as pd
MANAGER_OF = {
"north": "Asha",
"south": "Omar",
"east": "Lina",
}
orders = pd.read_csv("orders.csv")
print("before:", orders.shape)
orders["revenue"] = orders["quantity"] * orders["price"]
orders["discount"] = np.where(orders["quantity"] >= 10, orders["revenue"] * 0.10, 0.0)
orders["net"] = orders["revenue"] - orders["discount"]
orders["manager"] = orders["branch"].map(MANAGER_OF)
assert orders["manager"].isna().sum() == 0
orders["band"] = pd.cut(
orders["net"],
bins=[0, 300, 800, np.inf],
labels=["low", "mid", "high"],
)
orders["date"] = pd.to_datetime(orders["date"])
orders["weekday"] = orders["date"].dt.day_name()
category_total = orders.groupby("category")["net"].transform("sum")
orders["category_share"] = (orders["net"] / category_total).round(2)
print("after: ", orders.shape)
print()
columns = ["order_id", "product", "quantity", "net", "band", "weekday", "category_share"]
print(orders[columns].sort_values("net", ascending=False))before: (8, 7)
after: (8, 14)
order_id product quantity net band weekday category_share
2 1003 bag 2 1700.0 high Saturday 0.44
5 1006 bag 1 850.0 high Sunday 0.22
3 1004 bottle 7 840.0 high Saturday 0.22
7 1008 bottle 4 480.0 mid Monday 0.12
1 1002 notebook 5 300.0 low Friday 0.32
6 1007 pen 20 270.0 low Monday 0.28
4 1005 eraser 30 216.0 low Sunday 0.23
0 1001 pen 12 162.0 low Friday 0.17計画と照らし合わせて確認してみましょう。
- shapeは7列から14列になり、行数は8行のまま維持されました。7つの新しい列が追加され、行の増減はありません。
- 0行目(注文1001)の
netは162.0、区分はlowとなっており、手計算した値と一致しています。 - 最も高い
netは予測どおり注文1003(バッグ)の1700.0でした。消しゴムの注文1005は216.0でlowです。最も個数が多い注文ですが、単価が安いためです。 np.whereは「真」の場合の値として列を受け取りました(orders["revenue"] * 0.10)。これにより、単一の数値ではなく行ごとに独自の10%が計算されました。np.whereのどちらの分岐も、列または単一の値をとることができます。- 区分の境界値がはっきりと確認できます。 ノートの注文の
netはちょうど300.0であり、lowに分類されています。(0, 300]は300を含むためです。もし店のオーナーが「300以上をmidとする」という意味で言っていたのであれば、境界値を変更する必要があります——これは些細なディテールではなく、仕様上の判断です。 - カテゴリ内での割合の合計が1になっています。 accessories:
0.44 + 0.22 + 0.22 + 0.12 = 1.00。stationery:0.32 + 0.28 + 0.23 + 0.17 = 1.00。もしカテゴリごとの合計が1になっていなければ、transformでグループ化する列を間違えていたことになります。
最後に、わざと一度ずつエラーを起こしてみましょう。辞書から"east"を削除して、assertによってプログラムが停止することを確認してください。また、binsの開始位置を0ではなく200に変更して、どの行のbandがNaNになるか、そしてなぜそうなるのかを確認してみてください。