欠損データ — 空白を見つけ、その意味を判断する
-や?のように文字列として隠れているものも含めて表内のすべての空白を見つけ、NaNが合計や平均、データ型をどのように変化させるかを理解し、各空白を削除するか、埋めるか、そのまま残すかを列ごとに意図を持って選択する方法。
どうやって学びますか?
解決する問題
3つの支店で3月第1週の店舗の注文が手入力され、orders_raw.csvとしてエクスポートされました。オーナーは夕方までに2つの数字を求めています。何個売れたのか、そして売上金額はいくらになったのか、です。
ファイルを読み込み、これまでの章の習慣から、まずは規模感を把握するためにprice列を足し合わせてみます。
import pandas as pd
orders = pd.read_csv("orders_raw.csv")
print(orders["price"].sum())15.060.0850.08.0-15.0120.0これは数値ではありません。すべての価格が文字列として連結されてしまっています——そしてpandasは何のエラーも出さずにそれを教えてくれませんでした。次にquantity列を見てみましょう。
print(orders["quantity"].sum())
print(orders["quantity"].mean())76.0
10.857142857142858こちらは一見問題なさそうに見えますが、実はその方が厄介です。ファイルには8件の注文があるのに、76 / 8は9.5であり、10.86ではありません。1つのquantityが空白になっており、pandasは合計と平均の両方からそれを静かに除外していたのです。これについても誰も教えてくれませんでした。
このように、ファイルには欠損(空白)があり、それには2種類あります。1つは目に見えるものです。空のセルであり、pandasはこれを特別な「欠損」マーカーに変換し、算術演算の際には静かにスキップします。もう1つは隠れたものです。セルを空にする代わりに誰かが-と入力したため、pandasがそれをそのまま受け取り、price列全体が文字列になってしまいました。
どちらの種類もエラーは発生させません。しかし、どちらも答えを変えてしまいます。この章では、すべての欠損を見つけ出し、pandasがそれをどのように扱うかを理解した上で、デフォルト任せにするのではなく、削除するのか、埋めるのか、そのまま残すのかを意図を持って判断できるようになることを目指します。
この章を終える頃には
NaNとは何か、なぜNaN == NaNがFalseになるのか、そして1つのセルが空であるだけでなぜ整数の列が小数になるのかを説明できるようになるisna().sum()を使って列ごとの欠損値をカウントし、不完全な行を抽出できるようになるna_values=やpd.to_numeric(errors="coerce")を使って、-や?のような隠れたマーカーを本物の欠損値に変換できるようになるsum、mean、count、および列同士の算術演算が欠損値をどのように扱うかを予測できるようになるdropna()を使って欠損値を削除し、subset=やhow=を使って意図したものだけを確実に削除できるようになるfillna()を使って欠損値を置き換えられるようになる(単一の値、列ごとの異なる値、または統計値による補完)- 列ごとに、削除するのか、埋めるのか、そのまま残すのかを判断し、その理由を説明できるようになる
- pandas 3では何もしない
inplace=Trueの罠を回避できるようになる
前提条件: 行のフィルタリング。
まずファイルを作る
プロジェクトフォルダ内に、まったく同じ内容でorders_raw.csvを作成してください。欠損は意図的なものです——3行目、5行目、7行目を注意深く見てください。
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,,bottle,accessories,7,N/A
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,-
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0これはこれまでの章で使ったorders.csvと同じ8件の注文ですが、4箇所が壊れています。注文1002にはquantityがなく、注文1004にはbranchがなくpriceがN/Aになっており、注文1006にはpriceとして-が入っています。
コードを書く前に
欠損データは、いきなりコードを書くことが極めて危険なテーマです。dropna()もfillna()も何のエラーも出さずに実行され、どちらも計算結果を変えてしまいます。そのため、作業は観察して判断することから始まります。
1. 問いを1文で言語化する。 「今週の販売総数量と総売上(quantity × price)はいくらか——そして何件の注文が集計できなかったのか?」 最後の部分が重要です。2件の注文が知らぬ間に除外された合計と、「8件中6件の注文を集計」と明記された合計は、同じ答えではありません。
2. 何よりもまず、届いたデータを確認する。 shapeとinfo()を組み合わせることで、すべての列の状態を一度に把握できます。
print(orders.shape)
orders.info()(8, 7)
<class 'pandas.DataFrame'>
RangeIndex: 8 entries, 0 to 7
Data columns (total 7 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 order_id 8 non-null int64
1 date 8 non-null str
2 branch 7 non-null str
3 product 8 non-null str
4 category 8 non-null str
5 quantity 7 non-null float64
6 price 7 non-null str
dtypes: float64(1), int64(1), str(5)
memory usage: 580.0 bytes列ごとに、想定していた状態と突き合わせて読み取ります。
branch— 想定は8 non-nullのstrでしたが、実際は7 non-nullでした。branchが1つ欠損しています。quantity— 想定は8 non-nullのint64でしたが、実際は7 non-nullのfloat64でした。quantityが1つ欠損しており、それゆえにfloat64になりました(理由は後述します)。price— 想定は8 non-nullのfloat64でしたが、実際は7 non-nullのstrでした。priceが1つ欠損している上に、列の中に文字列が混入しています。
このリストの中の2つは、確定した事実というよりも手がかりです。整数の列がfloat64になっている場合、ほぼ確実に欠損が存在することを意味します。数値の列がstrになっている場合、文字列が混ざっていることを確実に意味します——そして数値列に混ざる文字列は、たいてい隠れた「欠損」マーカーです。
3. 欲しい出力のイメージを描く。 たとえば次のような形です。
Missing values per column: branch 1, quantity 1, price 2
Orders counted for revenue: 6 of 8
Total quantity (known): 76
Total revenue (counted): ...合計だけでなく、欠損についても報告している点に注目してください。このレポートを読む人は誰であっても、売上が下限値であることを知る必要があります。
4. 欠損ごとに使うツールを決める——コードを書く前に。 列ごとに自問します。ここにある空のセルは何を意味しているのか? そして、それなしでも問いに答えることはできるのか?
branch— 欠損は注文が行われたものの支店が記録されなかったことを意味します。判断:"Unknown"で埋める。注文自体は実在し、その金額も集計に含めるべきだからです。quantity— 欠損は何個売れたのかが分からないことを意味します。判断:欠損のまま残し、売上の集計からその行を除外する。数値を勝手に捏造(0にする? 平均値にする?)すれば、売上を捏造することになってしまうからです。price— 欠損はいくらで売れたのかが分からないことを意味します。判断:同じ理由から欠損のまま残し、売上の集計からその行を除外する——そして何行が除外されたかを報告する。
5. 事後に何を確認するかを決めておく。 値を削除したり置き換えたりするステップを実行するたびに、欠損の個数とshapeを再度出力します。もしdropna()によって8行から一気に2行になってしまったら、レポートを提出した後ではなく、その場ですぐに気づく必要があります。
この章の残りの部分では各ツールを順番に説明し、最後にこの計画を1つのプログラムにまとめます。
「欠損」とはどのようなものか:NaN
pandasは空のセルを見つけても、空文字列やゼロを格納するわけではありません。NaN(Not a Number、非数)と呼ばれる特別な浮動小数点数値を格納します。SeriesにNoneを入れることで、自分自身で作成することもできます。
import pandas as pd
quantity = pd.Series([12, None, 2])
print(quantity)0 12.0
1 NaN
2 2.0
dtype: float64dtypeに注目してください。2つの整数と1つの空白を渡したところ、12.0や2.0という値とともにfloat64が返ってきました。これがinfo()にあった手がかりの背後にある理由です。NaNは浮動小数点数(float)の値であり、従来の整数の列にはそれを保持する方法がありません。そのため、セルが1つでも欠損した瞬間、pandasはNaNを収めるスペースを作るために列全体をfloat64に昇格させます。今回のファイルでquantityが12.0や2.0などと出力されたのはそのためです。
文字列の列は異なる動作をします。str列は型を変更することなく欠損値を保持できます。
branch = pd.Series(["north", None, "east"])
print(branch)0 north
1 NaN
2 east
dtype: str出力は依然としてNaNですが、dtypeはstrのままです。したがってpandas 3では、数値列と文字列列の両方でNaNに出会うことになり、同じツールを使ってどちらの欠損も見つけることができます。
NaNは何とも等しくない——自分自身とさえ等しくない
これは欠損値に関する最も驚くべき事実であり、最も自然な検索方法を破綻させます。
import numpy as np
import pandas as pd
orders = pd.read_csv("orders_raw.csv")
print(np.nan == np.nan)
print((orders["branch"] == np.nan).sum())False
0NaNは「分からない何らかの値」を意味します。2つの不明な値は等しいとはみなされないため、比較は毎回Falseになります。その結果、== np.nanは決して何にも一致しない判定になります。branch列には欠損があるのに、カウントは0になってしまいます。代わりにpandasに直接問い合わせましょう。
決して==を使って欠損値を検索しないでください。== np.nanはエラーなしで実行されますが、常に何も見つけられないため、欠損だらけの列でも完全にデータが揃っているように見えてしまいます。isna()を使ってください。
欠損値を見つける:isna()
isna()は、==では不可能な「このセルは欠損しているか?」という問いを投げかけます。列に対して実行すると、True/Falseからなる列が返されます——フィルタリングの章で学んだマスクとまったく同じものです。
print(orders["branch"].isna())0 False
1 False
2 False
3 True
4 False
5 False
6 False
7 False
Name: branch, dtype: boolDataFrame全体に対して実行すると、すべてのセルに対して同様の処理が行われます。もっとも、8行にわたるTrue/Falseをそのまま目で追いたいことはあまりないでしょう。欲しいのは件数であり、Trueは1として数えられるため、.sum()で取得できます。
print(orders.isna().sum())order_id 0
date 0
branch 1
product 0
category 0
quantity 1
price 1
dtype: int64これは、新しいファイルを開いたときにinfo()の後に実行すべき1行です。Non-Null Countと同じ情報ですが、視点を変えて欠損そのものを直接カウントしています。
しかし、priceを見てください。1となっています。価格に問題のある行は2件(N/Aと-)あることが分かっています。このことを覚えておいてください。次のセクションで改めて取り上げます。
どの行が不完全なのか?
件数は個数を教えてくれますが、どの行かを確認するには、マスクをフィルタとして使います。isna().any(axis=1)は、各行に対して「この行の中に何か欠損しているものがあるか?」と問い合わせます。
incomplete = orders[orders.isna().any(axis=1)]
print(incomplete)order_id date branch product category quantity price
1 1002 2024-03-01 south notebook stationery NaN 60.0
3 1004 2024-03-02 NaN bottle accessories 7.0 NaNaxis=1は、各列を縦方向に調べるのではなく「各行を横断して調べる」ことを意味します。そして、このリストに入っていないものに注目してください。priceが-である注文1006です。pandasが認識している限りでは、その行は完全なのです。この点については少し待ってください。実際の行を目で確認することは、1行コードを増やすだけの価値があります。たとえば、欠損しているquantityがすべて特定の支店から来ているといったことに気づくきっかけになるからです。これはpandasではなく、その支店の人たちに確認すべき問題です。
件数ではなく割合として把握する
8行であれば件数だけで十分です。しかし8万行の場合、「欠損が312件」と言われても、それが0.4%だと分かるまではあまり意味を持ちません。True/Falseの列に対する.mean()は、Trueの割合を返します。
print(orders.isna().mean())order_id 0.000
date 0.000
branch 0.125
product 0.000
category 0.000
quantity 0.125
price 0.125
dtype: float64branch、quantity、priceでは、8個のセルのうち1個が欠損しています——それぞれ12.5%です。90%が空の列と0.1%が空の列ではまったく異なる判断が求められますが、この方法を使えばそれを見極めることができます。
隠れた欠損値:空白が文字列として書かれている場合
isna()が数えた欠損しているpriceは1件でしたが、実際には2件の価格が使い物になりません。2件目は注文1006の-です。pandasにとって-は欠損ではなく、まったく問題のない1つの文字列です。そして数値の列に文字列が1つあるだけで列全体がstrになってしまい、まさにinfo()で確認した通りの状態になります。
列の中に実際に何が入っているのかを調べる方法は次の通りです。
print(orders["price"].unique())<StringArray>
['15.0', '60.0', '850.0', nan, '8.0', '-', '120.0']
Length: 7, dtype: strすべての値が引用符で囲まれています——'15.0'を含め、すべて文字列です。そして2種類の欠損が並んでいます。pandasが認識したnanと、認識できなかった'-'です。
なぜN/Aはnanになり、-はならなかったのでしょうか? それは、read_csvが欠損として扱う文字列の組み込みリストを持っているからです。空のセル、NA、N/A、n/a、NaN、null、Noneなどです。-、?、missing、0、unknownはそのリストに含まれていません。データを入力した人が「分からない」を表すために何を使ったかによって、どちらの種類の欠損になるかが決まります。
隠れた欠損を本物のNaNに変換する方法は2つあります。
修正方法1:read_csvにマーカーを教える——na_values=
マーカーが分かっている場合、最もすっきりとした修正方法は読み込みの瞬間に指定することです。na_values=を使うと、組み込みリストに独自の文字列を追加できます。
orders = pd.read_csv("orders_raw.csv", na_values=["-"])
print(orders["price"].dtype)
print(orders.isna().sum())float64
order_id 0
date 0
branch 1
product 0
category 0
quantity 1
price 2
dtype: int64前のカウントと比較してみてください。branchとquantityは変わっていません——na_values=はリストを置き換えるのではなく追加するため、空のセルやN/Aも引き続き認識されます。変わったのはpriceです。現在はfloat64になり、欠損として2件報告されています——N/Aと-の両方が本物のNaNになりました。列が再び数値型になったため、算術演算が本来の計算として機能するようになります。
なお、na_values=["-"]はすべての列に適用されることに注意してください。ここではそれが望ましい動作です。もし他の場所(たとえば商品コードなど)で-が本物の値になり得る場合は、代わりに辞書を渡してna_values={"price": ["-"]}と指定すれば、price列だけに適用されます。
修正方法2:読み込み後に変換する——pd.to_numeric(errors="coerce")
列の中にどんな奇妙な文字列が入っているかが事前に分からない場合や、他の誰かが書いたコードでファイルがすでに読み込まれている場合もあります。その場合は、読み込んだ後に列を変換します。まずは、通常の変換で何が起こるかを確認してみましょう。
raw = pd.read_csv("orders_raw.csv")
price = pd.to_numeric(raw["price"])ValueError: Unable to parse string "-" at position 5このエラーは有用です。問題のある値とその位置を教えてくれます。errors="coerce"を追加すると、「数値に変換できないものはすべてNaNにする」という指示になります。
price = pd.to_numeric(raw["price"], errors="coerce")
print(price)0 15.0
1 60.0
2 850.0
3 NaN
4 8.0
5 NaN
6 15.0
7 120.0
Name: price, dtype: float64表を実際に変更するには、結果を列に再代入します——to_numericは新しいSeriesを返し、raw自体は変更しないためです。
raw["price"] = pd.to_numeric(raw["price"], errors="coerce")
print(raw["price"].dtype, raw["price"].isna().sum())float64 2どちらの修正方法を選ぶべきか
errors="coerce"は強力である反面、大雑把でもあります。数値ではないものをすべてNaNに変換してしまいます——誤って1O0(文字のO)と入力された本物の価格や、桁区切りのカンマが入った1,200なども含まれます。これらは欠損値ではなくタイポ(誤入力)のある値であり、強制変換(coerce)すると情報が静かに捨てられてしまいます。
そのため、習慣にすべきなのは、変換前にカウントし、変換後にカウントし、その差を説明することです。
- マーカーが分かっている場合(
-、?、missing):read_csvでna_values=を使用します。指定した文字列だけが欠損になるため、正確です。 - 正体不明のゴミ値があり、列を数値型にしなければならない場合:
pd.to_numeric(errors="coerce")を使用します。あらゆるものを拾い上げるため、何が拾われたのかを確認してください。 - 修復可能なタイポが含まれる場合:まず
unique()を確認し、修正してから変換します。強制変換すると本物のデータが破壊されてしまいます。
強制変換する前、raw["price"].unique()には数値以外の文字列がちょうど1つ('-')とnanが1つありました。強制変換した後には、ちょうど2つのNaNが存在します。数字が一致しているため、それ以外のデータは失われていません。この確認こそが、データを綺麗にすることとデータを損なうことの決定的な違いです。
ここからは、na_values=["-"]で読み込んだバージョンを使用します。
計算におけるNaNの扱い
欠損が本物のNaNになったところで、pandasがそれらをどう扱うかを知っておく必要があります——pandasは処理を止めて尋ねてくれたりはしないからです。
列の集計ではNaNがスキップされる
orders = pd.read_csv("orders_raw.csv", na_values=["-"])
q = orders["quantity"]
print("len :", len(q))
print("count:", q.count())
print("sum :", q.sum())
print("mean :", q.mean())
print("check:", q.sum() / q.count())len : 8
count: 7
sum : 76.0
mean : 10.857142857142858
check: 10.857142857142858覚えておくべき3つの動作があります。
lenは行数を数え、count()は値の個数を数える。 両者の差が欠損の個数です。これはinfo()におけるNon-Null Countと同じ考え方です。sum()はNaNをスキップする。 判明している7件の数量を合算します。これは理にかなっていますが、「76」はすべての注文の合計ではなく、判明している数量の合計であることを意味します。mean()はlenではなくcount()で割る。 平均は判明している7件の値に基づいて計算されます。通常はこれで問題ありません。欠損を0として扱うと、理由もなく平均が引き下げられてしまうからです。
何かが欠損している場合にそれを知らせてほしいときは、skipna=Falseを指定すると、1つでも値が欠損していれば結果が即座にNaNになります。
print(q.sum(skipna=False))nan列同士の算術演算ではNaNが広がる
集計処理では欠損がスキップされますが、算術演算では欠損が広がります。NaNと組み合わされたものはすべてNaNになります。「7 × 不明な価格」はそれ自体が不明だからです。
revenue = orders["quantity"] * orders["price"]
print(revenue)
print("total:", revenue.sum())
print("rows counted:", revenue.count(), "of", len(revenue))0 180.0
1 NaN
2 1700.0
3 NaN
4 240.0
5 NaN
6 300.0
7 480.0
dtype: float64
total: 2900.0
rows counted: 5 of 8この2つの動作を組み合わせると、章の冒頭にあった罠の正体が見えてきます。revenue.sum()は何の警告もなく2900.0を出力しましたが、8件の注文のうち3件はまったく計算に寄与していませんでした。注文1002はquantityが欠損しており、1004と1006はpriceが欠損しています。「8件中5件の注文」と明記して初めて、この合計は誠実なものになります。計画段階ですべての合計の隣に件数を表示することを求めたのはそのためです。
欠損値を削除する:dropna()
dropna()はNaNを含む行を削除します。引数を指定しない場合、いずれかの列に何らかの欠損があるすべての行を削除します。
print(orders.shape)
print(orders.dropna().shape)(8, 7)
(5, 7)8行中3行が消えてしまいました——そして注文1004はbranchが欠損していたために削除されましたが、数量に関する問いに対してはまったく問題のない行でした。これが引数なしの単体(bare)のdropna()の問題点です。「不完全なものをすべて削除する」という処理を行いますが、それが求められているケースは稀です。
subset=——問いに必要な列だけを指定する
どの列が存在していなければならないかを指定します。
known_qty = orders.dropna(subset=["quantity"])
print(known_qty.shape)
print(known_qty["quantity"].sum())(7, 7)
76.0注文1002だけが削除されました——quantityが本当に不明である唯一の行です。売上の計算には両方の列が必要です。
countable = orders.dropna(subset=["quantity", "price"])
print(countable[["order_id", "quantity", "price"]])order_id quantity price
0 1001 12.0 15.0
2 1003 2.0 850.0
4 1005 30.0 8.0
6 1007 20.0 15.0
7 1008 4.0 120.0インデックスに注目してください。0, 2, 4, 6, 7となっています。フィルタリングと同様に、dropna()は元のラベルを保持するため、いつでもファイルを遡って元の行を追跡できます。再び0, 1, 2…が必要な場合は、.reset_index(drop=True)を追加してください。
how="all"——すべての値が完全に空の行のみを削除する
エクスポートされたスプレッドシートの末尾には、すべての列が空白になっている行が数行含まれていることがよくあります。how="all"はそうした行だけを削除し、値が1つでもある行はそのまま残します。
print(orders.dropna(how="all").shape)(8, 7)ここでは何も削除されませんでした——完全に空の行は1つも存在しませんでした——まさにそれを確認したかったわけです。
dropna()は新しい表を返す
多くのpandasメソッドと同様に、dropna()はordersを変更しません。新しいDataFrameを返します。単独の行でorders.dropna()と書いても、ordersには何も起こりません。結果を変数に代入してください——今回のレポートのように元の表全体がまだ必要な場合(何行が削除されたかを報告するため)は、新しい変数名に代入します。
欠損値を置き換える:fillna()
欠損に対して妥当な代替値が存在する場合もあります。fillna()はNaNがあった場所に値を入れます。
1つの列に対して1つの値で埋める
branchの欠損が最も分かりやすい例です。注文は実際に行われ、売上金額も本物です。どの支店で受け付けたかが分からないだけです。ラベルで埋めることで行を保持でき、後から支店ごとに集計した際にも欠損を独立したグループとして可視化できます。
orders["branch"] = orders["branch"].fillna("Unknown")
print(orders["branch"].tolist())['north', 'south', 'north', 'Unknown', 'north', 'south', 'east', 'north']この行の書き方は重要です。列を取り出し、fillnaを実行し、同じ列に再代入します。fillnaは新しいSeriesを返すため、先頭にorders["branch"] =がないと何も保存されません。
列ごとに異なる値で埋める:辞書を使う
{列名: 値}の辞書を渡すことで、複数の列をそれぞれの値で一度に埋めることができます。指定しなかった列はそのまま残されます。
orders = pd.read_csv("orders_raw.csv", na_values=["-"])
filled = orders.fillna({"branch": "Unknown", "quantity": 0})
print(filled.isna().sum())order_id 0
date 0
branch 0
product 0
category 0
quantity 0
price 2
dtype: int64priceについては触れなかったため、依然として2つの欠損が残っています。これが、価格を含めたすべての列を0で埋めてしまうorders.fillna(0)に対する辞書形式の利点です——価格0は無料のバッグを意味してしまいます。
補完すると答えが変わる
quantityを0で埋めることは一見無害に思えました。平均値を比較してみましょう。
print("skip the gap :", orders["quantity"].mean())
print("fill with 0 :", orders["quantity"].fillna(0).mean())
print("fill with median:", orders["quantity"].fillna(orders["quantity"].median()).mean())skip the gap : 10.857142857142858
fill with 0 : 9.5
fill with median: 10.3751つの列から3つの異なる平均値が得られました。唯一の違いは、未知の数量が何を意味すると判断したかです。
- 0は「この注文では何も売れなかった」とみなします。確実に何かが売れたノートの注文(注文1002)にとってこれは誤りであり、平均を引き下げてしまいます。
- 中央値(ここでは7)は「典型的な注文だったと仮定する」とみなします。平均はおおむね維持されますが、合計には誰も数えていない7冊のノートが含まれることになります。
- スキップは「分からないので除外する」とみなします。平均は判明している7件の注文に基づいて計算されます。
これらのどれが一般的に「正しい」ということはありません。それぞれが少しずつ異なる問いに答えています。だからこそ、その場の思いつきのデフォルトに頼るのではなく、計画の中に判断を書き記しておく必要があるのです。
ほとんどの場合に役立つ目安は次の通りです。
- ラベル(branch、category、name):
"Unknown"などで埋める。行は保持され、欠損は独自のグループとして可視化されたままになります。 - 空が「なし」を真に意味するカウント(返品数、苦情数):
0で埋める——ただし、データ元において空白が本当にゼロを意味している場合に限ります。 - 測定値や金額(販売数量、価格、点数):
NaNのまま残し、計算から除外して、何件が除外されたかを報告する。捏造された数値は捏造された合計を生み出します。 - 問いに必要な列で、大半が存在している場合:その問いに対してのみ
dropna(subset=[...])を行う。わずかな損失で誠実な答えが得られます。
整数に戻す
埋めた後のquantityには欠損がありませんが、依然としてfloat64のままです——pandasが自動的に元の型に戻してくれることはありません。NaNが残っていなければ、astype(int)が動作します。
filled["quantity"] = filled["quantity"].astype(int)
print(filled["quantity"].tolist())
print(filled["quantity"].dtype)[12, 0, 2, 7, 30, 1, 20, 4]
int64埋める前にこれを試すと失敗します。「不明」を意味する整数が存在しないためです——後述の「問題が起きたとき」を参照してください。
完全な例
clean_orders.pyは、「コードを書く前に」で立てた計画を1つのプログラムにまとめたものです。既知のマーカーを指定して読み込み、すべての欠損を報告し、branchを埋め、集計できない行を除外し、何件の注文をカバーしているかとともに合計を報告します。
import pandas as pd
orders = pd.read_csv("orders_raw.csv", na_values=["-"])
# 1. Look first
print("Rows, columns:", orders.shape)
missing = orders.isna().sum()
print("Missing per column:")
print(missing[missing > 0])
print()
# 2. Decide per column: branch -> label; quantity and price -> keep, exclude
orders["branch"] = orders["branch"].fillna("Unknown")
# 3. Only rows with both numbers can be counted for revenue
countable = orders.dropna(subset=["quantity", "price"])
skipped = orders[orders[["quantity", "price"]].isna().any(axis=1)]
revenue = countable["quantity"] * countable["price"]
print("Orders counted for revenue:", len(countable), "of", len(orders))
print("Total quantity (known): ", int(orders["quantity"].sum()))
print("Total revenue (counted): ", revenue.sum())
print()
print("Not counted - check these with the branch:")
print(skipped[["order_id", "branch", "quantity", "price"]])Rows, columns: (8, 7)
Missing per column:
branch 1
quantity 1
price 2
dtype: int64
Orders counted for revenue: 5 of 8
Total quantity (known): 76
Total revenue (counted): 2900.0
Not counted - check these with the branch:
order_id branch quantity price
1 1002 south NaN 60.0
3 1004 Unknown 7.0 NaN
5 1006 south 1.0 NaNこのように書かれている理由:
na_values=["-"]が最初の行にあること。 マーカーはunique()を確認することで一度見つけられました。read_csvの中に記述しておくことで、列が最初から数値型になり、プログラムを読む人全員にこのファイルがどのマーカーを使っているかが伝わります。missing[missing > 0]は欠損がある列だけを出力します。40列あるファイルでは、これによって読みやすいレポートになるかゼロの壁になるかの違いが生まれます。- branchは埋められ、数値は埋められていないこと。 これは計画で決めた判断リストがコードとして表現されたものです。読み手がその判断に異論を持つことはあっても、何が行われたかは一目瞭然です。
dropna(subset=...)に売上計算が必要とする2つの列を指定していること。 これにより、注文1004は欠損したbranchのせいではなく、欠損したpriceのせいで除外されます。- スキップされた行が単にカウントされるだけでなく出力されていること。 「3件の注文が集計されませんでした」と言われれば、「どれが?」という疑問が当然湧きます——それをレポートの中で答えておきます。この3件の注文は、誰かが実際に確認して修正できるリストになります。
- 「Total quantity (known)」と「(counted)」で数値の意味を明確にしていること。 どちらも下限値であり、ラベルを付けることで完全な数値として誤認されるのを防ぎます。
問題が起きたとき
TypeError: Cannot perform reduction 'mean' with string dtype pandasが文字列として読み込んだ列に対して平均を求めた場合に発生します——通常、セルの中に-、?、missingのようなマーカーが1つ含まれていることが原因です。df["col"].unique()を実行して原因となっている値を見つけ、na_values=["-"]を指定して再読み込みするか、pd.to_numeric(df["col"], errors="coerce")で変換してください。
raw = pd.read_csv("orders_raw.csv")
print(raw["price"].mean())TypeError: Cannot perform reduction 'mean' with string dtypesum()が15.060.0850.0...のような長い数字の文字列を返した 原因は上記と同じですが、症状はさらに厄介です。文字列の列に対してsum()を実行すると、数値を加算する代わりに文字列を連結してしまい、エラーも一切発生しません。そのような見た目になった合計は、例外なくstr列から生じたものです。合計を計算する前にdf.dtypesを確認してください。
ValueError: Unable to parse string "-" at position 5 pd.to_numericが変換できない文字列に遭遇し、何がどの位置にあるかを正確に通知しています。errors="coerce"を追加するか(事前にunique()で確認し、その奇妙な値がタイポではなく本当に「不明」マーカーであることを確かめた上で)、先にタイポを修正してください。
(df["col"] == np.nan).sum()が0になるが、実際には欠損がある NaNは何とも等しくなく、自分自身とさえ等しくないため、== np.nanはどのセルにも一致しません。df["col"].isna()を使用してください。
IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer NaNがまだ残っている列に対してastype(int)を呼び出した場合に発生します。「不明」を意味する整数は存在しません。先に欠損を埋めるか削除してから変換してください。
orders = pd.read_csv("orders_raw.csv", na_values=["-"])
orders["quantity"] = orders["quantity"].astype(int)pandas.errors.IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer.Replace or remove non-finite values or cast to an integer typethat supports these values (e.g. 'Int64')(「integer.Replace」や「typethat」のスペースの欠落は、pandas自身のエラーメッセージによるものです。)メッセージには大文字のIを使った'Int64'についても言及されています。これは欠損値を保持できるpandas独自の整数型です。便利ではありますが、先に埋めるか削除する方がより単純な修正方法であり、まず検討すべきアプローチです。
ChainedAssignmentErrorが発生し、欠損が残ったままになっている 古いチュートリアルでよく見かけるinplace=Trueの形式を書いた場合に発生します。
orders = pd.read_csv("orders_raw.csv", na_values=["-"])
orders["quantity"].fillna(0, inplace=True)
print(orders["quantity"].isna().sum())1
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment using an inplace method.
Such inplace method 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).
For example, when doing 'df[col].method(value, inplace=True)', try using 'df.method({col: value}, inplace=True)' instead, to perform the operation inplace on the original object, or try to avoid an inplace operation using 'df[col] = df[col].method(value)'.
See the documentation for a more detailed explanation: https://pandas.pydata.org/pandas-docs/stable/user_guide/copy_on_write.htmlカウントは依然として1のままです。何も埋められていません。pandas 3では、orders["quantity"]は常に独立したコピー(Copy-on-Write)として振る舞うため、「インプレース(in place)」で埋めるとそのコピーが変更され、その直後に破棄されてしまいます。古いpandasではこれが機能することがあったため、ネット上にはこのパターンが溢れています。代わりに代入式として明示的に書いてください——すべてのバージョンで動作し、何が変更されるのかが明確になります。
orders["quantity"] = orders["quantity"].fillna(0)
print(orders["quantity"].isna().sum())0dropna()が予想よりもはるかに多くの行を削除してしまった 引数なしのdropna()は、いずれかの列に欠損がある行を削除します——使ってもいないほとんど空のnotes列やdiscount列なども含まれます。df.isna().sum()を出力して欠損の多い列を特定し、問いに必要な列だけを指定してdropna(subset=[...])を使用してください。
TypeError: can't multiply sequence by non-int of type 'float' 数値列と文字列列を掛け算した場合に発生します(priceがstrの状態でquantity * priceを実行した場合)。修正方法は最初のエラーと同じです。まずpriceを数値型にしてください。
ステップ 4 / 6 — 予想する
理解度をチェック
1つのquantityが空白です。何が出力されますか?
from io import StringIO
import pandas as pd
# Stands in for orders_raw.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,,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,,bottle,accessories,7,N/A
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,-
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["quantity"].sum(), orders["quantity"].count(), len(orders))- A76.0 7 8
- B76.0 8 8
- C76 8 8
- Dnan 7 8
価格のない注文を見つけることが目的です。2件の価格が欠損しています。何が出力されますか?
from io import StringIO
import pandas as pd
# Stands in for orders_raw.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,,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,,bottle,accessories,7,N/A
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,-
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), na_values=["-"])
missing_price = orders[orders["price"] == float("nan")]
print(len(missing_price))- A0 — `NaN`は何とも等しくなく、`NaN`とさえ等しくないため、`==`では決して見つからない
- B1
- C`TypeError`:`nan`とは比較できない
- D2
あるセルに-、別のセルにN/Aと入っていたため、price列がstrとして読み込まれました。両方の欠損をNaNにして数値に変換する行はどれですか?
- Aorders["price"] = pd.to_numeric(orders["price"], errors="coerce")
- Borders["price"] = orders["price"].fillna(0)
- Corders["price"] = orders["price"].astype(float)
- Dorders["price"] = orders["price"].dropna()
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
店舗で出前(デリバリー)が始まり、第1週の配達員ログがdeliveries.csvとして届きました。まったく同じ内容で作成してください。
order_id,branch,distance_km,fee,rider
2001,north,3.5,60,R1
2002,north,,60,R2
2003,south,8.0,?,R1
2004,east,12.0,120,
2005,north,2.0,40,R2
2006,south,6.5,90,?
2007,east,,110,R3
2008,north,4.0,60,R3オーナーからの質問は次の通りです。配達料としていくら回収できたか、配達料が欠損している配達は何件あるか、そして1キロメートルあたりの平均配達料はいくらか?
コードを書く前に、unique()を使ってファイルを確認し、隠れたマーカーが何であるかを判断してください。その上で、次の処理を行うdeliveries_report.pyを作成してください。
- 隠れたマーカーが出現するすべての列において、それが本物の欠損値になるようにファイルを読み込む
- 列ごとの欠損値の個数と、不完全な行を出力する
- 欠損している
riderを"unassigned"で埋める - 判明している配達料の合計、配達料のない配達の件数、そして配達料と配達距離の両方が判明している配達のみから計算された1キロメートルあたりの配達料(合計配達料 ÷ 合計距離)を出力する
プログラムが出力する最後の3行は、厳密に次のようになる必要があります。
Fees collected (known): 540.0
Deliveries with no fee: 1
Fee per km: 13.21 from 5 of 8 deliveriesまた、コードの前に、「コードを書く前に」と同様の判断リストを書き出してください。distance_km、fee、riderのそれぞれについて、欠損は何を意味し、埋めるのか、削除するのか、それともそのまま残すのか?
その後、1つの実験を試してみてください。dropna(subset=...)の代わりに引数なしのdropna()を使って1キロメートルあたりの配達料を計算してみます。数値は変わりますか? 何行が使用され、それはなぜですか?
解答
まずは計画から。 問いには3つの部分があります。合計、欠損のカウント、そして比率です。info()を読み込むとfeeがstrであることが分かります——配達料の列は数値であるはずなので、文字列が隠れています。列ごとの判断:
rider— 配達は行われましたが、配達員が記録されませんでした。"unassigned"で埋める。fee— いくら請求されたのかが分かりません。NaNのまま残す。判明している配達料を合計し、不明な配達料をカウントする。distance_km— 距離が分かりません。NaNのまま残す。キロメートルあたりの比率の計算からそれらの行を除外する。
比率の計算においては、同じ配達について両方の数値が判明していなければなりません——そうでなければ、7件の配達料を6件の配達距離で割ることになってしまいます。
ステップ1 — 通常通りに読み込んで確認し、マーカーを見つける:
import pandas as pd
raw = pd.read_csv("deliveries.csv")
print(raw.shape)
print(raw.dtypes)
print(raw["fee"].unique())
print(raw["rider"].unique())(8, 5)
order_id int64
branch str
distance_km float64
fee str
rider str
dtype: object
<StringArray>
['60', '?', '120', '40', '90', '110']
Length: 6, dtype: str
<StringArray>
['R1', 'R2', nan, '?', 'R3']
Length: 5, dtype: str?があるため、feeはstrになっています。そして?は配達員(rider)としても出現しています——同じマーカーが2つの列で使われています。distance_kmはすでにfloat64です。その欠損は空のセルであり、pandasが自動的に認識したからです。マーカーがどこでも同じであるため、読み込み時にna_values=["?"]を指定すれば両方の列を一度に修正できます。
プログラム全体、deliveries_report.py:
import pandas as pd
deliveries = pd.read_csv("deliveries.csv", na_values=["?"])
print("Types after reading:")
print(deliveries.dtypes)
print()
print("Missing per column:")
print(deliveries.isna().sum())
print()
print("Incomplete rows:")
print(deliveries[deliveries.isna().any(axis=1)])
print()
deliveries["rider"] = deliveries["rider"].fillna("unassigned")
fee_total = deliveries["fee"].sum()
fee_missing = deliveries["fee"].isna().sum()
print("Fees collected (known):", fee_total)
print("Deliveries with no fee:", fee_missing)
both = deliveries.dropna(subset=["fee", "distance_km"])
per_km = both["fee"].sum() / both["distance_km"].sum()
print("Fee per km:", round(per_km, 2), "from", len(both), "of", len(deliveries), "deliveries")Types after reading:
order_id int64
branch str
distance_km float64
fee float64
rider str
dtype: object
Missing per column:
order_id 0
branch 0
distance_km 2
fee 1
rider 2
dtype: int64
Incomplete rows:
order_id branch distance_km fee rider
1 2002 north NaN 60.0 R2
2 2003 south 8.0 NaN R1
3 2004 east 12.0 120.0 NaN
5 2006 south 6.5 90.0 NaN
6 2007 east NaN 110.0 R3
Fees collected (known): 540.0
Deliveries with no fee: 1
Fee per km: 13.21 from 5 of 8 deliveries一度手計算で数値を確認してみましょう。それによってクリーニングされた合計値を信頼できるようになります。判明している配達料:60 + 60 + 120 + 40 + 90 + 110 + 60 = 540(7件の配達から——2003には配達料がありません)。比率については、dropna(subset=["fee", "distance_km"])が2003(配達料なし)と2002、2007(距離なし)を除外します。暗算を過信するのではなく、どの行が残り、それらの合計がいくらになるかをpandasに尋ねてみましょう。
print(both["order_id"].tolist())
print(both["fee"].sum(), both["distance_km"].sum())[2001, 2004, 2005, 2006, 2008]
370.0 28.0370 / 28.0 = 13.21 となり、プログラムが出力した数値と一致します。比率の計算で行われなかったことに注目してください。7件の配達料である540を6件の配達距離で割るようなことはしていません。両方の数値は同じ5行から得られており、これこそが「1キロメートルあたり」という数値が意味を持つ唯一の方法です。
さらに注目すべき2つの判断があります。riderは計算の前に埋められましたが、計算には何の影響も与えていません——subset=はfeeとdistance_kmだけを見ていたからです。また、配達料の合計には「(known)」というラベルが付けられ、欠損している配達料の件数がその真下に出力されているため、540が集計されたすべてであると誤解されるのを防いでいます。
実験。 引数なしの単体のdropna()は、配達料と距離のどちらにも問題がなかった2004と2006も削除してしまいます。配達員(rider)が欠損していたためです。riderを埋める前の表で実行してみると、次のようになります。
deliveries = pd.read_csv("deliveries.csv", na_values=["?"])
strict = deliveries.dropna()
print(strict["order_id"].tolist())
print(round(strict["fee"].sum() / strict["distance_km"].sum(), 2))[2001, 2005, 2008]
16.843件の配達しか残らず、比率は13.21から16.84へと跳ね上がります——配達料が変わったからではなく、1キロメートルあたりで見ると割安であった最も長い2件の配達(12kmと6.5km)が、名前の欠損を理由に捨てられてしまったためです。rider列は問いとは何の関係もないため、どの行を計算に含めるかを決定づけるべきではありません。それこそがsubset=の目的です。