0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

EDA で一番効いたのは、数字に1回多く「なぜ」を聞いたことだった

0
Last updated at Posted at 2026-08-03

この記事は、書籍『Kaggleではじめる大規模言語モデル入門』の輪読会で学んだテーマを、自分の言葉で整理・深掘りしたものです。

はじめに

EDA(探索的データ分析)を、今回はじめてやりました。

df.head() を叩いて、出てきた表を眺めて、そこで止まりました。
表は見たのに、次に何をすればいいのか分かりません。

輪読会で探索的データ分析の章を読んだので、手を動かしながら順番を決めていきました。
この記事は、その順番と、途中で引っかかったところの記録です。
題材は Kaggle の Disaster Tweets 、災害に言及しているツイートかどうかを判定するデータです。

こういう人に向けて書いています。

  • pandas の基本操作(read_csv / head / groupby)は分かるが、EDA の手順が決まっていない人
  • Kaggle に登録して、コンペのデータを開いて途方に暮れた人

EDA のゴール

EDA が終わった時点で、次の2つが言える状態を目標にしました。

  1. 何をどう評価するのか(目的変数の性質と、それに合う評価指標)
  2. どんな特徴量を作って、どう検証するのか(使える列と直すべき列、交差検証の分け方)

きれいなグラフを作るのはゴールではありません。
この2つに繋がらないグラフは、描いても次の判断に使えません。

使うデータと入手手順

Disaster Tweets は、災害関連キーワードで集めたツイートに「実際に災害について述べているか」のラベル(target)が付いたデータです。
1ファイルで約11,000行、1.5MB くらいです。

Kaggle の入門コンペ Natural Language Processing with Disaster Tweets とは別のデータセットです。名前が似ていますが、コンペのほうは train が 7,613行で列構成も違います。この記事の行数と合わなくて混乱しないよう、vstepanenko/disaster-tweets を使ってください。

読み込みも集計もすぐ終わるので、ノート PC で足ります。

ブラウザなら、上のページで Download を押して zip を展開し、中の tweets.csv を作業フォルダに置くだけです。
自分は CLI で取りました。

pip install kaggle
kaggle auth login    # ブラウザが開くので、そこで承認する
kaggle datasets download -d vstepanenko/disaster-tweets --unzip -p ./data

kaggle auth login は CLI 2.2 以降のブラウザ認証です。トークンファイルを置く必要はありません。
使えない場合は https://www.kaggle.com/settings/api の API 欄で「Generate New Token」を押し、落ちてきた kaggle.json を配置します。

  • Windows: C:\Users\<ユーザー名>\.kaggle\kaggle.json
  • macOS / Linux: ~/.kaggle/kaggle.json

pip install 後に kaggle: command not found になる場合は、python -m kaggle で同じように呼べます。

以降のコードは、Jupyter か Colab のセルに上から順に貼る前提です。./data/tweets.csv にファイルがある状態から始めます。

準備

ライブラリを読み込み、表示設定を決めます。

import html
import re
from collections import Counter

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# 表示が途中で省略されると、見たいものが見えない
pd.set_option("display.max_columns", 50)
pd.set_option("display.width", 120)
pd.set_option("display.max_colwidth", 60)
plt.rcParams["figure.figsize"] = (8, 4)


class CFG:
    """ノートブック全体で使う設定をまとめる"""
    DATA_PATH = "./data/tweets.csv"
    TARGET_COL = "target"


target = CFG.TARGET_COL

設定を CFG クラスに集めたのは、ファイルパスや目的変数の列名がコードの各所に散ると直すときに漏れるからです。
1箇所にまとめておけば、別のコンペに使い回すときも上だけ書き換えれば済みます。

テキストデータを扱うときは、display.max_colwidth を広げておかないと head() で本文が ... で切られて、肝心の中身が見えません。

STEP 1. shape と head

df = pd.read_csv(CFG.DATA_PATH)
print("shape:", df.shape)
df.head()
shape: (11370, 5)

   id keyword        location                                                         text  target
0   0  ablaze             NaN  Communal violence in Bhainsa, Telangana. "Stones were pe...       1
1   1  ablaze             NaN  Telangana: Section 144 has been imposed in Bhainsa from ...       1
2   2  ablaze   New York City  Arsonist sets cars ablaze at dealership https://t.co/gOQ...       1
3   3  ablaze  Morgantown, WV  Arsonist sets cars ablaze at dealership https://t.co/0gL...       1
4   4  ablaze             NaN  "Lord Jesus, your love brings freedom and pardon. Fill m...       0

まず、1行が何を表すかを言葉にします。
このデータなら、1行が1件のツイートです。

自明に思えますが、取り違えると以降の集計が全部無意味になります。
1行がユーザー単位なのか、日付単位なのか、注文単位なのか、商品×日付単位なのか。
ここを間違えると、集計の分母がずれたまま最後まで進んでしまいます。

この出力で、1つ引っかかりました。
行2と行3は text の冒頭が同じで、location だけ違います。
同じ内容のツイートが複数行あるかもしれない、という疑いです(STEP 9 で確かめます)。

STEP 2. データ辞書

各列の意味を書き出します。

列名 意味
id 行の連番 数値
keyword 収集時に使われた災害関連の検索キーワード 文字列
location ユーザーが自己申告したプロフィール上の所在地 文字列
text ツイート本文 文字列
target 【目的変数】実際に災害について述べているか (0/1) 数値

この手の定義は、Kaggle のコンペなら Data タブに載っています。あるならそれを読めば足ります。

そのうえで、その値がどういう経緯で入ったのかも考えます。
こちらは Data タブには書かれていないので、自分で推測するしかありません。

