[Python] Pandasのメソッドチェーンを活かしたデータクリーニング

この記事では,調査によって得られる質問表からのデータのクリーニングをPandasのメソッドチェーン(method chaining, 以下chaining)を用いるとコードの可読性が飛躍的に向上するお話と,クリーニングの際の現在の僕のベストプラクティスを紹介する.

今回触れる内容は,

  • 欠損値が絡んだクロス集計.
  • 欠損値が絡んだ連続値からカテゴリーへの変換.
  • 欠損値が絡んだスケールのカテゴリーへの変換.

である.

Method chainingに関してはこのサイトが詳しい.この記事は実践編の立ち位置.
Pythonでメソッドチェーンを改行して書く note.nkmk.me

データの準備

データはKaggleより,
Depression and Academic performance of students
を用いる.全然有名なデータではないし,説明も少ないので例として用いるだけだ.
今回用いるコードは,https://github.com/akitoshiblog/pandas_method_chaining_cleaning からデータと共にダウンロード可能だ.

データを上のサイトからダウンロードしたら以下のコードを回しておく.

path = "Effects of Depression  Anxiety on Academic Performance among the students (respuestas).xlsx"
df = pd.read_excel(path)
# Insert missing values
np.random.seed(1234)
for col in df.columns:
    df.loc[df.sample(frac=0.1).index, col] = np.nan

後半部分は欠損値をこちらから意図的に行列に差し込む方法だ.欠損値込みのデータセットを作成する際に便利なコードだ.

クロス集計

pd.crosstab を用いれば良い.注意点としては欠損値は表れないのでreplaceをするか,いつも全体の数を出すように margins=True にしておくよう心がけることである.

sex = "Gender:"
age_ = "Age:"
tab = pd.crosstab(
        df[sex].replace(np.nan,"missing"), 
        df[age_].replace(np.nan,"missing"), 
        margins=True)
tab_per = ( 
    pd.crosstab(
        df[sex].replace(np.nan,"missing"), 
        df[age_].replace(np.nan,"missing"), 
        margins=True, 
        normalize="index"
    ).mul(100)
     .round(2)
)
display(tab)
display(tab_per)

これで数字と率が手に入る.注目すべきは率の算出の際に *100 ではなく .mul(100) を用いることで .round(2) までmethod chainingで繋げることが出来ることだ.また()で括ると\がいらなくなる.

連続値からカテゴリーへの変換

データに日付型が入ってしまっているので,今回はそれを欠損に変更しておく.実際の場面においては日付型が一つだけ混入しているなどはexcelの読み込みの問題が高いのでcsvに変換して,日付として読み込まれない工夫が必要である.

gpa = 'Your Last Semester GPA: '
df[gpa] = pd.to_numeric(df[gpa], errors='coerce')
df[gpa].describe()

良く知られている方法は pd.cut を用いる方法であろう.pd.cutを用いるとカテゴリー型を作成してくれるが,欠損値を違う名前に変更したいときにエラーが出る.解決方法としては型をstring型などにしてしまうことだ.

gpa_cate = "gpa_cate"
labels = [">=1,<2", ">=2,<3",">=3,<=4"]
df[gpa_cate] = (pd.cut(df[gpa], bins=[1,2,3,4.1], right=False, labels=labels)
                .astype(str)
                .replace("nan", "missing")
                .fillna("missing")
               )
df[gpa_cate].value_counts()

Pythonのバージョンによって.astype(str)の際のnp.nanの挙動が違うため,今回はどちらのケースでもカバー出来るように.replaceと.fillnaを用いている.

他にも2パターン同等の処理を叶える方法がある..maskを用いる方法だ.Way1とWay2が書いてあるが比較演算子をmethodで行っているかどうかの違いである.

# Way 1
df[gpa_cate] = (df[gpa]
                .mask(df[gpa].isnull(),"missing")
                .mask(df[gpa] <= 4, labels[2])
                .mask(df[gpa] < 3, labels[1])
                .mask(df[gpa] < 2, labels[0])
               )
