テーブルデータ処理に悩むあなたに朗報!Polarsの使い方を徹底解説 その2:重複・欠損処理編、ほか

2023.02.17

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんちには。

データアナリティクス事業本部 機械学習チームの中村です。

本記事では、世間でも話題となっているPolarsについて基本的な使い方を抑えていく記事のその2です。

私自身「データサイエンス100本ノック」をPolarsで一通り実施しましたので、それを元に実践に必要な使い方とノウハウをご紹介します。

本記事でPolarsの使い方とノウハウを習得し、実践的なテクニックを身につけて頂ければと思います。

なお、前回記事は以下となります。

本記事の内容

本記事では前回触れられなかった以下を見ていきます。

  • 遅延評価
  • ユニーク処理・重複の対応
  • 欠損値の対応
  • 数学関数
  • データ処理(ダミー変数化、pivot、サンプリングなど)

APIリファレンスは以下を参照ください。

使用環境

Google Colab環境でやっていきます。特にスペックは限定しません。

セットアップ

pipでインストールするのみで使用できるようになります。

!pip install polars

執筆時点ではpolars-0.16.5がインストールされました。

インポートはplという略称が使われているようです。

import polars as pl

ここからサンプルとしてデータサイエンス100本ノックのcsvデータを使用します。

※本記事は、「データサイエンティスト協会スキル定義委員」の「データサイエンス100本ノック(構造化データ加工編)」を利用しています※

以降は、以下のファイルが配置されている前提として進めます。動かしてみたい方はダウンロードされてください。

./category.csv
./customer.csv
./geocode.csv
./product.csv
./receipt.csv
./store.csv

以降のコードを動かす際は、以下のようにread_csvしたと仮定して進めます。

dtypes = {
    'customer_id': str,
    'gender_cd': str,
    'postal_cd': str,
    'application_store_cd': str,
    'status_cd': str,
    'category_major_cd': str,
    'category_medium_cd': str,
    'category_small_cd': str,
    'product_cd': str,
    'store_cd': str,
    'prefecture_cd': str,
    'tel_no': str,
    'postal_cd': str,
    'street': str,
    'application_date': str,
    'birth_day': str
}
df_category = pl.read_csv("category.csv", dtypes=dtypes)
df_customer = pl.read_csv("customer.csv", dtypes=dtypes)
df_geocode  = pl.read_csv("geocode.csv", dtypes=dtypes)
df_product  = pl.read_csv("product.csv", dtypes=dtypes)
df_receipt  = pl.read_csv("receipt.csv", dtypes=dtypes)
df_store    = pl.read_csv("store.csv", dtypes=dtypes)

pl.Series関連の操作

pl.Seriesの抽出: pl.DataFrame.get_column

前回記事ではあまりpl.Seriesは登場していませんでしたが、以下でpl.DataFrameから取り出すことが可能です。

df_receipt.get_column("amount")

また上記でカラム名をリストで与えると、取得されるものがpl.DataFrameとなりますので、そこは注意が必要です。

すべての列をそれぞれpl.Seriesで抽出: pl.DataFrame.get_columns

pl.DataFrame.get_columnsでそれぞれの列がpl.Seriesとなったリストを取得できます。

series_list = df_receipt.get_columns()

ここからカラムに対応するindex番号をpl.DataFrame.find_idx_by_nameで取得して、pl.Seriesを取得できます。

df_receipt.get_columns()[
    df_receipt.find_idx_by_name("sales_ymd")
]

ユニーク処理・重複の対応

ユニークな行の抽出: pl.DataFrame.unique

pl.DataFrame.uniqueを使ってsubsetで指定したカラムについてユニークなレコードを抽出可能です。

df_receipt.unique(subset=["customer_id"])

重複は削除された状態で表示されます。

削除されないレコードの判定基準は、デフォルトではkeep='first'となっており、最初にあるレコードが残される形となります。keepには'first', 'last', 'none'のいずれかを設定可能です。