location は自由入力欄だと思います。
head() に出ていた New York CityMorgantown, WV では粒度が違うし、5行のうち3行が NaN でした。
選択式なら粒度が揃うはずなので、ユーザーが好きに書ける欄なのでしょう。

keyword は、このツイートを集めるために投げた検索クエリだと読めます。
データの作り方そのものに由来する列です。
こういう列は目的変数と強く結びついていることがあるので、STEP 6 で重点的に見ます。

STEP 3. info と nunique

df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 11370 entries, 0 to 11369
Data columns (total 5 columns):
 #   Column    Non-Null Count  Dtype
---  ------    --------------  -----
 0   id        11370 non-null  int64
 1   keyword   11370 non-null  object
 2   location  7952 non-null   object
 3   text      11370 non-null  object
 4   target    11370 non-null  int64
dtypes: int64(2), object(3)

info() は型と非欠損の件数を同時に出してくれるので、コスパが良いです。
object は実質的に文字列、int64float64 は数値です。

見るのは、型が想定と違わないかどうか。
数値のはずの列が object になっていたら、"1,234" のようなカンマ入りや "-" のような欠損記号が混ざっています。
今回は素直な型でした。特に言うことがないので次に進みます。

続けて、各列が何種類の値を持つか(カーディナリティ)を見ます。

df.nunique()
id          11370
keyword       219
location     4504
text        11223
target          2
dtype: int64

この5行を1つずつ読みます。

  • id → 単なる連番。特徴量に入れると害になります
  • keyword は 219種類 → 扱いやすいカテゴリ変数
  • location は 4,504種類 → 11,370行に対して多すぎる。そのままカテゴリ変数にはできません
  • text は 11,223種類 → 11,370行あるのに 11,223種類しかない。147行ぶん余っている=重複がある
  • target は 2種類 → 二値分類

使えない列、加工が必要な列、重複の存在が、この5行で分かりました。

STEP 4. 欠損値

def missing_report(df):
    n_missing = df.isnull().sum()
    pct = (n_missing / len(df) * 100).round(2)
    return pd.DataFrame({"欠損数": n_missing, "欠損率%": pct})


missing_report(df)
           欠損数   欠損率%
id           0   0.00
keyword      0   0.00
location  3418  30.06
text         0   0.00
target       0   0.00

location だけが 30.06% 欠損しています。
欠損のある列が1本だけなので、「複数の列が同じ行でまとめて欠ける」パターンは今回は調べなくて済みます。列が複数欠けるデータなら、そこはクロス集計で見る必要があります。

ここで捨てるか 'unknown' で埋めるかを決めて次に行きたくなりますが、その前に一手だけ足します。
欠損しているかどうかが、目的変数と関係していないか。

loc_na = df["location"].isnull()
print("location 欠損 :", loc_na.sum(), "行  disaster率", round(df.loc[loc_na, target].mean(), 3))
print("location あり :", (~loc_na).sum(), "行  disaster率", round(df.loc[~loc_na, target].mean(), 3))
location 欠損 : 3418 行  disaster率 0.171
location あり : 7952 行  disaster率 0.193

0.171 対 0.193。差は 0.022 です。

小さいので目分量で「微妙」と片づけたいところですが、それだと感覚で切り捨てただけになるので、統計的な根拠がほしいところです。
確かめたいのは、11,370行を無作為に 3,418行と 7,952行に分けただけでも、これくらいの差は出るのかどうかです。

計算すると、無作為に分けただけで 0.022 の差が出る確率は 1% を下回りました。
偶然ではなさそうです(計算の中身は付録に置きました)。

ただ、偶然ではないと言えたのは「差があること」だけです。
差そのものは 0.022 しかないので、この列だけでスコアが動くとは思えません。
弱い候補として一応残しておきます。

それでも測った意味はあります。
ここが 0.10 対 0.40 まで開いていれば、location_is_null の1列でスコアが動く強い特徴量でした。
欠損率の表だけ見て捨てていたら、その可能性を確かめないまま終わっていました。

STEP 5. 目的変数の分布

目的変数は一番大事な列なので、単独で見ます。

counts = df[target].value_counts().sort_index()
ratio = df[target].value_counts(normalize=True).sort_index()
print(pd.DataFrame({"件数": counts, "割合": ratio.round(3)}))

fig, ax = plt.subplots(figsize=(5.5, 4))
ax.bar(counts.index.astype(str), counts.values, color=["#4c72b0", "#c44e52"])
ax.set_xlabel("target (0 = not disaster, 1 = disaster)")
ax.set_ylabel("count")
for i, v in zip(counts.index.astype(str), counts.values):
    ax.text(i, v, f"{v:,}", ha="center", va="bottom")
plt.tight_layout()
plt.show()
          件数     割合
target
0       9256  0.814
1       2114  0.186

01_target_distribution.png

0(災害ではない)が8割強を占める不均衡データでした。

これが評価指標の選択にそのまま効きます。

print("全部0と予測したときの正解率:", round((df[target] == 0).mean(), 4))
全部0と予測したときの正解率: 0.8141

何も学習せず「全部災害ではない」と答えるだけで、正解率 81.4%。
81% は多数派を答えただけの数字です。
このデータで Accuracy を追うのはやめます。

不均衡な二値分類なら、順位の正しさを測る AUC を主軸にして、少数派クラスの検出を重視するなら F1 や PR-AUC も見ます。
目的変数の分布を見た時点で指標が決まる。ここが STEP 5 の成果物です。

STEP 6. カテゴリ別の率を見たら target の意味が違った

keyword は 219種類。
キーワードごとに災害率がどれくらい違うかを見ます。

groupby して目的変数の平均を取るのが定石です。
目的変数が 0/1 なら、平均がそのまま1の割合、つまり災害率になります。