# Way 2
df[gpa_cate] = (df[gpa]
                .mask(df[gpa].isnull(),"missing")
                .mask(df[gpa].le(4), labels[2])
                .mask(df[gpa].lt(3), labels[1])
                .mask(df[gpa].lt(2), labels[0])
               )
df[gpa_cate].value_counts()

ミスが少なくなるのはどちらかと聞かれたら,実は.maskを用いる方法ではなかろうか? 確かにpd.cutは非常に多くの区分を設定する際には有用だが,今回のgpaの列に関しては,最低の2と最高の4を入れるために端の挙動について気を使う必要がある.また,欠損値処理のために.astypeや.replace, .fillnaが必要になる.

一方,maskによる方法はもっと直接的で明示的に処理が書かれる.この記法は構造化データの処理に特化したSASやSPSSといった統計ソフトに近くなることも特筆すべき点である.

この可読性はmethod chainingを用いることによって得られていることに気をつけること.

スケールからカテゴリー

7個の質問(答えは1~4を取る)に関して,3,4を答えたら1点,1,2だったら0点として得られるスケールを計算すると考えよう.

まず,項目を点数に変換するプロセスを行う.3通りの記法が考えられる.今回のケースだと取り扱う値が少ない(カテゴリー的)ので,.replaceを用いるのが最もsimpleだろう.

qs = {i : df.columns[i+2] for i in range(1,7)}
# Way 1
dfM = df.copy()
for i in range(1,7):
    c = qs[i]
    dfM[c] = dfM[c].replace({1:0,2:0,3:1,4:1})
    
# Way 2
dfM = df.copy()
for i in range(1,7):
    c = qs[i]
    dfM[c] = (dfM[c]
                 .mask(dfM[c].apply(lambda x: x in [1,2]), 0)
                 .mask(dfM[c].apply(lambda x: x in [3,4]), 1)
                )
# Way 3
dfM = df.copy()
for i in range(1,7):
    c = qs[i]
    dfM[c] = (dfM[c]
                 .mask(dfM[c].eq(1) | dfM[c].eq(2), 0)
                 .mask((dfM[c] == 3) | (dfM[c] == 4) , 1)
                )

次にこれらの点数を合計してカテゴリーにする.質問紙から得られるデータでよくあることだが,一つの項目だけ足りていない人でも,一番上のカテゴリーだと判断出来たり,一番下のカテゴリーに入れられると判断出来たりする.今回は6つの質問からなる得点を">=4", "2-3", "<=1" の3つのカテゴリーに変換することを考えよう.

cols = [qs[i] for i in range(1,7)]
sum1 = dfM[cols].sum(axis=1, skipna=False)
sum2 = dfM[cols].replace(np.nan,1).sum(axis=1, skipna=False)
sum3 = dfM[cols].replace(np.nan,0).sum(axis=1, skipna=False)

scale = (sum1
        .mask(sum1.isnull(), "missing")
        .mask( (sum1 >=4 ) | (sum3 >= 4), ">=4")
        .mask( sum1 <= 3, "2-3")
        .mask( (sum1 <= 1) | (sum2 <= 1), "<=1")
        )

方針としては,欠損を0点にしたにも関わらず一番上のグループに入れば,その人は一番上だと判断出来るし,欠損を1点にしたにも関わらず一番下のグループに入れば,その人は一番下だと判断出来る.

注目して欲しいことは,このような処理はpd.cutでは達成出来ない,もしくは煩雑になることである.ところがmaskを用いたchianingを用いれば可読性を向上したコードを書くことが可能である.

今回のケースだとこのような処理をすれば,欠損を169件からカテゴリーにした際に85件まで減らすことが出来,データを最大限活用することが出来る.

----------雑感(`・ω・´)----------
今回の記法は,以下の本によって学んだ.他にも色々なTipsが眠っているので是非流し読みしてみてほしい.

今まで書いていた自分なりのコードよりも圧倒的に分かりやすくて頭がスッキリします.

コメント

タイトルとURLをコピーしました