なお、subsetには複数カラムを指定することが可能です。

df_receipt.unique(subset=["customer_id", "product_cd"])

pandasでは、drop_duplicatesというメソッドでしたので、ここは混乱しないように注意が必要です。

ユニーク数: pl.Expr.n_unique

ユニーク数は、pl.Expr.n_unique()またはpl.Expr.unique().count()で取得可能です。

df_receipt.select([
    pl.col("receipt_no").n_unique()
])

ユニークな値を取り出す: pl.Series.unique

一旦、pl.Seriesを取り出してからuniqueを実行することで、ユニークな値を得ることができます。

df_receipt.get_column("store_cd").unique()

ここからは逆に重複の判定を見ていきます。

レコード全体が完全に重複しているレコードを抽出: pl.DataFrame.is_duplicated

pl.DataFrame.is_duplicatedは、全カラムが完全に重複しているかどうかの真偽値を取得します。

よって以下のようにpl.DataFrame.filterと組み合わせると、完全に一致したレコードのみを抽出することができます。

df_receipt.filter(
    df_receipt.is_duplicated()
)

ある単一カラムが重複しているレコードの抽出: pl.Expr.is_duplicated

逆に特定のカラムが重複しているかどうかは、pl.DataFrame.filter内でpl.Expr.is_duplicatedを使用する必要があります。

以下は誕生日が重複している"customer_name"を抽出する処理です。

df_customer.select([
    "customer_name", "birth_day"
]).filter(
    (pl.col("birth_day").is_duplicated())
).sort("birth_day")

複数カラムが重複しているレコードの抽出

複数カラムが重複する場合は、少し工夫が必要です。

pl.DataFrame.selectfilterおよび全体の重複を見るpl.DataFrame.is_duplicatedを組み合わせる必要があります

以下は誕生日と郵便番号が重複している"customer_name"を抽出する処理です。

df_customer.select([
    "customer_name", "birth_day", "postal_cd"
]).filter(
    df_customer.select(["birth_day", "postal_cd"]).is_duplicated()
).sort("birth_day")

以下のように単一カラムの場合と同様に記述すると、それぞれのカラムが独立して重複判定されてしまいます。

# NGな例
df_customer.select([
    "customer_name", "birth_day", "postal_cd"
]).filter(
    (pl.col("birth_day").is_duplicated())
    & (pl.col("postal_cd").is_duplicated())
).sort("birth_day").head(10)

重複の対応にまつわる補足

この辺りの記述の複雑さは、pl.DataFrame.is_duplicatedsubset引数を使えるようになれば、改善の余地がありそうです。

現段階ではpl.DataFrame.selectfilterおよびpl.DataFrame.is_duplicatedを組み合わせた記述が最も汎用性が高い記述方法かなと感じました。

ユニーク判定: pl.Expr.is_uniquepl.DataFrame.is_unique

pl.Expr.is_uniquepl.DataFrame.is_uniqueはそれぞれ、pl.Expr.is_duplicatedpl.DataFrame.is_duplicatedの真偽が逆転したものなので割愛します。

欠損値対応

Intでもnullを扱い可能

Polarsでは欠損値をInt型でも扱うことが可能です。

df = pl.DataFrame({
    "a": [1,2,3,4]
    , "b": [1,2,3,None]
})
df.dtypes
[Int64, Int64]

pandasでは、Noneを入れた時点でデータ型がfloat扱いになってしまいます。

# pandasの場合
df = pd.DataFrame({
    "a": [1,2,3,4]
    , "b": [1,2,3,None]
})
df.dtypes
a      int64
b    float64
dtype: object

nullとNaNの違い

NumPyのnp.nanを要素に入れた場合はNaN扱い、Noneの場合はnullとして区別して扱われます。(pandasではどちらもNaN扱いです)

またNaNはInt型では表現できないため、Float型になってしまう点は注意が必要です。

import numpy as np

