行のソート — 同順位(タイ)の扱いを意図して決める
sort_valuesを使った行の並べ替え:1列または複数列での指定、列ごとの昇順・降順の設定、欠損値や数値に見える文字列の処理、そして「上位N件」を抽出するnlargestや、境界値での同順位を偶然ではなく意図して決めるべき理由について学びます。
どうやって学びますか?
解決する問題
店のオーナーから短いメッセージが届きました。「今月最も大きかった注文はどれかな?上位3件を教えてほしい」
データはこれまでの章でおなじみのorders.csv——north、south、eastの3支店における8件の注文です。8行だけであれば、目で見て答えることもできるでしょう。quantity列を上から見て、30、次に20、そして12を見つければ完了です。
しかし、実際の業務ファイルを想像してみてください。注文が4,000件あったらどうでしょうか。列を上から順に見て最も大きい3つの数値を探すのは、もはや手法ではなく単なる希望的観測です。しかも、オーナーからの次のメッセージはすでに届きつつあります。「支店ごとにグループ化して、各支店の中で大きい順に並べたリストも送ってくれる?それから、最新の注文を一番上にしてね」
これらの要求はいずれも本質的には同じ操作です。すなわち、行を扱いやすい順序に並べ替え、上から順に読み取ることです。これがソートであり、pandasではsort_values()という1つのメソッドで行います。
しかし、メソッドの使い方自体は簡単な部分にすぎません。本当に重要な問題はその周辺にあり、レポートの誤りが発生するのもまさにそこです。
- 何をもって「最大」とするのか?数量なのか、単価なのか、それとも注文の売上金額なのか?基準ごとに上位3件は異なります。
- 2つの注文が同順位になった場合、どちらが先に来るのか——そしてコードを実行するたびにその結果が変わってしまうことはないか?
- 数量が空白になっている注文はどう扱われるのか?
- 数値の列が誤って文字列として読み込まれていた場合、警告なしに
"9"が"30"の後ろにソートされてしまいます。
この章では、メソッドの使い方だけでなく、より重要なこれらの一連の判断について学びます。
この章を終える頃には
sort_values()を使って1列または複数列で表をソートし、列ごとに昇順・降順を指定して、結果を見失わずに保持できるようになる- なぜインデックスのラベルが行と一緒に移動するのかを説明し、ラベルをそのまま残すか、
sort_index()を使うか、reset_index(drop=True)を使うかを選択できるようになる - 同順位を意図的に解決し、
na_positionを使って欠損値をどこに配置するかを決定できるようになる head()、nlargest()、nsmallest()を使って「上位N件」の問いに答えられるようになる(サブセットの上位N件や境界値での同順位の扱いを含む)key=を使って大文字・小文字を区別せずに文字列をソートできるようになり、ソート結果の並び順から文字列として保存された数値を識別できるようになる
前提条件: 欠損データ — 空白を見つけ、その意味を判断する。
まずファイルを作る
この章のすべての例題では、第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後のセクションでは、2つ目のファイルであるorders_gaps.csvを使用します。これは同じ表で注文1002と1007の2件の数量を空欄にしたもので、第6章で見つけ方を学んだ種類の欠損です。
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,,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,,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0コードを書く前に
ソートは計画を立てるまでもないほど単純に見えるかもしれません。しかし実際はそうではありません。ソート処理自体は決してエラーで失敗せず、常に何らかの順序を出力するからです。間違った基準でソートした場合でも、結果は正しい結果と同じくらい整然として見えてしまいます。そのため、コードを書く前に思考を整理しておく必要があります。
1. 列名を含めて、問いを1文で言語化する。 「最大の注文」というのは、pandasがそのまま答えられる問いではありません。「quantityが最も大きい3件の注文」であれば答えられます。同様に「priceが最も高い3件の注文」も明確です。これらは異なる答えを返します——850のバッグは最も高価な品目ですが、売れたのは2個だけです。オーナーがどちらの意味で言っているのかを判断してください。判断がつかない場合は質問しましょう。メッセージを1通送るだけで済みますし、誤った上位3件を提出して信頼を失うよりはるかにましです。
2. ソートする前に届いたデータを確認する。 ソートにおいて重要なのは3点です。行数、ソート基準にする列のデータ型、そしてそれらの列に空白(欠損)があるかどうかです。
import pandas as pd
orders = pd.read_csv("orders.csv")
print(orders.shape)
print(orders.dtypes)
print(orders["quantity"].isna().sum())(8, 7)
order_id int64
date str
branch str
product str
category str
quantity int64
price float64
dtype: object
08行7列です。quantityはint64、priceはfloat64なので、これらは数値としてソートされます。branchとproductはstr——すなわち文字列であり、アルファベット順にソートされます。dateもstrです。ここでは問題ありませんが、その理由は知っておく価値があります。YYYY-MM-DD形式で書かれた日付は、最も桁の大きい年が先頭に来るため、文字列のままでも正しくソートされます。4/3/2024のような書き方では正しくソートされません。そしてquantityには空白がないため、現時点では欠損値をどこに配置するかを判断する必要はありません。
3. 出力の見取り図を描く。 「数量の上位3件」であれば、最も大きい順に3行になるはずです。すなわち30(消しゴム、注文1005)、次に20(ペン、1007)、そして12(ペン、1001)です。コードを実行する前にこれをメモしておくことで、コードの出力を突き合わせて検証できるようになります。
4. ソートに必要な詳細を決める。
- キー(基準列) — どの列(または複数の列)で順序を決めるか?これが
by=になります。 - 方向 — 各キーについて、昇順(小さい順)にするか降順(大きい順)にするか?これが
ascending=になります。 - 同順位(タイ) — 2つの行が同じ値を持つ場合、どちらを先にするか?これが2つ目のキー、または
kind="stable"になります。 - 空白 — 欠損値を先頭と末尾のどちらに配置するか?これが
na_position=になります。 - 件数 — すべての行が必要か、それとも上位N件だけか?これが
.head(n)またはnlargest()になります。 - インデックス — 並べ替え後も元の行ラベルに意味があるか?そのまま保持するか、
reset_index(drop=True)で破棄するかを決めます。
5. 適切なツールを選ぶ。
- 1つまたは複数の列の順序に従ってすべての行を並べる場合:
sort_values() - 数値列の上位または下位N件だけを取得する場合:
nlargest()/nsmallest() - 行をインデックスの順序に戻す場合:
sort_index() - サブセットの中での上位N件(「north支店内での上位」など)を取得する場合:先にフィルタリングし、次にソートし、その後に
head()を適用する
6. どのように検証するかをあらかじめ決めておく。 ソート後、先頭数行が見取り図と一致しているか確認し、shapeが変わっていないこと(ソートによって行が増減することは決してありません)、そして数値キーの場合は列の値が一方向に単調に並んでいることを確認します。
sort_values()——1つの列
出力の幅をコンパクトに抑えるため、例では7列中5列のみを表示しています。colsは列名のリストであり、第4章で学んだ二重角括弧による選択で使用します。
cols = ["order_id", "branch", "product", "quantity", "price"]
by_qty = orders.sort_values("quantity")
print(by_qty[cols])order_id branch product quantity price
5 1006 south bag 1 850.0
2 1003 north bag 2 850.0
7 1008 north bottle 4 120.0
1 1002 south notebook 5 60.0
3 1004 east bottle 7 120.0
0 1001 north pen 12 15.0
6 1007 east pen 20 15.0
4 1005 north eraser 30 8.0デフォルトは昇順——小さい順です。quantity列を下に向かって見てみましょう。1、2、4、5、7、12、20、30となっています。これは計画段階での確認ポイント通りです。数値キーであれば値は増加する一方になるはずです。
ここで左端に注目してください。インデックスはもはや0, 1, 2, …ではありません。5, 2, 7, 1, 3, 0, 6, 4と並んでいます。
なぜインデックスが行と一緒に移動するのか
pandasが行番号を振り直さなかったのは、意図的な仕様です。インデックスのラベルは行の位置ではなく、行の名前だからです。ラベル5はソート前も注文1006であり、ソート後も変わらず注文1006です。もしソートによって行番号が振り直されてしまったら、すべてのラベルが突然別の注文を指すことになり、「5行目」についてメモしていた内容がすべて静かに狂ってしまうことになります。
したがって、ソート後には「最初の行」を指定する方法が2通り生じることになり、両者の指す行は一致しなくなります。
print(by_qty.iloc[0]["order_id"])
print(by_qty.loc[0]["order_id"])1006
1001iloc[0]は位置が0番目——画面上の最初の行である注文1006を意味します。loc[0]はラベルが0の行——今や画面上の6番目に移動した注文1001を意味します。第4章でこの違いを説明しましたが、ソートこそがこの区別を重要にする操作です。「ソート後の先頭行」を取得したいときは、位置を数えるilocまたはhead()を使用します。
大きい順:ascending=False
print(orders.sort_values("quantity", ascending=False)[cols])order_id branch product quantity price
4 1005 north eraser 30 8.0
6 1007 east pen 20 15.0
0 1001 north pen 12 15.0
3 1004 east bottle 7 120.0
1 1002 south notebook 5 60.0
7 1008 north bottle 4 120.0
2 1003 north bag 2 850.0
5 1006 south bag 1 850.0上位3件は1005、1007、1001であり、まさに計画段階で描いた見取り図通りです。この3件だけを取り出すには、head(3)を追加します。
top3 = orders.sort_values("quantity", ascending=False).head(3)
print(top3[cols])order_id branch product quantity price
4 1005 north eraser 30 8.0
6 1007 east pen 20 15.0
0 1001 north pen 12 15.0このメソッドチェーンを左から右へ読むと、計画がそのまま表現されていることが分かります。ordersを取得し、最も大きい数量を先頭に並べ替え、最初の3行を残します。この「ソートしてからhead」というパターンは、「上位N件」の問いの大半に対する答えになります。
文字列はアルファベット順にソートされる
str型の列は辞書順にソートされます。
print(orders.sort_values("product")[cols])order_id branch product quantity price
2 1003 north bag 2 850.0
5 1006 south bag 1 850.0
3 1004 east bottle 7 120.0
7 1008 north bottle 4 120.0
4 1005 north eraser 30 8.0
1 1002 south notebook 5 60.0
0 1001 north pen 12 15.0
6 1007 east pen 20 15.0aはoより前なので、bagはbottleの前に来ます。バッグが2件、ボトルが2件、ペンが2件あります——これらは同順位(タイ)であり、同順位のペアのどちらが先に来るのかという問題については後ほど詳しく見ていきます。
ソートは新しい表を返す
これはほぼ誰もが一度は犯す間違いであり、何のエラーも発生しません。
orders.sort_values("quantity", ascending=False)
print(orders[cols].head(3))order_id branch product quantity price
0 1001 north pen 12 15.0
1 1002 south notebook 5 60.0
2 1003 north bag 2 850.0何もソートされていません。sort_values()はordersを変更しません。新しい順序で新しいDataFrameを構築して返します。1行目はそのソートされた表を作成しましたが、誰もそれを変数に保存しなかったため、破棄されてしまいました。ordersはread_csvが生成したときの状態のままです。
なぜpandasはこのように動作するのでしょうか?元の順序もまた情報だからです。orders.csvにおいては、それは注文が届いた順序を意味します。もしソートするたびに手元の唯一のコピーが書き換えられてしまったら、「ファイルが元々どうだったか」に戻すことができなくなり、あるレポート用に書いたソート処理が次の処理の入力を知らぬ間に変えてしまうことになります。新しいオブジェクトを返す仕組みになっているおかげで、ソートはいつでも安全に試すことができるのです。
sort_values()が、呼び出し元の表を変更することは決してありません。結果を変数に代入しなければ、ソートは実行された後にそのまま破棄されます——そして警告は何も出ません。そのため、結果を新しい変数名に代入するか、元の変数名に再代入して保持する必要があります。
by_qty_desc = orders.sort_values("quantity", ascending=False) # keep both
print(by_qty_desc["order_id"].head(3).tolist())
print(orders["order_id"].head(3).tolist())[1005, 1007, 1001]
[1001, 1002, 1003]ソートされたコピーでは大きな注文が上に来ており、元の表には一切手が加えられていません。元の順序がもう不要だと確信できている場合は、orders = orders.sort_values(...)のように書くのも問題ありません。
昔のコードではinplace=Trueを目にすることがあるでしょう。これはその場で表を変更してNoneを返しますが、特有のバグの原因になります(問題が起きたときを参照)。また、pandas 3のCopy-on-Writeの仕組みにより、inplace=Trueを使ってもメモリの節約にはなりません。結果を変数に代入する方が、ソートされた表がどこに格納されるのかがその行を見ただけで分かるため、はるかに明確です。
複数の列:優先順位と昇順・降順
オーナーの2つ目の要望は、「支店ごとにグループ化して、各支店の中で大きい順に並べたリスト」でした。これは2つのキーを指定することを意味し、リスト内の順序が優先順位になります。
by_branch = orders.sort_values(["branch", "quantity"], ascending=[True, False])
print(by_branch[cols])order_id branch product quantity price
6 1007 east pen 20 15.0
3 1004 east bottle 7 120.0
4 1005 north eraser 30 8.0
0 1001 north pen 12 15.0
7 1008 north bottle 4 120.0
2 1003 north bag 2 850.0
1 1002 south notebook 5 60.0
5 1006 south bag 1 850.0pandasがこれをどのように構築したのかを順に見てみましょう。まず、すべての行がbranchのアルファベット順(AからZ)に配置されます(east、north、south)。2つ目のキーであるquantityは、1つ目のキーが同順位になったときだけ参照されます——east内、north内、south内です。north内では30、12、4、2と並んでいます。ascendingの2つ目の要素がFalseであるため、大きい順になっています。
ascendingには、byと同じ順序でキーごとに1つのTrue/Falseを渡します。単一のFalseを指定すると両方のキーに適用され、southが先頭に来てしまいます。
リストの順序を入れ替えると、問いの意味がまったく変わってしまいます。
print(orders.sort_values(["quantity", "branch"], ascending=[False, True])[cols].head(4))order_id branch product quantity price
4 1005 north eraser 30 8.0
6 1007 east pen 20 15.0
0 1001 north pen 12 15.0
3 1004 east bottle 7 120.0今度は数量が最優先となり、branchは2つの注文の数量がまったく同じ場合にのみ意味を持つことになります——実際には同じ数量の注文はありません。そのため、支店はあちこちに散らばってしまいます。したがって、リストを書く前に自問すべき問いは、「このレポートは何を基準に整理されているのか?」です。その基準となる列を先頭に配置します。
同順位(タイ):順序を偶然ではなく意図して決める
priceで最も高い順にソートしてみましょう。
print(orders.sort_values("price", ascending=False)[cols])order_id branch product quantity price
2 1003 north bag 2 850.0
5 1006 south bag 1 850.0
7 1008 north bottle 4 120.0
3 1004 east bottle 7 120.0
1 1002 south notebook 5 60.0
0 1001 north pen 12 15.0
6 1007 east pen 20 15.0
4 1005 north eraser 30 8.0120の2件のボトルに注目してください。ファイル内では、注文1004が1008の前にあります。しかしソート後の表では、1008が先に来ています。何も壊れてはいません。単一のキーでソートする場合、pandasのデフォルトのソートアルゴリズム(quicksort)は、同順位の行を元の順序のまま保持することを保証しておらず、ここでは順序が入れ替わりました。850のバッグ2件はファイルの順序を保ち、ボトル2件は入れ替わりました——事前に予測できる規則性はありません。
普段はこれが問題になることはあまりありません。問題になり始めるのは、リストを途中で切り詰める瞬間です。もしレポートが「最も高価な注文の上位3件」であった場合、3位は1004と1008の同順位となり、どちらがリストに残るかはあなたではなくアルゴリズムによって決められてしまいます。少しデータが異なるファイルで同じコードを実行すると、答えが変わってしまう可能性があります。
その主導権を取り戻す方法は2つあります。
方法1:安定ソートを使う。 kind="stable"を指定すると、同順位の行がソート前の順序(ここではファイル内の出現順)を維持することが保証されます。
stable = orders.sort_values("price", ascending=False, kind="stable")
print(stable["order_id"].tolist())[1003, 1006, 1004, 1008, 1002, 1001, 1007, 1005]方法2(通常はこちらの方が優れています):明示的なタイブレーカー(同順位の解決基準)を指定する。 同順位になったときに何をもって優先するかを明確にします。売上レポートであれば、「価格が同じなら、注文数量が多い方を優先する」というのが理にかなったルールです。
tie_broken = orders.sort_values(["price", "quantity"], ascending=[False, False])
print(tie_broken[cols])order_id branch product quantity price
2 1003 north bag 2 850.0
5 1006 south bag 1 850.0
3 1004 east bottle 7 120.0
7 1008 north bottle 4 120.0
1 1002 south notebook 5 60.0
6 1007 east pen 20 15.0
0 1001 north pen 12 15.0
4 1005 north eraser 30 8.0もう一度ボトルを見てみましょう。7は4より大きいため、1004が1008の上に来ています。そして15の2つのペンについても、先ほどは1001が先でしたが、今度は20が12に勝つため1007が優先されます——そしてどのようなマシンで実行しても、ファイル内の順序がどうであっても、常に1007が優先されます。タイブレーカーを指定することで、単なる偶然を、オーナーに説明可能な明確なルールへと変えることができます。(なお、複数の列でソートする場合、pandasのソートは元から安定ソートになるため、キーのリストを指定した場合は本物の同順位が勝手に入れ替わることもありません。)
ビジネス上の明確なルールがない場合は、order_idを最後のキーに指定するのが適しています。一意の値であるため、それを指定した後は同順位が一切残らなくなるからです。
欠損値:na_position
2件の数量が空欄になっているバージョンのファイルを読み込んでみましょう。
gaps = pd.read_csv("orders_gaps.csv")
print(gaps["quantity"].isna().sum())
print(gaps.sort_values("quantity", ascending=False)[cols])2
order_id branch product quantity price
4 1005 north eraser 30.0 8.0
0 1001 north pen 12.0 15.0
3 1004 east bottle 7.0 120.0
7 1008 north bottle 4.0 120.0
2 1003 north bag 2.0 850.0
5 1006 south bag 1.0 850.0
1 1002 south notebook NaN 60.0
6 1007 east pen NaN 15.0注目すべき点が2つあります。第1に、quantityが30.0や12.0のように表示されるようになりました——第6章で説明した通り、NaNは浮動小数点数であるため、空白があることで列全体がfloat64に変化したのです。第2に、大きい順にソートするよう指定したにもかかわらず、NaNの行は末尾に移動しました。これはデフォルトの動作(na_position="last")であり、昇順・降順のどちらでも同様に適用されます。欠損値は「小さい」わけでも「大きい」わけでもなく不明な値であるため、pandasは勝手に推測せず脇に除外するのです。
これが上位3件にどう影響したかに注目してください。注文1007は実際にはペンを20個販売していましたが、このファイルでは数量が空白だったため、上位3件から脱落して代わりに1004が入ってしまいました。ソートによって欠損データを修復することはできません。空白がどこへ移動したかを示すことしかできないのです。計画のステップ2で、ソートする前に空白をカウントしておくのはまさにこのためです。
データを監査(チェック)しているときは、問題のある箇所をすぐに見つけられるよう、逆に先頭に持ってきたいことが多いでしょう。
print(gaps.sort_values("quantity", na_position="first")[cols].head(3))order_id branch product quantity price
1 1002 south notebook NaN 60.0
6 1007 east pen NaN 15.0
5 1006 south bag 1.0 850.0このように、na_positionは単なる見栄えの指定ではありません。"last"は、空白によって実際の値が押し出されてしまわないようにしたいレポートに適しています。"first"は、空白こそが探している対象そのものであるチェック作業に適しています。
最初に戻す:sort_index()とreset_index()
すべての行が自身のラベルを保持していたため、元の順序は決して失われていません。インデックスでソートすることで、元の順序を取り戻せます。
restored = by_qty.sort_index()
print(restored["order_id"].tolist())[1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008]sort_index()にもascending=Falseを指定でき、これを使えば「最新のラベルを先頭にする」といった並べ替えが可能です。
一方で、古いラベルに意味がなくなる場合もあります。ソートして順位表を作成し、それを他の人に提出する場合、端に4, 6, 0といったラベルが並んでいると混乱を招くだけです。reset_index(drop=True)を使えば、古いラベルを破棄し、行番号を新しい順序に従って0, 1, 2, …と振り直すことができます。
ranked = orders.sort_values("quantity", ascending=False).reset_index(drop=True)
print(ranked[cols].head(3))
print(ranked.loc[0, "order_id"])order_id branch product quantity price
0 1005 north eraser 30 8.0
1 1007 east pen 20 15.0
2 1001 north pen 12 15.0
1005これでloc[0]とiloc[0]の指す対象が再び一致しました。どちらも最大数量である注文1005を指しています。drop=Trueを指定しない場合、pandasは古いラベルをindexという新しい列として保持します——元の行を追跡したいときには便利ですが、不要であれば邪魔になってしまいます。
どちらを選ぶべきでしょうか?データの処理中で、元の行と突き合わせる必要があるかもしれない間は、ラベルを保持します。ランキングやトップ10リストのように、並び順そのものが成果物である場合は、インデックスをリセットします。
「上位N件」のショートカット:nlargest()とnsmallest()
「数量の上位3件」のような処理は非常に頻繁に行われるため、pandasにはその意図をそのまま表現するメソッドが用意されています。
print(orders.nlargest(3, "quantity")[cols])order_id branch product quantity price
4 1005 north eraser 30 8.0
6 1007 east pen 20 15.0
0 1001 north pen 12 15.0これはsort_values("quantity", ascending=False).head(3)とまったく同じ行を返します。なぜこちらを使うのでしょうか?コードの仕組みではなく、「最大の3件」という意図を直接表現できるからです。さらに、大きな表においては、たった3行を残すためだけに全行をソートする必要がないため、実行速度も速くなります。
nsmallest()はその裏返しであり、店内で最も価格の安い3品目を取得します。
print(orders.nsmallest(3, "price")[cols])order_id branch product quantity price
4 1005 north eraser 30 8.0
0 1001 north pen 12 15.0
6 1007 east pen 20 15.0境界値での同順位:keep=
最も高価な品目の上位3件を求めてみると、少し困った事態が起こります。
print(orders.nlargest(3, "price")[cols])order_id branch product quantity price
2 1003 north bag 2 850.0
5 1006 south bag 1 850.0
3 1004 east bottle 7 120.03位は120ですが、120の注文は1004と1008の2件存在します。pandasはそのうちの1件——デフォルトのkeep="first"により、表内で先に出現した方——を返し、もう一方を警告なく除外しました。もし「上位3件」に賞を授与するような場面であれば、これは1008に対して不公平です。keep="all"を指定すると、結果がN行を超えることになっても、境界値で同順位になっているすべての行を保持します。
print(orders.nlargest(3, "price", keep="all")[cols])order_id branch product quantity price
2 1003 north bag 2 850.0
5 1006 south bag 1 850.0
3 1004 east bottle 7 120.0
7 1008 north bottle 4 120.0「上位3件」に対して4行が返されました。これはバグではありません。3位が同順位で共有されているときの誠実な答えです。この挙動を望むかどうかは、やはり計画段階での判断事項です。
なお、nlargestとnsmallestは数値列にのみ機能します。文字列の場合は、sort_valuesとheadを組み合わせて使用してください。
サブセットの上位N件:フィルタ、ソート、抽出の順序
「north支店で数量の多かった上位2件の注文はどれか?」 この問いは、第5章の内容と本章の内容を組み合わせたものであり、処理のステップの順序が極めて重要です。
north = orders[orders["branch"] == "north"]
top_north = north.sort_values("quantity", ascending=False).head(2)
print(top_north[cols])order_id branch product quantity price
4 1005 north eraser 30 8.0
0 1001 north pen 12 15.0最初にフィルタリングを行います。この問いはnorth支店のみを対象としているからです。もし表全体を先にソートしてhead(2)を取ってしまうと、1005と1007が得られます——しかし1007はeast支店の注文です。その後にフィルタリングを適用すると1行しか残らなくなり、「上位2件」といいながら1件しか記載されていないレポートになってしまいます。守るべきルールは、「問いが対象としている行に絞り込み、次に順位付けを行い、その後に切り詰める」です。
同じ処理の流れはnlargestでも同様に書くことができます:orders[orders["branch"] == "north"].nlargest(2, "quantity")。
大小文字を区別せずに文字列をソートする:key=
文字列のソートでは文字コードに基づいて文字同士が比較されるため、すべての大文字はすべての小文字よりも前に配置されます。人が入力したデータでは、この2つが混在することがよくあります。
names = pd.DataFrame({"product": ["pen", "Notebook", "bag", "Bottle", "eraser"]})
print(names.sort_values("product"))product
3 Bottle
1 Notebook
2 bag
4 eraser
0 pen単に大文字で始まっているというだけで、BottleとNotebookがbagの前に割り込んでしまっています。これをアルファベット順と呼ぶ読者はいないでしょう。
データを直接修正することもできますが、そうすべきではない場合も少なくありません——その表記自体が顧客の入力した正式なデータであるかもしれないからです。key=引数を使うと、元の値を保持したまま、列の変換後のバージョンに基づいてソートすることができます。この引数には、列全体を受け取り、ソート基準となる変換後の列を返す関数を指定します。
print(names.sort_values("product", key=lambda col: col.str.lower()))product
2 bag
3 Bottle
4 eraser
1 Notebook
0 pen小文字に変換されたコピーは比較のためだけに使われ、比較が終わると破棄されます。表にはBottleやNotebookが入力された通りの表記でそのまま残っています。(lambda col: col.str.lower()は1行の関数であり、渡された列colを小文字に変換して返します。)
数値が文字列として保存されている場合
ここで、計画の段階でdtypesを確認した理由である罠が登場します。ファイル内に余計な"N/A"が混入していたり、すべてを文字列としてエクスポートするシステムだったりして、quantityが文字列として届いたとしましょう。どのような事態になるかを確認するため、astypeを使って意図的にそのような列を作成してみます。
as_text = orders.astype({"quantity": "str"})
print(as_text["quantity"].dtype)
print(as_text.sort_values("quantity")[["order_id", "quantity"]])str
order_id quantity
5 1006 1
0 1001 12
2 1003 2
6 1007 20
4 1005 30
7 1008 4
1 1002 5
3 1004 71, 12, 2, 20, 30, 4, 5, 7となっています。エラーは出ず、一見すると何となく並んでいるように見えます。文字列は1文字ずつ比較されます。"12"の先頭は1であり、これは2よりも小さいため、辞書で"ab"が"b"の前に来るのと同じように、"12"は"2"の前に来ます。ソート処理は指示された通りのことを忠実に行いました。指示した基準が間違っていたのです。
これを見抜く手がかりが2つあります。出力結果でquantityが左寄せになっていること(pandasが文字列を出力するときの形式です)、そしてdtypeがstrと表示されていることです。根本的な解決策は、列を数値型に変換することです(第6章で学んだpd.to_numeric)。どうしても列をそのまま残さなければならない場合は、key=を使って数値としての値に基づいてソートすることもできます。
fixed = as_text.sort_values("quantity", key=pd.to_numeric)
print(fixed["quantity"].tolist())['1', '2', '4', '5', '7', '12', '20', '30']これで正しい順序になりました——ただし値は依然として文字列のままなので、足し算などの計算を行うことはできません。ファイルを読み込んだ直後に一度だけ列の型を変換しておく方が好ましいでしょう。そうすれば、その後のすべてのソート、フィルタリング、合計の計算が、意識することなく常に正しい結果になります。
完全な例
オーナーからのすべての要望に、1つのプログラムで答えます。(1) 売上金額の上位3件、(2) 最新の注文順、(3) 支店ごとに各支店内での最大注文を先頭にしたリスト。
「売上金額」は数量×単価です。新しい列の追加についてはコースの後半で正式に扱いますが、今のところは1行で十分です——新しい列名に代入するだけで作成できます。
sorted_report.py:
import pandas as pd
orders = pd.read_csv("orders.csv")
# Check the input before ranking anything
print("Rows, columns:", orders.shape)
print("Blank quantities:", orders["quantity"].isna().sum())
print()
orders["revenue"] = orders["quantity"] * orders["price"]
cols = ["order_id", "date", "branch", "product", "revenue"]
# 1. Top three by revenue, ties broken by the larger quantity, renumbered as a ranking
top3 = (
orders.sort_values(["revenue", "quantity"], ascending=[False, False])
.head(3)
.reset_index(drop=True)
)
print("Top three orders by revenue")
print(top3[cols])
print()
# 2. Newest first; within the same day, the bigger order first
newest = orders.sort_values(["date", "revenue"], ascending=[False, False])
print("Newest first")
print(newest[cols].head(4))
print()
# 3. By branch A-Z, biggest order first inside each branch
by_branch = orders.sort_values(["branch", "revenue"], ascending=[True, False])
print("By branch")
print(by_branch[cols])
print()
print("Still eight rows:", by_branch.shape[0] == orders.shape[0])Rows, columns: (8, 7)
Blank quantities: 0
Top three orders by revenue
order_id date branch product revenue
0 1003 2024-03-02 north bag 1700.0
1 1006 2024-03-03 south bag 850.0
2 1004 2024-03-02 east bottle 840.0
Newest first
order_id date branch product revenue
7 1008 2024-03-04 north bottle 480.0
6 1007 2024-03-04 east pen 300.0
5 1006 2024-03-03 south bag 850.0
4 1005 2024-03-03 north eraser 240.0
By branch
order_id date branch product revenue
3 1004 2024-03-02 east bottle 840.0
6 1007 2024-03-04 east pen 300.0
2 1003 2024-03-02 north bag 1700.0
7 1008 2024-03-04 north bottle 480.0
4 1005 2024-03-03 north eraser 240.0
0 1001 2024-03-01 north pen 180.0
5 1006 2024-03-03 south bag 850.0
1 1002 2024-03-01 south notebook 300.0
Still eight rows: Trueこのような書き方になっている理由:
- 確認を最初に行う。 順位付けを行う前に、
shapeと空白の件数を出力しています。半分しか読み込まれていないファイルから計算された上位3件や、空白が末尾に追いやられた上位3件でも、出力結果自体は何食わぬ顔で表示されてしまうからです。 - 「最大」を具体的な列に変換した。
quantityで順位付けすると、消しゴム30個が先頭に来ます——取引額としては240です。revenueで順位付けすると、1,700に相当するバッグ2件が先頭に来ます。計画の最初のステップ——列名を含めて問いを言語化する——こそが、オーナーに届く答えを決めるのです。 - すべてのソートにタイブレーカーを設定した。 2024-03-04には2件の注文があります。
revenueによってどちらを先にするかを決めているため、レポートは実行するたびに毎回同じ結果になります。 - ランキングのみ行番号を振り直した。
top3は完成されたリストであるため、そのラベルを0、1、2にリセットしています。newestとby_branchは元のラベルを保持しているため、どの行もファイル内の元の行まで追跡できます。 orders自体は一度も再ソートしていない。 各レポートは手つかずの同じ入力から構築された新しい表であるため、3つの結果が互いに干渉することはありません。- 最後の行は手軽なアサーション(確認検証)。 ソートによって行数が変わることは決してあってはなりません。
Trueを出力するコストはゼロであり、たとえば誤って別の場所に残してしまったhead()などを即座に検知できます。
問題が起きたとき
KeyError: 'Quantity'
orders.sort_values("Quantity")KeyError: 'Quantity'by=に指定する名前は、大文字・小文字や空白を含めて、列名と完全に一致していなければなりません。この列はすべて小文字のquantityです。第3章でお勧めしたように、list(orders.columns)を出力して、引用符や余分な空白を含めた実際の列名を確認してください。
AttributeError: 'NoneType' object has no attribute 'head'
top = orders.sort_values("quantity", inplace=True)
top.head()AttributeError: 'NoneType' object has no attribute 'head'inplace=Trueを指定すると、sort_valuesはordersを変更してNoneを返します。そのためtopはNoneとなり、Noneにはhead()メソッドが存在しません。スタイルはどちらか一方を選んでください。単独の行でorders.sort_values("quantity", inplace=True)と書くか、あるいはより推奨される方法として、inplaceを使わずにtop = orders.sort_values("quantity")と書きます。
ValueError: Length of ascending (1) != length of by (2)
orders.sort_values(["branch", "quantity"], ascending=[False])ValueError: Length of ascending (1) != length of by (2)ascendingにリストを渡す場合、キーごとに1つの要素が必要です。キーが2つあればブール値も2つ必要です:ascending=[True, False]。(リストに入れずに単独のFalseを指定することは許可されており、その場合はすべてのキーに適用されます。)
ValueError: For argument "ascending" expected type bool, received type str.
orders.sort_values("branch", ascending="False")ValueError: For argument "ascending" expected type bool, received type str.引用符で囲まれた"False"は文字列であり、ブール値のFalseではありません。引用符を削除してください。
TypeError: Column 'branch' has dtype str, cannot use method 'nlargest' with this dtype
orders.nlargest(3, "branch")TypeError: Column 'branch' has dtype str, cannot use method 'nlargest' with this dtypenlargestとnsmallestは数値のみを順位付けできます。「アルファベット順で最後の3つの支店」を取得したい場合は、orders.sort_values("branch", ascending=False).head(3)を使用してください。数値であると期待していた列でこのエラーが発生した場合は、その列が文字列として読み込まれてしまっていることが本当の問題です——dtypesを確認してください。
TypeError: '<' not supported between instances of 'str' and 'int'
mixed = pd.Series([12, "unknown", 5])
mixed.sort_values()TypeError: '<' not supported between instances of 'str' and 'int'この列には実際の数値およびテキストが混在しているため、dtypeがobjectになっており、Pythonは"unknown"が12より大きいのか小さいのかを判断できません。まず列をクリーンアップしてください。pd.to_numeric(mixed, errors="coerce")を使えば"unknown"がNaNに変換され、ソートが機能するようになり、na_positionによって空白をどこに配置するかが決定されます。
ステップ 4 / 6 — 予想する
理解度をチェック
2つのバッグが最高価格の850.0で並んでいます。何が出力されますか?
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))
top = orders.sort_values("price", ascending=False)
print(list(top.index[:3]))- A[2, 5, 7]
- B[0, 1, 2]
- C[2, 5, 3]
- D[5, 2, 7]
最も数量の多い注文を出力することが目的です。何が出力されますか?
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.sort_values("quantity", ascending=False)
print(orders.iloc[0]["order_id"])- A1001 — `sort_values`はソートされたコピーを返したが、誰もそれを変数に保存しなかった
- B1007
- C`TypeError`:結果を変数に代入しなければならない
- D1005
支店ごとにアルファベット順(A〜Z)でグループ化し、各支店内では数量が最も多いものを先頭に並べ替えたいと考えています。これを実現する行はどれですか?
- Aorders.sort_values("branch").sort_values("quantity", ascending=False)
- Borders.sort_values(["branch", "quantity"], ascending=False)
- Corders.sort_values(["quantity", "branch"], ascending=[False, True])
- Dorders.sort_values(["branch", "quantity"], ascending=[True, False])
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
ある教員から1つのテストの点数が送られてきました。これをresults.csvとして保存してください。2名の生徒にはまだ点数がなく、一部の名前は小文字で入力されています。
name,section,marks
Rafi,B,78
anika,A,91
Tanvir,A,
Mitu,B,91
zara,A,65
Karim,B,
Nadia,A,88
Sabbir,B,54ranking.pyを作成してください。コードを書く前に、ファイル冒頭のコメントとして計画を記述します。各リストについて、キーとなる列、ソートの方向、同順位の解決方法、そして空白をどこに配置するかを整理してください。その上で、プログラムが以下の4つの条件を満たすように実装してください。
- ソートを行う前に、
shape、dtypes、および欠損している点数の件数を出力する。 - 成績順リスト(Merit list)を出力する:点数の高い順、点数のない生徒は末尾、点数が同点の場合は大文字・小文字を無視したアルファベット順の名前で順位を解決し、行番号を0、1、2…と振り直す。
nlargestを使って上位3名(Top three)を出力し、3位で同点になっている全員を含める。- 教員が生徒に確認できるよう、大文字・小文字を無視した名前順で点数のない生徒(No mark yet)を出力する——そして最後に、一度も変更されていないことを証明するために元の表を出力する。
プログラムの出力は正確に次のようになる必要があります。
(8, 3)
name str
section str
marks float64
dtype: object
Missing marks: 2
Merit list
name section marks
0 anika A 91.0
1 Mitu B 91.0
2 Nadia A 88.0
3 Rafi B 78.0
4 zara A 65.0
5 Sabbir B 54.0
6 Karim B NaN
7 Tanvir A NaN
Top three
name section marks
1 anika A 91.0
3 Mitu B 91.0
6 Nadia A 88.0
No mark yet
name section marks
5 Karim B NaN
2 Tanvir A NaN
Original, unchanged
name section marks
0 Rafi B 78.0
1 anika A 91.0
2 Tanvir A NaN
3 Mitu B 91.0
4 zara A 65.0
5 Karim B NaN
6 Nadia A 88.0
7 Sabbir B 54.0その後、成績順リストに対して2つの実験を行ってみてください。
key=を削除します。91点の2名の生徒のうち、どちらが先に来るようになりますか?その理由は何ですか?- キーから
"name"を完全に削除し、marksだけでソートします。91点の中で誰が先に来ますか——そしてその結果は常に保証されますか?
解答
計画。 ファイルには8名の生徒と3つの列があります。marksには空白があるためfloat64として読み込まれ、点数でソートするすべてのリストで空白をどこに配置するかを指定する必要があります。nameには大文字と小文字が混在しているため、名前の比較にはすべてkey=が必要です。
- 成績順リスト(Merit list) — キーは
marks、次にname。点数は高い順、名前はAからZ順。点数が同点の場合は名前で解決。空白は末尾(デフォルト)。reset_index(drop=True)で行番号を振り直す。 - 上位3名(Top three) —
keep="all"を指定したnlargest(3, "marks")。これにより、同率3位が切り捨てられないようにする。nlargestは自動的に空白を除外する。 - 点数のない生徒(No mark yet) — これまでの章と同様に、
marksが欠損している行をフィルタリングし、その後に名前でソートする。先にフィルタリングし、次にソートする。 - 元の表(Original) — 一度も再代入されていないため、最後に
resultsを出力するとファイル内の元の順序が表示される。
1つの詳細点:key=関数はすべてのキー列に適用されます。成績順リストの場合、これはmarksにも適用されることを意味し、数値に対して.str.lower()を実行するとエラーになります。そのため、この関数では文字列の列のみを小文字に変換し、それ以外の列はそのまま返すようにします。
ranking.py:
import pandas as pd
# Merit list : marks high->low, ties by name A->Z ignoring case, blanks last, renumber
# Top three : nlargest on marks, keep="all" so a shared third place is not cut
# No mark yet : filter rows with no mark, then name A->Z ignoring case
# Original : never reassigned
results = pd.read_csv("results.csv")
print(results.shape)
print(results.dtypes)
print("Missing marks:", results["marks"].isna().sum())
print()
def lower_text(col):
return col.str.lower() if col.dtype == "str" else col
merit = results.sort_values(
["marks", "name"], ascending=[False, True], key=lower_text
).reset_index(drop=True)
print("Merit list")
print(merit)
print()
print("Top three")
print(results.nlargest(3, "marks", keep="all"))
print()
print("No mark yet")
missing = results[results["marks"].isna()]
print(missing.sort_values("name", key=lower_text))
print()
print("Original, unchanged")
print(results)(8, 3)
name str
section str
marks float64
dtype: object
Missing marks: 2
Merit list
name section marks
0 anika A 91.0
1 Mitu B 91.0
2 Nadia A 88.0
3 Rafi B 78.0
4 zara A 65.0
5 Sabbir B 54.0
6 Karim B NaN
7 Tanvir A NaN
Top three
name section marks
1 anika A 91.0
3 Mitu B 91.0
6 Nadia A 88.0
No mark yet
name section marks
5 Karim B NaN
2 Tanvir A NaN
Original, unchanged
name section marks
0 Rafi B 78.0
1 anika A 91.0
2 Tanvir A NaN
3 Mitu B 91.0
4 zara A 65.0
5 Karim B NaN
6 Nadia A 88.0
7 Sabbir B 54.0出力結果を計画と突き合わせて確認します。
marksはfloat64であり、int64ではありません。そしてMissing marks: 2です——順位付けを行う前に空白が発見されているため、成績順リストの読者は、2名の生徒が最下位なのではなくリストから除外されていることを把握できます。- 91点の同順位は名前によって解決されています。 比較が
"anika"と"mitu"で行われたため、anikaがMituの前に来ています。 - ここでの上位3名は3行になっています。 3位(88点)が同率ではないからです。もしNadiaも91点だった場合、
keep="all"は生徒を警告なく脱落させるのではなく、4行を返します。 - 未採点リストは先にフィルタリングし、その後にソートしています — サブセットの上位N件パターンと同じ「絞り込んでから順位付けする」順序です。
- 最後の表はファイル内の元の順序です。 3つのリストが生成されましたが、
resultsには一度も再代入されていません。
実験:
no_key = results.sort_values(["marks", "name"], ascending=[False, True])
marks_only = results.sort_values("marks", ascending=False)
print(no_key["name"].head(2).tolist())
print(marks_only["name"].head(2).tolist())['Mitu', 'anika']
['anika', 'Mitu']key=を指定しない場合、名前は1文字ずつ比較され、すべての大文字はすべての小文字の前に来るため、"Mitu"が"anika"に勝ってしまいます——アルファベット順ではなく、名前がどのように入力されたかに基づく順序になってしまうのです。marks単独でソートした場合、anikaがたまたまファイル内で先に出現していたため先頭に来ます。これは正しい成績順リストと同じ結果ですが、単なる偶然にすぎません。先ほど120のボトル2件で見たように、単一キーによるデフォルトのソートでは、同順位の行がファイル内の順序を保つことは保証されないからです。自分で書き記したルールは常に保証されますが、単に結果が正しそうに見えるだけのものは保証されません。