データセットの綴りミスは必ず直すべきか?
前回書いた記事では、綴りミスなどの修正に用いる辞書を手動で構築する方法を紹介しました。
本記事では、Petfinderコンペを題材に「データセットの綴りミスは必ず直すべきか?」という問いについて考えたいと思います。
自分なりの回答
- 綴りミスなどもデータの特徴の一つ
- 課題設定によっては、敢えて修正しない選択肢もあり得る
- 「修正度合い」という特徴量作成や同一ユーザの特定などに活用する方法もありそう
概説
綴りミスの修正は、データセット全体の完成度を高め、より良い機械学習モデルの構築に繋がる可能性があります。ただし、綴りミスなどもデータが持つ大切な特徴の一つであることに留意する必要があると思います。
前回の記事でも扱ったPetfinderコンペを例に考えます。
このコンペでは、次のようなデータが提供され「マレーシアのペットショップでの犬・猫が引き取られる早さ」を予測しました。
- ペットの画像形式データ
- 説明文やペットの名前などのテキスト形式データ
- 身長・体重・属性などのテーブル形式データ
- 必要に応じて外部データ
ここで、犬・猫を引き取る人の気持ちになってみましょう。説明文は実際にペットに付与されていた情報です。この説明文中の綴りミスは、引き取り手のペットに対する印象を悪くしていた可能性が考えられるのではないでしょうか。(同じような価格・種類のペットが2匹いた場合、説明文がきちんとしている方のペットが選ばれやすそうです)
このような仮説のもとで考えると、闇雲に綴りミスを修正してしまうのは、性能の向上ではなく悪化に繋がってしまう可能性があります。(Petfinderコンペでは修正の有無ではスコアはほとんど変わりませんでした)
どのような対応を取るべきかはデータと課題設定に依る話です。例えば、綴りミスを修正した上で、新規に「修正した文字数」などの特徴量を加えるなどのやり方もあるでしょう。他にも、同じような綴りミスをしているデータを見つけて、データセット上は公開されていない同一のグループを特定しにいくようなアプローチも考えられるかもしれません。仮に特定したグループがtrain, testにまたがっていれば、leakageのような要素で性能向上に繋がる可能性があります。
綴りミスに限らない話ですが、何事も思考停止で取り組むのではなく、データをしっかりと眺めて求められる課題設定に即した処理を実施していく必要があると思います。
typo辞書を人力で作るためのTips
準優勝したKaggleのPetfinderコンペでは、元データの英単語の綴りミスなどの修正に用いる辞書を手動で構築しました。
本記事では、このような辞書を構築した方法についてまとめます。
結論
気合いで目で見ながら作りました。
概説
今回、元データを確認し、最終的には次のような辞書を構築しました。例えば1行目は "sherpherd" という綴りを "shepherd" に修正するという意味です。最終的には200個くらいのペアを作成しました。
{ u"sherpherd": u"shepherd", u"sherphed": u"shepherd", u"sherperd": u"shepherd", u"sherpard": u"shepherd" }
ここで大切なのは、如何にして綴りミスを効率的に発見するかです。もちろん自然言語処理の技術を活用すれば、ある程度自動的に綴りミスを発見できるかと思います。ただし今回は実装の時間・手間や網羅性などを考慮し、人力で辞書を構築することに決めました。
綴りミスを発見した手順は以下の通りです。最初の2つは同じチームだったtakuokoさんに作業してもらい、私は最後の気合いの部分を担当しました。
- embeddingのout of vocabularyとなる単語を抽出する
- 登場回数を集計して降順にソートする
- Wordに貼って、赤下線を参考にしながら綴りミスを見つける
embeddingのout of vocabularyとなる単語を抽出する
最初に、対象のデータセットに含まれている単語の中で、分散表現の学習済モデルには含まれていない単語(以下、out of vocabulary)を抽出します。この処理を通じて、対象の単語群から綴りが正しい単語を取り除くことが可能です。
登場回数を集計して降順にソートする
次いで、out of vocabularyの中で、単語の登場回数を集計し、降順にソートします。例えば次のような結果が得られます。
sherpard 100 sherperd 50 sherphed 2 sherpherd 1
この処理には、修正し得る単語候補の優先順位を付ける意味合いがあります。
Wordに貼って、赤下線を参考にしながら綴りミスを見つける
この処理では集計結果を丸ごとWordに貼ることで、綴りミスを峻別しやすくしています。次の例では参考までに正しい綴りを一番上に載せました。正しい綴り以外に赤下線が引かれていると分かります。このWordの機能を活用することで、少しだけ効率的に綴りミスを探すことができました。
おわりに
本記事では、綴りミスなどの修正に用いる辞書を手動で構築する方法を述べました。あくまで、私がPetfinderコンペの際に取り組んだ方法に過ぎないので、より効率的な方法があるかとは思いますが、一つのやり方として参考になれば嬉しいです。
日本語版text8コーパスから単語の分散表現を得る
はじめに
手軽に日本語の単語の分散表現を得ようと思ったら、これが良かった。数分でやりたいこと終わった。
— u++ (@upura0) June 15, 2019
===
日本語版text8コーパスを作って分散表現を学習する - Ahogrammer https://t.co/wySQTPhIw9
上のツイートが思いのほか反響が良かったので、簡単ではありますが処理方法をまとめておきます。
手順
- コーパスのダウンロード
- gensimでの読み込み
gensimでの読み込み
あとはGithubのREADME.mdの記載通りに読み込むだけです。sizeは単語ベクトルの大きさを意味します。
from gensim.models import word2vec sentences = word2vec.Text8Corpus('ja.text8') model = word2vec.Word2Vec(sentences, size=200)
model['金融']
array([ 6.60341680e-01, -1.64953339e+00, 3.32918353e-02, -9.80880857e-02, 1.20754802e+00, 1.94470084e+00, 1.62470174e+00, -9.61422026e-01, (中略) 2.30138436e-01, -2.17804956e+00, 6.24406040e-01, -1.56991804e+00], dtype=float32)
Kaggle地震コンペ振り返り(public 5th -> private 212th)
2019年1〜6月にわたって開催されていたKaggleの「LANL Earthquake Prediction」コンペに参加し、銀メダルを獲得しました。
public LBの時点では賞金圏の5位につけていて、ドキドキしながら最終結果を待ち構えていました。
心臓ちぎれそうだけど、このドキドキが味わえる幸せ。これがKaggleなんだよなああああああああああ。
— u++ (@upura0) June 3, 2019
残念ながら最終結果は212位に転落しましたが、なんとか銀メダルも獲得でき、学びの多いコンペでした。
Kaggle地震コンペ、public賞金圏5位からの転落でした……。金メダル圏内も手持ちにありましたが、絶対に選べないやつだったので悔いはないです!!過学習の恐ろしさを身に沁みて実感できて、また学びになりました٩(๑`^´๑)۶ 入賞したkaggler-jaの皆さん、おめでとうございます!! pic.twitter.com/t10gjL8U60
— u++ (@upura0) June 4, 2019
本記事では、通称「地震コンペ」とも呼ばれた本コンペを、筆者自身の視点で時系列的に振り返ります。
なお既にdiscussionにはアプローチを投稿しています(英語)。
コンペの概要
扱うのは現実世界のデータではなく、実験室内での地震を起こした際の波形データでした。
- 入力:波形
- 予測対象:何秒後に地震が発生するか
より具体的な説明は、先に公開されている本コンペの振り返り記事をご参照ください。
コンペ参加(5月3日)
本コンペは1月から長らく開催されていましたが、参加を決めた直接のきっかけは下記のツイートでした。最初は興味本位でKernelを眺めただけでしたが、教育目的で公開されたKernelの内容が知見に富んでおり、コンペに惹かれていきました。特に、実際にモデリングが記述されているKernelではなくEDA的なKernelが素晴らしかったです。
地震コンペ、「俺はこれで1.392(提出時27位) になったから!」みたいな kernel が生えている。
— 俵 (@tawatawara) May 3, 2019
そのまま submission 出来るわけでは無いっぽいけど荒れそうhttps://t.co/LtxiwGenXO
ソロ参加期間
既にコンペ終了まで約1カ月で、既に3500チームほど参加していました。
特徴量
時間の節約も兼ねて、特徴量作成の部分は全て上記のKernelに任せ、モデリング部分で工夫していく決断をしました。(いま振り返ると、この時点で最終的な結果はある程度決まっていたと思います)
特徴量は800以上あったので、下記の2プロセスで389まで特徴量を削りました。
- LightGBMのfeature importance (gain) 上位400件に絞り込み
- adversarial validationのfeature importance (gain) 上位11件を削除
CV
CVは下記の設定を採用しました。「shuffle=False」も試しましたが、各Foldのスコアが大きくブレたので不採用にしました。
- KFold
- 8 fold
- shuffle=True
本コンペは回帰問題でしたが、目的変数の大小をバランスよく分けるために、Stratified KFoldも検討しました。ただし、予測値の相関をKFoldと比較したところ0.998でほとんど変化がなかったので不採用としました。
- Stratified KFold
- 8 fold
- shuffle=True
- y = pd.cut(y_train['time_to_failure'], 8, labels=False)
モデル
モデルは {LightGBM, XGBoost, Catboost, MLP, RNN} を試しました。LightGBMが最もpublic LBスコアが良かったです。
本コンペでは、ハイパーパラメータを変えることで、かなりpublic LBスコアが変わりました。
- objective: 'regression' -> 'gamma'
- 1.433 -> 1.349
- feature_fraction = max(0.1, sqrt(n_features)/n_features)
- public LB score +0.004
feature_fractionは次のブログを参考にしました。
問題点
今回特徴量を作成したKernelでは、一部の重複を許してtrainデータを水増ししていました。その上でCVは単純に「shuffle=True」をKFoldを採用しているので、CVは当たり前のように過剰に良くなってしまいます。そのためLightGBMのearly stoppingが正しく機能しないと推察されたので、意図的にrounds数を決め打つ戦略を取っていました。
チーム参加期間(コンペ終了10日前ごろ)
ソロで30位以内に入っていたこともあり、当時金圏にいたmtmtさん(@mtmt14079706)とチームマージしました。最終的にはmtmtさんが既にチームマージを決めていたTelash(@TERACHIYANNU)さん含め、3人チームでコンペ終了まで戦いました。
マージ後は、主に次の2つに取り組みました。
- お互いのアイディアを使いながらシングルモデルの精度を上げる
- アンサンブルを試す
シングルモデルの向上
前者について、諸々の試行錯誤は割愛しますが、最終的には次の辺りを調整しました。
- LightGBMのハイパーパラメータ
- 特徴量の削り方
アンサンブル
後者はなかなか上手くいきませんでした。原因としては、1日のsubmit数が最大2と少なく、public LBに使われているデータ量も13%と少なかったことが挙げられます。特にstackingは、なかなかpublic LBスコアが向上しませんでした。
最終的に採用したアンサンブルは、勝手に命名した「rounds averaging」でした。先述した通りearly stoppingが使いづらかったので、次のようなrounds数が異なる複数モデルを作り、結果を平均しました。
- lgbm training rounds = 10000 (public LBスコア: 1.263)
- lgbm training rounds = 12000
- lgbm training rounds = 14000
- lgbm training rounds = 16000
- lgbm training rounds = 18000
- lgbm training rounds = 20000 (public LBスコア: 1.262)
6つを平均したモデルはpublic LBスコア: 1.259となりました。
最終submit選択
最終submitの2サブは、次の通りです。
- ここまでここまで説明した6モデル rounds averaging
- 上記の派生版:'mean'関連の特徴量を全て削除して学習したモデル
discussionや自らの試行錯誤を通じて平均値関連の特徴量が過学習の要因になりがちだと感じていたので、後者が比較的保守的なsubmitでした。
コンペ終了(6月4日)
最終結果は冒頭でも述べた通り、212位の銀メダルでした。手持ちには金メダル相当のモデルもありましたが、チームで納得できる最終submitは選べたので、個人的には後悔はありません。
唯一の反省点を挙げるとすると、保守的なsubmitはもう少し攻めたモデルにしても良かったかもしれないと感じています。
public 28位からprivate 10位に入ったGibaさんがdiscussionで解法を非常に簡潔にまとめていました。
The equation used to post- process the top-10 solution was:
ypred = ypred * 6.51 / mean(ypred)
本コンペではdiscussionでtestデータの可能性があるデータが考察されていて「public LBとprivate LBで分布の違うデータが使われている」と議論されていました。もちろん筆者のチームもその辺りを考慮した最終submit選択をしたつもりではあったのですが、private LBで上位に入った方々は完全にデータソースを決め打った戦略を採用していました。(自分たちのsubmitも、Gibaさんの方法で全体を約1.1倍するだけで金メダルに入るprivate LBスコアとなりました)
本質を見抜いて適切に戦う部分が大切なコンペだったので、特徴量生成をKernelに任せてしまった時点で、ある程度勝負は付いていたのかなと振り返って感じています。
おわりに
public LB賞金圏からの転落を体験したコンペでしたが、過学習の恐ろしさを身に沁みて実感でき、学ぶを深めることができました。チームメイトのmtmtさん、Telashさんに金メダルを渡せなかったのが残念なところですが、楽しいチーム戦でした。
本コンペでの銀メダル獲得をもって、Kaggle Masterになりました。今後とも精進していきます。
2018年のGWにKaggleを始めて約1年、Kaggle Masterになりました🎉 twitterやkaggler-ja slackなどで多くの方とやり取りさせていただき、ここまで成長できたと思います。コミュニティに貢献できるよう、今後も精進してきたいです💪 pic.twitter.com/acJ4ZgyHuo
— u++ (@upura0) June 4, 2019
人工知能学会2019@新潟のご飯まとめ
2019年度 人工知能学会全国大会 (第33回)に参加してきました。
本記事では技術的な話は一切書かず、新潟で満喫したご飯をまとめます。
須坂屋そば
2回目。
おわりに
新潟駅と会場の朱鷺メッセ付近のお店に限定されますが、新潟料理を満喫できました。ホテルの朝食バイキングのお米も美味しかったです。
ほぼ毎食、いろいろな方と食事を共にすることができました。皆さま本当にありがとうございました。
twitter経由で毎食いろんな人と会食できてスーパー楽しい
— u++ (@upura0) June 6, 2019
AtCoder Beginner Contest 127をPythonで解く
AtCoder Beginner Contest 127をPythonで解きました(A〜D)。
A - Ferris Wheel(100点)
- 丁寧に条件分岐
A, B = list(map(int, input().split())) if (A <= 5): print(0) elif (A >= 13): print(B) else: print(B//2)
B - Algae(200点)
- 漸化式を指示通りに書く
def f(r, D, x): return r*x - D r, D, x = list(map(int, input().split())) for _ in range(10): x = f(r, D, x) print(x)
C - Prison(300点)
- 「左端として一番右にあるゲート」と「右端として一番左にあるゲート」を調べる
- 解が0個の場合も考えて、maxを取る
N, M = list(map(int, input().split())) L = [0] * M R = [0] * M for i in range(M): L[i], R[i] = list(map(int, input().split())) print(max(0, min(R) - max(L) + 1))
D - Integer Cards(400点)
- リストAに「書き換える選択肢となる要素」を追加
- 大きい順にN個取った和が解となる
- 方針は分かった後にTLEを取るのが難しかった(提出)
- 「書き換える選択肢となる要素」を追加するとき、事前に大きい順にソートしておき、N個より多くは追加しないようにすると通った
N, M = list(map(int, input().split())) A = list(map(int, input().split())) cards = [list(map(int, input().split())) for i in range(M)] cards = sorted(cards, key=lambda x: x[1], reverse=True) cnt = 0 for card in cards: A += [card[1]]*card[0] cnt += card[0] if cnt >= N: break A.sort(reverse=True) print(sum(A[:N]))
AtCoder Beginner Contest 126をPythonで解く
AtCoder Beginner Contest 126をPythonで解きました(A〜E)。
A - Changing a Character(100点)
- いろんなやり方はあると思うが、計算量も余裕があるので愚直に
- マッピング用のdictを用意し、K番目のときだけ適用する
N, K = list(map(int, input().split())) S = input() convert_dict = { 'A': 'a', 'B': 'b', 'C': 'c' } ans = '' for i, s in enumerate(S): if i != (K - 1): ans += s else: ans += convert_dict[s] print(ans)
B - YYMM or MMYY(200点)
- もっと綺麗に書ける気もするが、前半・後半がMMになり得るかをそれぞれ確認する
S = input() month_flg0 = True month_flg1 = True if (S[:2] > '12' or S[:2] < '01'): month_flg0 = False if (S[2:] > '12' or S[2:] < '01'): month_flg1 = False if month_flg0 and month_flg1: print('AMBIGUOUS') elif not(month_flg0) and not(month_flg1): print('NA') elif month_flg0: print('MMYY') else: print('YYMM')
C - Dice and Coin(300点)
- 数学だった
- 「何回連続でコインが表を出す必要があるか」を計算する(コードのコメント参照)
import math N, K = list(map(int, input().split())) ans = 0 for n in range(1, N+1): # 2^(num_success) * n >= K なる num_success # 2^(num_success) >= K/n # num_success >= log_2(K/n) num_success = max(0, math.ceil(math.log2(K/n))) ans += (1/2) ** num_success print(ans/N)
D - Even Relation(400点)
- editorial.pdf の解説が分かりやすい
- sys.setrecursionlimit(20000000) 忘れでREが出て辛かった提出
import sys sys.setrecursionlimit(20000000) def dfs(node, distance): for next_node, edge_size in G[node]: if ans[next_node] == -1: ans[next_node] = ((distance+edge_size) % 2) dfs(next_node, distance+edge_size) N = int(input()) G = [[] for i in range(N)] for i in range(N-1): u, v, w = map(int, input().split()) G[u-1].append([v-1, w]) G[v-1].append([u-1, w]) ans = [-1]*N dfs(0, 0) print(*ans, sep='\n')
E - 1 or 2(500点)
- ABC120 Dでも使ったUnionFindTreeクラスを貼った
- uniteした回数を持たせた
import sys sys.setrecursionlimit(20000000) def dfs(node, distance): for next_node, edge_size in G[node]: if ans[next_node] == -1: ans[next_node] = ((distance+edge_size) % 2) dfs(next_node, distance+edge_size) N = int(input()) G = [[] for i in range(N)] for i in range(N-1): u, v, w = map(int, input().split()) G[u-1].append([v-1, w]) G[v-1].append([u-1, w]) ans = [-1]*N dfs(0, 0) print(*ans, sep='\n')