kw = df.groupby("keyword")[target].agg(["mean", "count"])
kw.columns = ["disaster率", "件数"]
kw = kw[kw["件数"] >= 30]        # 件数が少なすぎるキーワードは率がブレるので除外
print(f"件数30以上のキーワード: {len(kw)}")
print("\n--- disaster率が高い top10 ---")
print(kw.sort_values("disaster率", ascending=False).head(10).round(3))
print("\n--- disaster率が低い bottom10 ---")
print(kw.sort_values("disaster率").head(10).round(3))
件数30以上のキーワード: 190 件

--- disaster率が高い top10 ---
              disaster率  件数
keyword
sinkhole          0.963  54
thunderstorm      0.828  93
collision         0.740  77
avalanche         0.706  34
windstorm         0.703  37
derailment        0.684  79
rescued           0.606  33
ablaze            0.588  34
earthquake        0.571  42
hazardous         0.550  60

--- disaster率が低い bottom10 ---
            disaster率  件数
keyword
battle            0.0  66
blazing           0.0  61
collapse          0.0  60
crush             0.0  43
demolition        0.0  60
hellfire          0.0  51
mayhem            0.0  44
destroy           0.0  72
harm              0.0  61
ruin              0.0  48

上下の端だけをグラフにすると、全体平均からどれだけ離れているかが見えます。

top12 = kw.sort_values("disaster率", ascending=False).head(12)
bottom12 = kw.sort_values("disaster率").head(12)
plot_df = pd.concat([bottom12, top12]).sort_values("disaster率")

fig, ax = plt.subplots(figsize=(8, 7))
ax.barh(plot_df.index, plot_df["disaster率"], color="#4c72b0")
ax.axvline(df[target].mean(), color="#c44e52", linestyle="--",
           label=f"overall {df[target].mean():.3f}")
ax.set_xlabel("disaster rate")
ax.legend()
plt.tight_layout()
plt.show()

02_keyword_disaster_rate.png

全体平均が 0.186 に対して、sinkhole は 0.963、thunderstorm は 0.828。
下位の10個は災害率がちょうど 0.000 です。

0.000 が10個並ぶと、まず「件数が少なくて偶然0になっただけでは」と疑います。
なので検算しました。

全体の災害率 0.186 のもとで66件すべてが0になる確率は $0.814^{66} \approx 1.3 \times 10^{-6}$。
battle は66件、destroy は72件あって災害ツイートが1件もない。偶然では説明できません。

並んでいる語を見ると、battle hellfire mayhem ruin destroy harm crush blazing
どれも災害専用の語ではありませんが、これだけでは何の話に使われているのか分かりません。中身を覗きます。

for k in ["battle", "hellfire", "ruin"]:
    sub = df[df["keyword"] == k]
    print(f"--- {k} ({len(sub)}件, disaster率 {sub[target].mean():.3f})")
    for t in sub["text"].head(3).tolist():
        print("  ", t[:80])
--- battle (66件, disaster率 0.000)
   Listen 1. Individual activities are ok - don't be discouraged. The agencies also
   One Its, let's remember X1's words. X1 exists because of us. If we give up on th
   🎁 100$ DATDROP BATTLE VIDEO ONLY GIVEAWAY 🎁 ✅ Watch Video &amp; Follow all the S
--- hellfire (51件, disaster率 0.000)
   Don’t be an arrogant sinner. Quickly acknowledge your sin, repent and get back o
   Forced to develop locally made missiles fired from Apache helicopters after Obam
   aziraphale stepped into the hellfire and sandalphon watched
--- ruin (48件, disaster率 0.000)
   It matter not how it all started- whether u were in the wrong or the one wronged
   We'll ruin his development', 'Looks about 12' - Many NUFC fans react to deal nea
   Kara Walker's “The Jubilant Martyrs of Obsolescence and Ruin” is brilliant. One

K-POP グループの応援、景品キャンペーン、説教、サッカーの移籍、現代アートの作品名。
hellfire の3件目は Good Omens の二次創作です。

災害語が比喩に転用されているのだと思っていましたが、そうではありませんでした。
これらは災害語ではなく、政治や宗教や推しの話に出てくるただの英単語です。
battle には「The battle for $8500!Go bulls!」(株の話)まで入っています。

キーワード全体でどうなっているかも見ておきます。

g = df.groupby("keyword")[target]
summ = pd.DataFrame({"n": g.size(), "rate": g.mean()})
zero_kw = summ.index[summ["rate"] == 0]
print("全部 target=0 のキーワード数:", (summ["rate"] == 0).sum())
print("全部 target=1 のキーワード数:", (summ["rate"] == 1).sum())
print("0/1 が混ざるキーワード数  :", ((summ["rate"] > 0) & (summ["rate"] < 1)).sum())
print("全部 target=0 のキーワードが占める行数:", df["keyword"].isin(zero_kw).sum())
全部 target=0 のキーワード数: 18
全部 target=1 のキーワード数: 0
0/1 が混ざるキーワード数  : 201
全部 target=0 のキーワードが占める行数: 717

必ず非災害になるキーワードは18個(717行)あるのに、必ず災害になるキーワードは1つもありません。

hellfire のように、実際の災害報道ではほぼ使われない語があるのは分かります。
分からないのは逆です。earthquake でさえ災害率 0.571 で、42件のうち4割強が非災害に落ちています。
この 0 に何が入っているのかが気になりました。
たとえばポケモンの技「じしん」は英語名がそのまま Earthquake なので、ポケモン対戦の話あたりが引っかかっているのではないか、と予想しました。
ガブリアスのじしんで全抜きされる側にとっては災害ですが、それはそれとして中身を見ます。

eq0 = df[(df["keyword"] == "earthquake") & (df[target] == 0)]
print("earthquake で target=0 の行数:", len(eq0))
for t in eq0["text"].head(6).tolist():
    print("  ", t[:80])