df = pl.DataFrame({
    "a": [1,2,3,4]
    , "b": [1,2,3,None]
    , "c": [1,2,3,np.nan]
})
print(df.dtypes)
df
[Int64, Int64, Float64]

Polarsでは区別して扱うため、以降の判定等のメソッドでも区別されます。

欠損値のカウント: pl.DataFrame.null_count

各カラムの欠損値をpl.DataFrame.null_countで数えることができます。(なお、nan_countは存在しないため注意が必要)

df_product.null_count()

pandasの場合は、isnull().sum()という形で求める必要がありました。

# pandasで同等処理をする例
df_product.isnull().sum()

欠損かどうか: pl.Expr.is_null, pl.Expr.is_nan

is_duplicatedis_uniqueなどと違い、is_nullpl.DataFrameには存在せず、pl.Exprのみに存在します。

df_product.filter(
    (pl.col("unit_cost").is_null())
    | (pl.col("unit_price").is_null())
)

なお、is_nanについてもほぼ同様の使い方となっていますので、割愛します。

DataFrame全体への欠損値埋め: pl.DataFrame.fill_null, pl.DataFrame.fill_nan

欠損値埋めはDataFrame全体への処理が可能です。pl.DataFrame.fill_nullが適用可能です。

df_product.fill_null(strategy='mean')

fill_nullvalue引数に固定値を指定するか、strategy引数に'forward', 'backward', 'min', 'max', 'mean', 'zero', 'one'いずれかを指定することで埋めることができます。

同様の処理がpl.DataFrame.fill_nanとしてありますが、こちらはstorategyが指定できないため注意が必要です。

しかしfill_nanは固定値埋め以外に、以下のように別の列の値で埋めることができます

df = pl.DataFrame(
    {"A": [1, 2, 3], "B": [np.nan, np.nan, np.nan]}
)
df.fill_nan(pl.col("A"))

逆に別の列の値で埋めることは、fill_nullではできないようです。

df = pl.DataFrame(
    {"A": [1, 2, 3], "B": [None, None, None]}
)
df.fill_null(pl.col("A"))

カラムに対する欠損値埋め: pl.Expr.fill_null, pl.Expr.fill_nan

カラムに対してはpl.Expr.fill_nullを使って埋めることができます。

カラムに対する場合は、以下のように別の列の値で埋めることも可能です。

df = pl.DataFrame(
    {"A": [1, 2, 3], "B": [None, None, None]}
)
df.select([
    pl.col("A")
    , pl.col("B").fill_null(pl.col("A"))
])

pl.Expr.fill_nanもほぼ同様ですが、strategy引数はないため注意が必要です。

動的な欠損値埋め: pl.coalesce

pl.coalesceであればnullだった場合には埋めるなどというSQLクエリと同様な処理が可能です。

以下のようにpl.coalesceを複数列指定してつなげることにより、より複雑な欠損値処理を行うことができます。

df = pl.DataFrame(
    {"A": [1, 2, 3], "B": [4, 5, None], "C": [None, None, None]}
)
df.select(
    pl.coalesce([
        pl.col("C"), pl.col("B"), pl.col("A")
    ])
)

欠損値の削除: pl.DataFrame.drop_nulls

pl.DataFrame.drop_nullsで欠損値がカラムに一つでもあるレコードは削除できます。

df_product.drop_nulls()

転置: pl.DataFrame.transpose

pl.DataFrame.transposeによって、カラム方向とレコード方向を入れ替えることができます。

df_product.null_count().transpose(include_header=True)

転置後にカラム名も含めたい場合は、引数をinclude_header=Trueとする必要があります。

遅延評価

遅延評価と実行: pl.DataFrame.lazy, pl.DataFrame.collect

lazyを使うことで、collectされるまで評価を遅らせることができます。途中結果を変数に格納する場合でも有効です。

以下は少し複雑な例題ですが、冒頭にlazyを使い、最後にcollectで結果を取得します。

