実は奥が深い欠損値の話

2022/02/21

長らくWeb開発の世界に没頭し続けていると技術的な情報ってほとんどGoogle検索に頼れば手に入るような気がしてしまうけど、実は全然そんなことないんだよね。

Webそのものに関する情報をWebで検索しているから高度なレベルの情報まで網羅的にアクセス出来るのであって、ジャンルが変わると大分状況も変わってくる。

お料理、医療、スポーツetc全部ひっくるめた各ジャンルのアクティビティごとに「Webで得られる情報の充実度」みたいな指標があったとしたら、多分Web開発というジャンルの充実度はずば抜けて高い位置にあるんじゃないかな。当たり前?笑

こうしてWebの利便性をよく知った上で日々活用しているIT業界の人たちほど「有益な情報や便利なものはもう既にWeb上のどこかにある」みたいな感覚になりがち…

だけど実はWebから有益な情報や機能性をあまり享受できない人の方が全然たくさんいるというのはあって、そこを感覚的に理解できているかどうかがWeb開発に携わる人間として大切なポイントな気がしているよ。

前置き

で、個人的にデータ分析や機械学習についてもWeb開発系の充実度と比べたらWeb上の情報が充実してないなーと思うことが多くて。

ビジネスマン向けのざっくりした解説記事や初心者向けの入門コンテンツはたくさんあっても既に実務に取り組んでる人が活用できそうな込み入った知見って結構レアだよね。

まぁこれに関しては自分に理論的な理解が欠けているがために論文で公表された知恵を実務に活かせるレベルにないだけというのが正解な気はしているんだけど、今は置いておこう。勉強しよ。

ということでこのブログでは自分が日々データと格闘する中で得た知見も積極的に公開していけたら良いなと思っていて、ゆーて全然レベル低いんだけど少しでも誰かのお役に立つことがあれば何より。

前述の通り理論的なことが全然分かってないのでやってみたらこうなったレベルのお話ばかりになってしまうのは容赦して欲しい。

あと間違ったことを偉そうに言ってるのを発見したら教えてくれると超嬉しい。

特に言葉選びに関してはちゃんとした人が見たら絶対気持ち悪いと感じる使い方をしちゃってることが多々出てくると思うんだけど、やっぱり出来るだけ厳密な情報を伝えられる言葉の使い方ができるようになりたいから本当にツッコミ入れまくって欲しい。

Twitterアカウントはこちら → @YuhsakInoue

今日はデータの欠損値の話。

データの欠損値、どう扱ってる?

データに NaN とか None とか入ってるやつ。欠損値ってよく出会う割にニューラルネットワークみたいな連続的な演算を基本にしたモデルにそのまま入力することが出来ないから厄介だよね。

代表的な前処理の仕方はこんな感じ?

  • 欠損値を含む特徴量自体を無視してしまう
  • 0.0, 1.0など固定の値で埋める
  • 平均値, 中央値, 最小値, 最大値などの統計量で埋める
  • 前後や周辺のデータ点から滑らかに補完する

とりあえず無視して進めるっていうのは一旦とにかく何らかのモデルを組んでベースラインとしての精度を確認してみたい時によくやるし、そうでなければ特徴量の性質と照らし合わせて何となくリーズナブルな感じの補完方法を採用してみたりするかと思う。

例えば新規顧客の「過去1週間の広告反応率」を同年代の既存顧客の平均値で補完する、とか。

こんな感じでそれっぽい前処理をした結果それなりにワークしてるからOKということで特に深堀りしないケースが結構あるんじゃないかなーと思っているんだけど、それだとすごく勿体無いかもしれないよというのがこの記事の主旨。

例) ある商品の顧客アンケートデータ

例えばある商品に対して顧客に実施したアンケートの回答データがあったとする。アンケートには20項目あって顧客それぞれの評価値が1~5の数値で入っている。

このデータを顧客のLTV予測を行うモデルの特徴量に採用したいが、顧客が回答をスキップした項目の数値は欠損値になっているためモデルに入力するためには何らかの前処理をする必要がある。

普通に考えると何となく20項目の評価値の和が100に近いほど高い評価をしてくれていそうだし、逆に0に近いほど評価も低そうだから素直なデータに思えるよね。

基本的に各項目の評価値をそのまま特徴量に活かす方針で良さそうで、欠損値は回答が無かった項目だから「特に不満でも満足でもない」ということでその項目の全体の中央値で補完することにする。

何となく良さそうだよね?

そんでこの仮説でモデルの入力にしてみたところ確かに少し精度は上がってやっぱりアンケート多少は効くんだねーという結論になりました。

でも実はこのデータには「評価値自体よりも回答項目数の方がその後のLTVへの相関が高い」という性質が隠れていました。

顧客にとってはそもそもアンケートに回答すること自体に労力がかかるから、5項目だけ満点で回答してくれた顧客よりも20項目全部2~3点で回答してくれていた顧客の方が今後への期待を込めて感想を伝える労力を割いてくれていた。そして実際にLTVも高くなる傾向があった、という。

結果としてモデルへの入力としては「欠損値を補完した個別の評価値」よりも「全項目の評価値の平均」+「回答してくれた項目数」を採用した方がLTVの推定精度の向上に寄与していたはずだった。