earthquake で target=0 の行数: 18
   「Disasters + Creative” #Exhibition- Practice at Parsons School of Design and 25-
   https://t.co/HvxhwYJ0jF Food truck offers free food for earthquake-affected tour
   Hey, loves! Tip lang coming from someone who survived a 7.2 magnitude earthquake
   Puerto Rico - Earthquake 🌋 South Korea - Bush fire 🔥 Mexico volcano erupting 🌋 G
   Hello iKONIC please pray for everyone who lives near the ‘Taal volcano’ in the P
   Forget all these education debates.. Who remembers the time when apparently, the

ポケモン対戦の話は1件もありませんでした。
それどころか、18件のほとんどが本物の地震の話です。

阪神淡路大震災25年の展示会、被災者向けの無料フードトラック、7.2の地震を生き延びた人の助言、火山への祈り。
地震と無関係だったのは「民主党は #WalkAway が起こす Earthquake に備えていない」の1件だけで、残りは過去の震災、復興、募金、噂、体験談です。

target が分けているのは、災害について述べているかどうかではありません。
いま起きている災害を報じているかです。
被災者向けのフードトラックも復興の報告も災害について述べていますが、どちらも 0 になっています。
同じ地震でも、報道なら 1、追悼や募金や思い出話なら 0 です。

だからどんなに災害寄りのキーワードでも 1 に張り付かず、必ず災害になる語が1つもありません。

keyword はそのまま効く特徴量です。
目的変数から作った列ではなく収集時の検索クエリなので、リークにもなりません。
ただし効くのは「絶対に災害ではない」側を切り落とすところまでで、災害寄りの語が来たときに 0 か 1 かは本文を読まないと決まりません。

そして keyword を眺めていて、もう1つ気づきました。

enc = df["keyword"].str.contains("%")
print("'%' を含む keyword を持つ行数:", enc.sum())
print("その keyword の種類数:", df.loc[enc, "keyword"].nunique())
print(sorted(df.loc[enc, "keyword"].unique())[:8])
'%' を含む keyword を持つ行数: 1821
その keyword の種類数: 35
['airplane%20accident', 'blew%20up', 'blown%20up', 'body%20bag',
 'body%20bagging', 'body%20bags', 'bridge%20collapse', 'buildings%20burning']

keyword が URL エンコードされたままでした。
%20 はスペース。35種類、1,821行が該当します。全体の16%。

keyword をテキストに結合して言語モデルに食わせるなら、mass%20murder のままではトークナイザが妙な分割をします。
直すのは df["keyword"].str.replace("%20", " ") の一行です。見ていなければ、そのまま学習を回していました。

location も確認します。

print(df["location"].value_counts().head(8))
print("ユニーク数:", df["location"].nunique())
print("1回しか出てこない location の数:", (df["location"].value_counts() == 1).sum())
location
United States      96
Australia          83
London, England    81
UK                 77
India              74
London             69
United Kingdom     59
USA                52
Name: count, dtype: int64

ユニーク数: 4504
1回しか出てこない location の数: 3668

UKUnited KingdomLondon, EnglandLondon が別値として並んでいます。
4,504種類のうち 3,668種類は1回しか出てきません。
STEP 2 で自由入力だろうと当たりをつけた通りでした。

機械的な正規化でどこまで減るかを試します。

norm = df["location"].str.lower().str.strip().str.replace(r"\s+", " ", regex=True)
print("正規化前:", df["location"].nunique(), "→ 正規化後:", norm.nunique())
正規化前: 4504 → 正規化後: 4375

小文字化と空白の詰めでは 4,375 にしか減りません。
ちゃんと吸収するには国名辞書とのマッチングが必要になります。その前に、丸めて意味があるのかを見ておきます。

loc = df.groupby("location")[target].agg(["mean", "count"])
loc = loc[loc["count"] >= 30].sort_values("mean", ascending=False)
print(loc.round(3).to_string())
                          mean  count
location
Ireland                  0.742     31
UK                       0.403     77
Lagos, Nigeria           0.250     36
London, England          0.235     81
United Kingdom           0.220     59
Melbourne, Victoria      0.219     32
United States            0.219     96
India                    0.203     74
USA                      0.192     52
Australia                0.181     83
England, United Kingdom  0.179     39
Los Angeles, CA          0.178     45
Nigeria                  0.167     36
California, USA          0.128     47
London                   0.116     69
Earth                    0.097     31

Earth が31件ありました。自由入力欄だという見立てのとおりです。

一番上の Ireland は31件で 0.742。全体平均 0.186 の4倍です。
件数が少ないので、これも偶然かどうかを疑います。付録と同じ計算をします。

ire = df[df["location"] == "Ireland"]
p = df[target].mean()
se = np.sqrt(p * (1 - p) / len(ire))
print("Ireland:", len(ire), "件  disaster率", round(ire[target].mean(), 3))
print("全体", round(p, 3), "との差は標準誤差の", round((ire[target].mean() - p) / se, 1), "")
Ireland: 31 件  disaster率 0.742
全体 0.186 との差は標準誤差の 8.0 倍

標準誤差の8倍。件数30以上の16個から一番上を選んで見ていることを差し引いても、これは偶然では出ません。
Ireland の 0.742 は本物です。

keyword の 0.000 と同じで、疑ったら確かめれば済みます。
ただし、この列で厄介なのは別のところです。

肝心なのは UK が 0.403 で London が 0.116 という並びです。
どちらもイギリスなのに、全体平均 0.186 を挟んで反対側にいます。
UK United Kingdom England England, United Kingdom London London, England をまとめると 343行・0.236 になり、この差は消えます。