P-039: レシート明細データフレーム(df_receipt)から売上日数の多い顧客の上位20件と、売上金額合計の多い顧客の上位20件を抽出し、完全外部結合せよ。ただし、非会員(顧客IDが"Z"から始まるもの)は除外すること。

df_data = df_receipt.lazy().filter(
    pl.col('customer_id').str.starts_with('Z').is_not()
)

df_cnt = df_data.groupby('customer_id').agg(
    pl.col('sales_ymd').n_unique()
).sort('sales_ymd', reverse=True).head(20)

df_sum = df_data.groupby('customer_id').agg(
    pl.col('amount').sum()
).sort('amount', reverse=True).head(20)

df_cnt.join(df_sum, how='outer', on='customer_id').collect()

入力に複数のDataFrameを使う場合は、以下の例題のように双方ともにlazyを使用する必要があります。

P-053: 顧客データフレーム(df_customer)の郵便番号(postal_cd)に対し、東京(先頭3桁が100〜209のもの)を1、それ以外のものを0に2値化せよ。さらにレシート明細データフレーム(df_receipt)と結合し、全期間において売上実績がある顧客数を、作成した2値ごとにカウントせよ。

df_customer.lazy().select([
    "customer_id"
    , pl.when(
        pl.col("postal_cd").str.slice(0,3)
            .cast(pl.Int32).is_between(100, 209)
        ).then(1).otherwise(0).alias("is_tokyo")
]).join(
    df_receipt.lazy().select([
        pl.col("customer_id")
    ]), how="inner", on="customer_id"
).groupby("postal_cd").agg(
    pl.col("customer_id").unique().count()
).collect()

またデバッグ用に制限した行数で評価したい場合は、collectの代わりにfetchを使用することができます。

ファイル読込も遅延評価: pl.scan_csv

ファイル読み込みはpl.read_csvでしたが、ファイル読込を含めて遅延評価するには代わりにpl.scan_csvを使用します。

df_receipt_lazy  = pl.scan_csv("receipt.csv", dtypes=dtypes)

実行の際には、同様にcollectすればOKです。

df_receipt_lazy.head(10).collect()

数学関数

対数: pl.Expr.log10

polarsは数学関数が充実しているため、NumPyなしでも様々な処理が行えます。

P-061: レシート明細データフレーム(df_receipt)の売上金額(amount)を顧客ID(customer_id)ごとに合計し、売上金額合計を常用対数化(底=10)して顧客ID、売上金額合計とともに表示せよ。ただし、顧客IDが"Z"から始まるのものは非会員を表すため、除外して計算すること。結果は10件表示させれば良い

df_receipt.filter(
    pl.col("customer_id").str.starts_with("Z").is_not()
).groupby("customer_id").agg([
    pl.col("amount").sum()
]).select([
    "customer_id"
    , pl.col("amount").log10().alias("amount_log10")
]).sort("customer_id").head(10)

主な数学関数まとめ

数学関数で主に使用できるものを下表にまとめます。

処理内容 polars pandas
常用対数 pl.Expr.log10 なし(numpyで処理)
自然対数 pl.Expr.log なし(numpyで処理)
切り捨て pl.Expr.floor なし(numpyで処理)
丸め処理 pl.Expr.round なし(numpyで処理)
切り上げ pl.Expr.ceil なし(numpyで処理)
絶対値 pl.Expr.abs Series.abs

その他のデータ処理

出現回数のカウント: pl.Series.value_counts, pl.Expr.value_counts

pl.Series.value_countsを使えば、カテゴリなどの出現回数がpl.DataFrameで得られます。

df_customer.get_column('gender_cd').value_counts()

pl.Expr.value_countsは構造体が返されるため注意が必要です。

df_customer.select([
    pl.col('gender_cd').value_counts()
])

ダミー変数化: pl.get_dummies

pl.get_dummiesで実施可能です。

まずselectでダミー変数化したいカラムを抽出した後にget_dummiesで処理します。 それらをアンパックすることで横方向に結合します。