「欠損している」という事実自体が持つ情報量

こんな感じで「情報が欠損している」という事実自体が情報量を持っているケースが多々ある。

上記の例はこの記事のためにそれっぽく考えただけのものなんだけど実務上で実際に類似したケースに遭遇したことは何度もあって、欠損値についてはその生成過程に着目してみると割合筋の良い仮説が作りやすいって何となくだけど思ってる。

特に分析の実務上データの分布から性質を推測するということは普通に行われていると思うんだけど、その際に欠損値については無視されがちだったりあまり注目されなかったりする気がしてるんだよね。

最初から欠損値に注目してないと超絶分かりにくいパターンの「一定期間において観測されたデータに欠損値が含まれている割合自体がその期間全体を通した全ての観測値の誤差の大きさと相関している」みたいなケースではそれに気付けること自体が値千金だったりもするので、頭の片隅に置いておく価値はあると思う。

お手軽な確認方法

とは言え時間にたっぷり余裕がないと色んなケースを念頭に置いて掘り下げるのはなかなか難しい。

それにお仕事でやってるとそもそも分析者とデータが生成される場所が部署的に遠すぎて生成過程について気軽に情報を得られないことばかりだと思うから、自分がいつもサクッと試してる方法を2つ紹介するよ。

コード例は普段よく使ってる Python × Pandas のもの。

その1. 欠損項目数に関する特徴量を追加して比較

例に挙げたアンケートのパターンのようにあるデータ点で欠損している項目数をカウントした特徴量を追加してみる方法。
これで精度が上がれば欠損していること自体が良い情報になってそう。

df["count_na"] = df["feat_A"].isna() + df["feat_B"].isna()

実際は単純なカウントよりも適当な粒度で切ったグループ内の統計量を与えた方がより効果的な場合が多い。(もちろんリークには気をつけないとね)

# 日毎に標準化する
group_count_na = df.groupby("date")["count_na"]

df["standardized_count_na"] = (
  (df["count_na"] - group_count_na.transform("mean"))
  / group_count_na.transform("std")
)

その2. 欠損値をそのまま扱えるモデルに学習させてみて比較

テーブルデータでお世話になりまくってるお馴染みのLightGBMを使った確認方法。

欠損してること自体が有用な特徴になってる場合、LightGBMでは下手に補完すると逆に精度が下がることが多いっていう性質を利用して前処理しないパターンと補完したパターン両方で学習して精度を比較してみるという。実に乱暴…笑

# 結果を再現できるようにseed固定でdeterministicを指定しておく
params = {
    "boosting": "gbdt",
    "objective": "binary",
    "metric": "binary_logloss",
    "seed": 42,
    "deterministic": True,
}
# 欠損値そのままのデータセット
dataset_train_na = lgbm.Dataset(x_train_na, y_train)
dataset_valid_na = lgbm.Dataset(x_valid_na, y_valid, reference=dataset_train_na)

model_na = lgbm.train(
  params,
  dataset_train_na,
  valid_sets=dataset_valid_na,
  num_boost_round=1000,
  callbacks=[lgbm.early_stopping(100)],
)
# 欠損値を補完したデータセット
dataset_train_fillna = lgbm.Dataset(x_train_fillna, y_train)
dataset_valid_fillna = lgbm.Dataset(x_valid_fillna, y_valid, reference=dataset_train_fillna)

model_fillna = lgbm.train(
  params,
  dataset_train_fillna,
  valid_sets=dataset_valid_fillna,
  num_boost_round=1000,
  callbacks=[lgbm.early_stopping(100)],
)
# 顕著に差があるかどうか確認してみる
metric_na = model_na.best_score["valid_0"]["binary_logloss"]
metric_fillna = model_fillna.best_score["valid_0"]["binary_logloss"]

print("na    :", metric_na)
print("fillna:", metric_fillna)
print("diff  :", metric_na - metric_fillna)

おまけ: データの中で欠損してる部分を網羅的に確認する

欠損値を含むカラムを確認することは df.info() で一発なんだけど、実際にデータ内の欠損してる部分を確認していきたい時もあるよね。そんな時に便利なスニペット。

「欠損値を含むカラム」×「欠損値を含む行」を網羅的に抽出できるよ。

データの加工後に最終的に inf が混じっているかどうかもチェックしておきたい時用にそっちバージョンも。

from pandas import DataFrame
from typing import Callable
import numpy as np

def extract_grid(df: DataFrame, grid_fn: Callable[[DataFrame], DataFrame]) -> DataFrame:
    grid = grid_fn(df)
    cols = grid.sum(axis=0) > 0
    rows = grid.sum(axis=1) > 0
    return df.loc[rows, cols]

def extract_na(df: DataFrame) -> DataFrame:
    return extract_grid(df, lambda df: df.isna())

def extract_inf(df: DataFrame) -> DataFrame:
    return extract_grid(df.select_dtypes(exclude=['object']), lambda df: np.isinf(df))
# こういうDataFrameがあるとき
df
feat_Afeat_Bfeat_C
00.11.2NaN
10.20.90.7
2NaN1.30.9
# こうなる
df_na = extract_na(df)

df_na
feat_Afeat_C
00.1NaN
2NaN0.9