この差が本物なのかは、Ireland のようには判定できません。
Ireland は「全体平均と違うか」を1つ測るだけで済みましたが、こちらは「UKLondon が本当に別物か」の話です。
同じ国の別表記が偶然分かれた結果 0.403 と 0.116 になったのか、UK と書く人と London と書く人が実際に違うツイートをしているのか、この表からは区別できません。

区別できないので、正規化はやめました。
まとめてしまうと元の区別には戻せませんが、区別を残しておけば後から選べます。
本物なら効きますし、ノイズなら target encoding の段で件数の少ないカテゴリが全体平均に寄るので、そこで薄まります。
判断がつかないうちは、不可逆な操作を先にやらないほうが安全です。

そもそもこの列は、欠損の有無ですら差が 0.022 でした(STEP 4)。
主戦場は text なので、location_is_null フラグだけ作って、表記ゆれの吸収は後回しにします。

STEP 7. テキストの長さと、140に立つ針

テキストが主役のデータでも、いきなり BERT を持ち出す前に長さを見ます。
1行で作れるので、効かなくても損はありません。

df["text_len"] = df["text"].str.len()
df["word_cnt"] = df["text"].str.split().str.len()
print(df[["text_len", "word_cnt"]].describe().round(1))
print("\n--- target 別の平均 ---")
print(df.groupby(target)[["text_len", "word_cnt"]].mean().round(1))
       text_len  word_cnt
count   11370.0   11370.0
mean      107.6      17.2
std        30.1       5.5
min         5.0       1.0
25%        94.0      14.0
50%       119.0      18.0
75%       126.0      21.0
max       149.0      32.0

--- target 別の平均 ---
        text_len  word_cnt
target
0          106.0      17.1
1          114.6      17.4

災害ツイートのほうが平均 8.6文字長い(106.0 から 114.6)のに、単語数はほぼ同じ(17.1 から 17.4)。
単語数が変わらないのに文字数が長いなら、1単語が長いということです。
earthquakeevacuation のような長い語や固有名詞が効いているのだと思います。

分布を描くと、平均だけでは見えないものが出てきました。

bins = np.arange(0, 152, 2)
fig, ax = plt.subplots(figsize=(9, 4.5))
for label, color in [(0, "#4c72b0"), (1, "#c44e52")]:
    ax.hist(df.loc[df[target] == label, "text_len"], bins=bins, alpha=0.55,
            density=True, label=f"target={label}", color=color)
ax.set_xlabel("number of characters")
ax.set_ylabel("density")
ax.legend()
plt.tight_layout()
plt.show()

03_text_length.png

140文字ちょうどに鋭い針が立って、そこで分布が断ち切られています。

print("長さ140以上の行数:", (df["text_len"] >= 140).sum())
print(df["text_len"].value_counts().sort_index(ascending=False).head(10))
長さ140以上の行数: 1112

text_len
149       1
148       6
147       8
146       1
145       1
144      51
143      30
142       6
141       2
140    1006

140文字ちょうどが 1,006行。140 は Twitter の古い文字数上限なので、そこで切られたのだと分かります。

ただ、それなら 141文字から149文字の106行が説明できません。上限を超えているのに存在しています。
中身を見ます。

over = df[df["text_len"] > 140]
print("140文字超:", len(over), "")
print(over["text"].iloc[0])
140文字超: 106 行
When technical brains speak to a raging debate... Mostly, their disposition &amp; knowledge calms the storm. Even more… https://t.co/vDnC9tkXDB

&amp; が入っています。HTML エスケープされたままでした。
&amp; は生のテキストでは5文字ですが、表す文字は & の1文字です。1つ入るだけで見かけの長さが4文字伸びます。

html.unescape で戻して、長さを測り直します。

df["text_clean"] = df["text"].map(html.unescape)
df["text_len"] = df["text_clean"].str.len()
df["word_cnt"] = df["text_clean"].str.split().str.len()

print("140文字ちょうど:", (df["text_len"] == 140).sum(), "")
print("140文字超:", (df["text_len"] > 140).sum(), "")
140文字ちょうど: 1073 行
140文字超: 0 行

グラフも描き直します。さきほどのヒストグラムのコードをもう一度実行するだけです(text_len を上で更新したので、同じコードで新しい分布が出ます)。

04_text_length_unescaped.png

140でぴったり止まって、右側に何もなくなりました。
141以上の106行は打ち切りの例外ではなく、エスケープで見かけの長さが伸びていただけでした。
実体化すると 140ちょうどが 1,006行から 1,073行に増えます。

これが分かると、さっき自分で書いた「災害側が 8.6文字長い」に戻らないといけません。
140に張り付いた 1,006行は元の長さが分からないので、両クラスで打ち切りに当たる率が違えば、8.6文字の差はその違いを映しているだけになります。
平均を比べたいなら 140文字未満の行に絞って測り直す必要がある。今回そこまではやっていないので、あの 8.6文字は保留にします。

打ち切りそのもので困るのが2点あります。

text_len を特徴量にすると、その値の意味が「ツイートの長さ」から「打ち切りに当たったかどうか」にすり替わります。

もう1つは、これらの行では文末が途中で切れているということ。
感情表現やオチが末尾にある文だと、そこが落ちています。この塊でテキスト分類の精度が下がっても不思議はありません。

describe() の平均と標準偏差だけ見て分布を描かないでいると、ここは見えません。
max 149 という数字を眺めているだけでは、140に 1,006行が張り付いている構造にたどり着けません。
ヒストグラムの端に立つ針は、だいたい打ち切りか上限かデフォルト値のサインです。

STEP 8. ラベル別の頻出語を、そのまま並べてはいけない

TF-IDF や言語モデルの前に、Counter で単語を数えるだけでも方針は見えます。
数えるのは、STEP 7 で実体化した text_clean のほうです。