df_customer.select([
    "customer_id"
    , *pl.get_dummies(df_customer.select(pl.col("gender_cd")))
]).head(10)

ダミー変数の結果は以下のようにu8(符号なし8bit整数)となります。

pandasも同名のget_dummiesがありますが、使い方は違うので注意が必要です。

# pandasでの同様の処理例
pd.get_dummies(df_customer[['customer_id', 'gender_cd']],
    columns=['gender_cd']).head(10)

クロス集計: pl.pivot

こちらはほぼpandasと同様で、values, index, columnsを引数に指定すればOKです。

以下に例題と回答を示します。

P-043: レシート明細データフレーム(df_receipt)と顧客データフレーム(df_customer)を結合し、性別(gender)と年代(ageから計算)ごとに売上金額(amount)を合計した売上サマリデータフレーム(df_sales_summary)を作成せよ。性別は0が男性、1が女性、9が不明を表すものとする。

ただし、項目構成は年代、女性の売上金額、男性の売上金額、性別不明の売上金額の4項目とすること(縦に年代、横に性別のクロス集計)。また、年代は10歳ごとの階級とすること。

df_customer.select(
    ["customer_id", "gender_cd", "age"]
).join(
    df_receipt.select(
        ["customer_id", "amount"]
    ), how="left", on="customer_id"
).select([
    "gender_cd"
    , ((pl.col('age') / 10).floor() * 10).cast(pl.Int16).alias("era")
    , "amount"
]).groupby(["gender_cd", "era"]).agg(
    pl.col("amount").sum()
).pivot(
    values="amount", index="era", columns="gender_cd"
).sort("era").select([
    "era"
    , pl.col("0").alias("male")
    , pl.col("1").alias("female")
    , pl.col("9").alias("unknown")
])

差分処理: pl.Expr.shift

pl.shiftで引数指定をなしとすれば、一つ前のレコードが参照でき、演算が可能になります。

P-041: レシート明細データフレーム(df_receipt)の売上金額(amount)を日付(sales_ymd)ごとに集計し、前日からの売上金額増減を計算せよ。なお、計算結果は10件表示すればよい。

df_receipt.groupby("sales_ymd").agg(
    pl.col("amount").sum()
).sort("sales_ymd")\
.with_columns(
    (pl.col("amount") - pl.col("amount").shift()).alias("diff_amount")
).head(10)

pandasでは複数のDataFrameを作成してconcatする必要があり煩雑でした。

# pandasの解答例
df_sales_amount_by_date = df_receipt[['sales_ymd', 'amount']].\
                                groupby('sales_ymd').sum().reset_index()
df_sales_amount_by_date = pd.concat([df_sales_amount_by_date,
                                     df_sales_amount_by_date.shift()], axis=1)
df_sales_amount_by_date.columns = ['sales_ymd','amount','lag_ymd','lag_amount']
df_sales_amount_by_date['diff_amount'] = \
    df_sales_amount_by_date['amount'] - df_sales_amount_by_date['lag_amount']
df_sales_amount_by_date.head(10)

shiftに引数を指定すれば、指定した分前のレコードを参照することも可能です。

サンプリング: pl.DataFrame.sample

サンプリングは、pandasと同様sampleで実行できます。

df_customer.sample(frac=0.01).head(10)

この例ではfracのみ指定していますが、これは第2引数であり第1引数にサンプル数を指定できるnが存在します。

nを使う場合でもfracを使う場合でも、一度より多くレコードをサンプルする必要があるオーバーサンプリングの場合、with_replacement=Trueを指定する必要があります。

まとめ

いかがでしたでしょうか。ほぼほぼ基本的な部分はこの「その2」まででおさえていますが、 まだ全てを書ききれていないので次回以降では以下のような内容を書いていこうと思います。

  • 日付型の扱い
  • UDF(ユーザ定義関数)とapply
  • 他ライブラリとの連携(scikit-learnなど)
  • 処理時間の計測・検証

本記事がPolarsを使われる方の参考になれば幸いです。

参考記事