ここで一手だけ気をつけます。
災害側は 2,114行、非災害側は 9,256行。4.4倍も行数が違うので、出現回数をそのまま並べても比べられません。
非災害側の数字が大きいのは、そのクラスの行が多いだけ、という可能性が常にあります。

なので、そのクラスの行のうち何割に出てくるかを見ます。
1行に何回出ても1回と数えるため、set() を挟んでいます。

STOP = set('''a an the and or but if of to in on for with is are was were be been it its
this that these those i you he she they we my your his her their our me him them as at by
from so not no very too also just s t m re ve ll d have has had do does did will would
can could there here what when which who how all out up about into over after more
co https http rt'''.split())


def count_rows(texts):
    """語ごとに『その語を含む行数』を数える。1行に何回出ても1回"""
    cnt = Counter()
    for t in texts.fillna(""):
        words = set(re.findall(r"[a-z']+", t.lower()))
        cnt.update(w for w in words if w not in STOP and len(w) > 2)
    return cnt


pos = df.loc[df[target] == 1, "text_clean"]
neg = df.loc[df[target] == 0, "text_clean"]
cnt_pos, cnt_neg = count_rows(pos), count_rows(neg)


def top_table(cnt, n_rows, n=20):
    rows = [(w, c, round(c / n_rows, 3)) for w, c in cnt.most_common(n)]
    return pd.DataFrame(rows, columns=["", "行数", "出現率"])


print(f"--- disaster(1) {len(pos):,}行 ---")
print(top_table(cnt_pos, len(pos)).to_string(index=False))
print(f"\n--- not disaster(0) {len(neg):,}行 ---")
print(top_table(cnt_neg, len(neg)).to_string(index=False))
--- disaster(1) 2,114行 ---
           語  行数   出現率
      people 127 0.060
      killed  99 0.047
        fire  92 0.044
        iran  80 0.038
     volcano  80 0.038
thunderstorm  78 0.037
         now  76 0.036
         two  72 0.034
   australia  71 0.034
        taal  69 0.033
        news  68 0.032
         due  68 0.032
      severe  64 0.030
       train  63 0.030
       storm  63 0.030
   collision  62 0.029
         one  61 0.029
         hit  59 0.028
     warning  59 0.028
       fires  58 0.027

--- not disaster(0) 9,256行 ---
      語  行数   出現率
   like 484 0.052
 people 384 0.041
    one 346 0.037
    get 286 0.031
    now 280 0.030
    new 220 0.024
   time 218 0.024
because 214 0.023
 should 211 0.023
   know 208 0.022
   need 206 0.022
   it's 206 0.022
    why 202 0.022
   some 201 0.022
   fire 194 0.021
   only 193 0.021
    see 191 0.021
  being 187 0.020
  first 186 0.020
  think 181 0.020

対比がはっきり出ました。

災害側(1)は報道の語彙です。
killed volcano storm severe collision warning に、iran australia taal という固有名詞が混ざります。
taal はフィリピンのタール火山、australia は森林火災でしょう。
このデータは特定の時期の特定の災害に強く紐づいています。

非災害側(0)は会話の語彙。
like get know need why should it's と、一人称と主観の語が上位を占めます。

気になったのは、fire people now one が両方の top20 に入っていることです。
両方に出る語は判定に使えない、と言いたくなりますが、率を比べると話が変わります。

for w in ["fire", "people", "now", "one"]:
    r1 = cnt_pos[w] / len(pos)
    r0 = cnt_neg[w] / len(neg)
    print(f"{w:7s} disaster {r1:.3f} / not disaster {r0:.3f}{r1 / r0:.2f}")
fire    disaster 0.044 / not disaster 0.021 → 2.08倍
people  disaster 0.060 / not disaster 0.041 → 1.45倍
now     disaster 0.036 / not disaster 0.030 → 1.19倍
one     disaster 0.029 / not disaster 0.037 → 0.77倍

fire は災害側に 2.08倍 偏っていました。
出現回数で並べると災害側92、非災害側194 で「非災害のほうが多い語」に見えますが、それは非災害の行が4.4倍あるからです。

本当に差がないのは now の1.19倍で、one は 0.77倍とわずかに非災害寄りです。
「両方の top20 に入る」は「どちらにも同じくらい出る」を意味しません。分母を揃えるまでは何も言えません。

そのうえで、fire が2倍偏っていても災害側の 4.4% の行にしか出ないので、fire の有無だけで判定はできません。
報道の文中にあるか会話の文中にあるかで決まります。
なので TF-IDF と線形モデルをベースラインに置いて、事前学習済み言語モデルで文脈を見ると伸びるはずです。

固有名詞が上位に来るのは、モデリング上は危険信号でもあります。
「タール火山の話題なら災害」と覚えたモデルは、別の時期の別の災害には通用しません。

STEP 9. 重複とノイズ

STEP 3 で見えた重複を、ここで回収します。

まず、行がまるごと重複していないかを見ます。

print("完全に同じ行:", df.duplicated().sum())
print("id 以外が同じ行:", df.drop(columns=["id"]).duplicated().sum())
完全に同じ行: 0
id 以外が同じ行: 20

完全に同じ行はゼロですが、これは id が連番なので当たり前です。
id を除くと20行が完全に一致します。この20行は削って構いません。

本文だけで見ると、もっと多くあります。

dup = df["text_clean"].duplicated(keep=False)
print("重複しているテキストを含む行:", dup.sum())
print("ユニークなテキスト数:", df["text_clean"].nunique(), "/", len(df))

g = df[dup].groupby("text_clean")[target].nunique()
print("重複テキストのうち target が食い違っているもの:", (g > 1).sum(), "種類")
重複しているテキストを含む行: 292
ユニークなテキスト数: 11223 / 11370
重複テキストのうち target が食い違っているもの: 2 種類

STEP 3 の「147行ぶん余っている」と数が合いませんが、これは数えているものが違うからです。
147 は 11,370 − 11,223、つまり消せる行の数
292 は duplicated(keep=False) の結果なので、1回目も含めた重複に関与している行の数です。
内訳は、2回以上出てくる本文が145種類あって、そこに292行が絡み、1種類につき1行を残すと147行が消える、という関係です。

そのうち2種類は、同じ本文なのに target が 0 と 1 で食い違っていました。
1件、中身を見ます。

for t in g[g > 1].index[:1]:
    rows = df[df["text_clean"] == t]
    print("text:", t[:85] + "...")
    print("target:", sorted(rows[target].tolist()), "| keyword:", rows["keyword"].tolist())
text: in case of a nuclear attack to a sprawling bureaucratic agency tasked with mobilizing...
target: [0, 1] | keyword: ['nuclear%20disaster', 'nuclear%20disaster']

同じ本文、同じキーワードで違うラベル。ラベルノイズです。
2件だけなので実害はありません。ただ、このデータのラベルは人手で付いていて完璧ではない、という証拠にはなります。
CV スコアが 1.0 に届かないのは当たり前だと分かっていれば、そこを詰めようとして時間を溶かさずに済みます。

重複でもっと効いてくるのは交差検証のほうです。
同じ本文が train 側と valid 側に分かれると、モデルは答えを見たことがある問題を解くので、CV スコアが実力より高く出ます。

続いてノイズ要素を数えます。

df["has_url"] = df["text_clean"].str.contains(r"https?://", regex=True)
df["has_mention"] = df["text_clean"].str.contains(r"@\w+", regex=True)
df["n_hashtag"] = df["text_clean"].str.count(r"#\w+")

pd.DataFrame({
    "URLを含む率": df.groupby(target)["has_url"].mean(),
    "メンションを含む率": df.groupby(target)["has_mention"].mean(),
    "ハッシュタグ平均個数": df.groupby(target)["n_hashtag"].mean(),
}).round(3)
        URLを含む率  メンションを含む率  ハッシュタグ平均個数
target
0         0.463        0.0       0.209
1         0.554        0.0       0.325

URL を含む行は全体の48%で、災害側のほうが高いです(0.463 から 0.554)。
報道記事へのリンクが多いのでしょう。has_url はそのまま使えます。

ハッシュタグは災害側が約1.55倍(0.209 から 0.325)。これも弱い特徴量になります。

メンションが両方 0.000。1件もありません。

この 0.000 は追いかけました。

print("'@' を1つ以上含む行数:", df["text_clean"].str.contains("@", regex=False).sum())
print("正規表現 @\\w+ にマッチする行数:", df["text_clean"].str.contains(r"@\w+", regex=True).sum())
'@' を1つ以上含む行数: 25
正規表現 @\w+ にマッチする行数: 0

@ を含む行は25行あるのに、@\w+ には1つもマッチしません。
中身を見たら "the cameraman panicking @ tae""This doctor @ my job" のように、at の意味で使われた @ でした。

メンションはこのデータセットを作った時点で除去済みです。
気づかずにメンション数を特徴量に入れると、全行0の列が1本増えるだけで終わります。

集計に出てきた 0.000NaN は、そのまま受け入れて次に行くと危険です。
きれいすぎる0は、その列が存在しないか、前処理で消されているサインです。
生データを数行 print すれば済む話です。

STEP 10. 仮説を次の一手に落とす

EDA の成果物はこのリストです。

分かったこと

  1. 1行 = 1ツイート、11,370行。id は連番で使えない
  2. target は 0 が 81.4% の不均衡データ。Accuracy は使えない(全部0で 81.4%)
  3. keyword (219種)はそのまま効く。災害率 0.000 から 0.963。ただし URL エンコード済み(%20)で 1,821行が汚れている
  4. 必ず非災害のキーワードは18個あるが、必ず災害は0個。非災害側は比喩(battle は株や K-POP)が原因で、災害側が 1 に張り付かないのは target が「いま起きている災害の報道か」を指しているから。同じ地震でも追悼や募金は 0
  5. location は 30%欠損、4,504種の自由入力。欠損有無の災害率差は 0.171 対 0.193 で小さい。Ireland の 0.742(31件)は検算すると本物だが、UK 0.403 と London 0.116 の差が実体か表記ゆれかは判定できないので、正規化は保留
  6. text は140文字で打ち切り。実体化前は 1,006行、html.unescape 後は 1,073行が張り付く。長さ特徴は打ち切り判定に近い
  7. 頻出語は報道語彙と会話語彙で分かれる。ただし出現回数の生値は行数比 4.4倍に引っ張られるので率で見る。fire は災害側 2.08倍で、有無だけでは決まらないので文脈が必要
  8. 重複292行(消せるのは147行)、id 以外が同じ行が20行、ラベル食い違い2種類。メンションは除去済み

次の一手(特徴量)

  • keyword%20 デコードして、(a) カテゴリ変数として target encoding、(b) text の先頭に連結して言語モデルに渡す。target encoding は必ず fold 内で計算する。全体で計算すると答えを覗いたことになります
  • texthtml.unescape をかける。&amp; のままではトークナイザが余分に分割する
  • has_url / n_hashtag / location_is_null / text_len を数値特徴量として追加
  • 打ち切りフラグを別に立てる。判定は html.unescape したあとの長さが 140 かどうかで見る
  • location の表記ゆれ吸収は後回し

次の一手(検証)

  • StratifiedKFoldtarget の比率を fold 間で揃える。0 が 81.4% なので、層化しないと fold ごとに比率がぶれます
  • 重複テキストが fold をまたがないようにする。同一本文は同じ fold に入れる(GroupKFold の group に本文のハッシュを使う等)。またぐと CV が実力より高く出ます
  • 指標は AUC を主軸に。少数派クラスの検出を重視するなら PR-AUC や F1 も併記
  • ベースラインは TF-IDF とロジスティック回帰。ここを超えられない改善案は採用しない

このデータでは扱えなかったこと

このデータは1ファイル構成なので触れませんでした。Kaggle コンペのように train と test が分かれているなら、上に2項目足す必要があります。

  • train と test で分布を比較する。主要な列の平均や分布が大きくズレていたら、その列への依存を減らす
  • train にあって test に無い列を洗い出す。目的変数と強く相関する列が train だけにあるなら、それはリーク源で使えません。set(train.columns) - set(test.columns) で機械的に出せます

コピペで使える EDA チェックリスト

次に自分がデータを開くとき、上から順に潰します。

■ 全体像
□ shape を見て、1行が何を表すかを言葉にした
□ head() でテキスト列が省略されていない(max_colwidth を広げた)
□ info() で型が想定と合っている(数値が object になっていない)
□ nunique() を見て、行数と一致する列(= ID)を特徴量から除いた
□ nunique() が行数に近い列(高カーディナリティ)に印を付けた
□ 各列の意味と、どう作られた値かを書き出した

■ 目的変数
□ クラス比率を確認した
□ 全部多数派と答えたときのスコアを計算した
□ その比率に合う評価指標を選んだ(不均衡なら Accuracy を使わない)

■ 欠損
□ 列ごとの欠損率を出した
□ 複数列が同時に欠損する行がないかクロス集計で見た
□ 欠損しているかどうかと目的変数の関係を測った

■ 特徴量の当たり
□ カテゴリ列ごとに groupby(col)[target].mean() を見た
□ 件数の少ないカテゴリを除外してから率を比べた
□ 率が 0 や 1 に張り付くカテゴリを、偶然かどうか検算した
□ 極端に高い/低いカテゴリも、偶然かどうか検算した
□ カテゴリの値そのものが汚れていないか目視した(エンコード、表記ゆれ、空白)
□ クラス間で件数を比べるとき、行数で割って分母を揃えた(生カウントで並べていない)

■ 分布
□ describe() で終わらせず、ヒストグラムを描いた
□ 分布の端に立つ針を疑った(打ち切り、上限、デフォルト値)
□ 目的変数で層別して分布を重ねた

■ 汚れ
□ 完全重複行と重複テキストを数えた
□ 重複の中でラベルが食い違うものを探した
□ 集計結果の 0.000 や NaN を鵜呑みにせず、生データで確かめた

■ 検証設計
□ 層化が必要か決めた(不均衡なら StratifiedKFold)
□ fold をまたぐとリークする単位を特定した(今回は重複テキスト)
□ train と test の分布を比較した(分かれている場合)
□ train にしか無い列を洗い出した(分かれている場合)

まとめ

head() の次に何を見るか。
今回たどり着いた順番は、info()nunique() から始めて、欠損、目的変数の分布、カテゴリ別の率、テキストの長さ、頻出語、重複とノイズ、と降りていく形でした。

今回いちばん役に立った発見は、どれも1歩踏み込んだところで出てきました。

  • 欠損率の表で止めずに、欠損有無で災害率を比べた
  • 災害率 0.000 を見て、偶然かどうか検算した
  • describe()max 149 で止めずに、ヒストグラムを描いた
  • 頻出語を出現回数のまま並べず、クラスの行数で割った
  • メンション率 0.000 を鵜呑みにせず、生データを print した

逆に、最初に書いた欠損率の表や describe() の出力そのものからは、何も出ていません。
出てきた数字に1回だけ多く「なぜ」を聞く。それだけの差でした。

次はここで立てた仮説を、TF-IDF とロジスティック回帰のベースラインで検証していきます。

付録 比率の差の検算

STEP 4 で「無作為に分けただけでこの差が出る確率は 1% 未満」と書いた部分の中身です。
EDA の本筋ではないので、飛ばしても以降に影響しません。

location が欠損している 3,418行の災害率が 0.171、欠損していない 7,952行が 0.193。
知りたいのは、欠損の有無と災害率が無関係だとしたら、この 0.022 の差はどれくらい起きやすいのかです。

無関係なら、どちらのグループの災害率も全体の 0.186 に近い値になります。
ただし行数は有限なので、無作為に分けても2つの率がぴったり一致することはありません。
このズレの標準的な大きさを標準誤差と呼びます。比率の場合は次の式で出ます。

\sqrt{p(1-p)\left(\frac{1}{n_1} + \frac{1}{n_2}\right)}

p は全体の率、n_1n_2 は2グループの行数です。
行数が増えると分母が大きくなるので、標準誤差は小さくなります。

p = df[target].mean()
n1 = df["location"].isnull().sum()
n2 = len(df) - n1
se = np.sqrt(p * (1 - p) * (1 / n1 + 1 / n2))

loc_na = df["location"].isnull()
diff = df.loc[~loc_na, target].mean() - df.loc[loc_na, target].mean()
print("標準誤差:", round(se, 4))
print("差は標準誤差の", round(diff / se, 1), "")
標準誤差: 0.008
差は標準誤差の 2.8 倍

この「標準誤差の何倍離れているか」が z です。
z が 2 を超えると、無関係なのにそこまで離れる確率は 5% を切ります。3 を超えると 0.3% 程度です。
今回の z は 2.8 で、確率にすると 1% を下回ります。この確率が p値 です。

最後に注意点です。
p値が小さいことは「差がある」の根拠にしかならず、「差が大きい」とは何も言っていません。
行数が増えれば、どんなに小さな差でも p値は小さくなります。
特徴量として使えるかどうかを決めるのは差の大きさのほうで、今回はそれが 0.022 しかなかったという話です。

参考

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?