Certified ScrumMaster (CSM) training course に参加した話

概要

タイトルの通り、CSM training course に参加しました。

この記事では主に以下の情報を未来の自分と興味のある人向けに書きます。

  • CSM や Scrum についての基礎知識・知識への導線
  • CSM training を受けた感想・学び
  • training の内容 (public なものではないので)

背景

私は普段スクラムチームで働いているのですが、今年から Scrum Master (SM) ロールが空いてしまうので私が SM になろうとしたのが背景です。前任の SM の方に CSM が良い training の場になるとおすすめして頂いたのでこのコースを選びました。

ちなみに私自身はこれまで Scrum でいう Developers (Dev) ロールの SWE として働いていて、今後も SM 専属でやりたいというよりはメインでは Dev として働きたいと思っていました。ただ SM と Dev の兼任は非推奨らしく、この辺どうするかな〜というのが一つの悩みです。(なので SM を検討している人はキャリアの舵取り観点でこの辺も一度考えたほうが良いかも...)

CSM とは

CSM とは Odd-e Japan によると

スクラムマスターの認定資格にはいくつか種類がありますが、最もメジャーと言われている認定資格は、Scrum Alliance® の認定スクラムマスター(Certified ScrumMaster®:CSM®)です。

Scrum Alliance® においては、まずこの認定資格を取得し、実践経験を積んだうえで、さらに上位資格へステップアップしていくことになります。

とのことで、Scrum Alliance における Scrum Master のための基礎資格です。より上位の資格としては CSM® → A-CSM® → CSP®-SM があるらしいですが、今回私が受けたのは CSM® 向けの training です。

私が受けた training は日本人唯一の CSP Educator である認定スクラムトレーナー(CST®)の 江端一将(ebacky)san が講師を務めるもので、13:00~19:00 * 5 days 税込み ¥330,000 のものです。Tech会というサイトを経由して参加しました。なお Product Owner (PO) 向けの CSPO や Dev 向けの CSD というものもあり、SM 以外の方向けの training もあります。ちなみに CSM は training に参加すれば全員が資格をもらえるというわけではなく、

本トレーニング中に能力を評価された方は、認定スクラムトレーナー(CST®)によって Scrum Alliance® に登録されます。

残念ながら、本トレーニングは、お金で資格を取得(購入)するための研修ではございません。

Scrum Alliance® に登録されるとオンライン試験の受講資格が与えられ、このオンライン試験に合格すると認定スクラムマスター(CSM®)と認定されます。認定スクラムマスター (CSM®) は、スクラムマスターとして必要なスクラムの基礎を理解していることを示します。

とのことで、ちゃんと training 内で SM としての素養を認められ、かつその後試験に合格しなければ CSM を取得できないです。

Scrum とは

Agile の代表的なフレームワークの一つで、Scrum Guide (日本語 ver) にその内容が書かれています。ここで書かれているのは基本的にはスクラムフレームワークであり、

スクラムフレームワークは意図的に不完全なものであり、スクラムの理論を実現するために必要な部分のみが定義されている。

ここで概要を述べたように、スクラムフレームワークは不変である。スクラムの⼀部だけを導⼊することも可能だが、それはスクラムとは⾔えない。すべてを備えたものがスクラムであり、その他の技法・⽅法論・プラクティスの⼊れ物として機能するものである。

と書かれているように、実際にはプロダクト開発に適用するためフレームワークの上に状況に応じた様々なプラクティスが乗って運用がなされるものだと理解しています。例えば Transparency Inspection Adaptation を pillars としたり Sprint Planning などのイベントの実行はフレームワーク、一方でどのように pillars を活用したり Sprint Planning を実行 (ex. N hours で〇〇という内容をする) するかといった話がプラクティスと理解しています。

Scrum のイメージ図 from https://www.pastoraldog.com

感想

ここからは参加した感想を書いていきます。といっても training の内容にふれるのであまり踏み込んだ話はできないですが...

まず、この training はめちゃくちゃタフでした...。3 日目くらいに一度胃薬を飲んだ日がありました。何がつらいかというと、私の場合は ① それぞれが全く異なる個性を持っていることにより自然に共通認識を持てることを期待できない人々 (年齢・職種・タイプ・経歴・etc...)② 大勢集まった集団において ③ 理解しきれていない Scrum を駆使しつつ ④ CSM を取得するに値するように集団の成果に貢献しないといけない という部分がきつかったです。結局 training 完了後の今でも十分パフォーマンスを出せたと思っていなくて、テストの受験資格を貰えない可能性が十分あると思っています...。

また、学びに関してはかなりありました。Scrum を学びたい人にとって、参加する価値は十分にある training だと断言できます。まず理論に関して、public なガイドや書籍を読んだだけでは知り得ない内容を体験を伴うことで非常に効率よく学ぶことができました。具体的には、フレームワークの正しい解釈とその背景にある考え方を知ることができ、またその重要性自体を学ぶことができました。またフレームワークに則ったプラクティスのデザインについても独学では得られない有効な学びを得られたと思っています。次に実践についても、何が難しくてその場合どのように対処していけば良いかということを 5 days の間常に考え続けており、ピュアにスクラムの実践に向き合い続けることができたためこの形式でしか得られないような貴重な経験を得られたと思います。これらは独学を頑張れば学び得るが難易度が高いので training に参加した方が良い、というよりは独学では学び得ないものと言ってもよいものだと思っていて、この意味で私はある程度 Scrum を真面目にやりたいのであればこの training に参加することをおすすめします。(精神的にはつらいですが...)

学んだこと

これもあまり踏み込んだ内容については書かないですが、一般的な key takeaways だけ残しておきます。どちらかというと training でこう教えられたというより自分がこう解釈したやこう思ったという内容なので、training で実際に学べることとは乖離がある可能性もあります。

  • Scrum Team や Scrum 自体の現状を知るために、Pillars (Transparency Inspection Adaptation) が保てているか、そして Values (Commitment Focus Openness Respect Courage) が育めているかという観点で自問自答すれば改善点が見えてきそう。
  • SM として正しく本質的にあるいは未然に問題解決を行うために、チームやプロジェクトの状態やアイデアのモデル化及びその包括的な解釈が役に立ちそう (ex. チームで次の Quarter にどんなものを作るか考える際にどうやって決めるかを一度モデル化してみて、それで意思決定を行う上でどんな問題が起こるか考えてみる)
  • SM は Scrum がうまくワークするために team だけでなく organization に対してもアプローチする必要がある。
  • (個人の反省として) Scrum とは関係ないけど、集団に対する提言のやり方は結構反省点が多かった。例えばただ可能性を提示するだけでは、特に集団の複雑性が高い場合、ただ議論の複雑度を増していることにしかならない場面は結構ある。

おわりに

この training に参加して、Scrum への理解が飛躍的に増しました。ふと現職に転職して最初 team としての開発能力や適応能力などの完成度が異常に高いことにびっくりしましたが、その一部の要因が Scrum をある程度以上ちゃんと運用できていたからだと思いました。

本文でも書きましたが、Scrum にある程度真面目に取り組みたい方は training への参加を考えても良さそうだと思いました。個人的には多少 Scrum を経験したあとに参加するとより理解が深まる気がします。

また、初めて Scrum をやる人は、(もしかしたらかえって表層だけの理解に陥る原因になってしまうかもしれないですが) 私が読んだこの本は漫画形式で実際に Scrum を適用する話とともに Scrum を学べるので、一読してから参加すると多少イメージが湧きやすくなるかもしれないです。

HHKB Professional HYBRID Type-S を買った話

概要

最近 Happy Hacking Keyboard Professional HYBRID Type-S 無刻印/雪(英語配列) を購入したので周辺情報を書き残しておきます。

https://www.pfu.ricoh.com/direct/hhkb/detail_pd-kb800yns.html

購入理由と使用感

もともと HHKB Professional BT を使用していたのですが、以下の理由で HYBRID Type-S 無刻印/雪 へと買い替えました。
1. desktop PC (win&linux) の購入に際してキー入力で接続先を変えられる HYBRID がほしい
2. よく妻にうるさいと言われる 😇 ので静音性の高い Type-S がほしい
3. 無刻印/雪 の見た目が良すぎる... しかも白いキーボードながら ABS樹脂 の採用により黄ばみに強いらしい
4. あわよくば打鍵感とタイピング速度が向上してほしい
5. BT ではできなかった有線接続が可能

買ってみた感想としては ↑ に上げた5つのモチベーションがすべて満たされ、最高の買い物をしたと思います。
1. Fn+ctrl+数字 で簡単に切り替え可能!
2. めちゃくちゃ静かになった!部屋のドアを開けていても妻に何も言われなくなった
3. 実物は更に写真に勝ってた...愛着が湧く
4. 打鍵感が想像より気持ち良かった。BT より軽くタイピングできる感覚があってタイピング速度も上がった
5. 実はまだ試してない...。けど恐らく可能なので PC 初期設定用に有線キーボードを別持ちしなくて良いし、電池がなくなった際の非常用に良い

最初少し困ったのは BT の時できていたコマンドが効かなくなったことでしたが、これは win mode や mac mode の切り替えを DIP スイッチで行えば解決しました。ちなみにキー入力でもモード切り替えできます (ex. Fn+ctrl+(W|M) → (win|mac))。

アームレスト

折角なら周辺環境を整えようとアームレストを買いました。(HHKB で今まで使っていなかったというと同僚に驚かれた...)

私は元々 macbook のキーボードの上に HHKB を置くいわゆる『尊師スタイル』でタイピングしていたので、公式の セパレート型ウッドパームレスト(ウォールナット)を買いました。購入後しばらく試していると、たしかにタイピング速度は上がるし腕は楽になるのですが、元々 macbook の厚さ分高さがあったところにさらにアームレストで高さが増すので流石に肩こりがキツいと感じるようになりました。

尊師スタイル + セパレート型

そこで、思い切って magic trackpad を手に入れクラムシェルモードで作業をするようにし、そうなるとセパレートである必要もなくなったので非セパレートのアームレストを新たに購入しました。公式のアームレストは言ってしまえば木の板なのにあまりにも高すぎるので、今回はメルカリで個人の方が作成しているものを購入しました。届いたものは十分クオリティが高く、肩こりも減りタイピング速度も早くなりました。

しかし、更にしばらく試していると若干アームレストの高さが足りない気がしてきました。公式のものもそうなのですが、HHKB 用のアームレストは通常高さ 18mm ほどで作られていることが多い気がしています。一方で私が手元で確認したところ、24mm ほどがベストだという結論に至りました。そこでまたメルカリを探し回り、オーダーメイドでアームレストを作ってくれる人を見つけたので 24mm のものを作ってもらいました。値段も公式の半額程度で、送られてきたものは今度こそ完璧でした。今ではこの高さ 24mm ものを愛用しています。

最終形態

おわり

リーダブルコードを読んだ際のメモ

概要

リーダブルコードを読んだので、有用だと思った知見をほぼ自分用にメモとして残しておく。自分は研究時代から考えると6年コードを書いているので割と自分もそうしてるな〜と思うところが多かったが、メモしておいても良さそうなものを書き残す。

2章 名前に情報を詰め込む

ループイテレータ

↓ みたいな感じのコードはよく見るが、これだと間違った参照 (ex. members[j]) が起こり得る。

for i in len(members):
    for j in len(clubs):
        print(f'{members[i]} / {clubs[j]}')

これは ↓ みたいな感じで index に意味をもたせることでリスク軽減可能

for member_i in range(len(members)):
    for club_i in range(len(clubs)):
        print(f'{members[member_i]} / {clubs[club_i]}')

長ったらしいなら ↓ も良い。

for mi in range(len(members)):
    for ci in range(len(clubs)):
        print(f'{members[mi]} / {clubs[ci]}')

変数の命名

結論、その変数が表す概念をその変数が関わるスコープで表すのに十分かつ最小な長さまで伸ばせば良い。長い命名 != 悪。必要なら (エディタの保管機能もあるし近年はディスプレイの画質も上がってきてるし) 伸ばせば良い。スコープの大きな変数であれば有るほど長い名前をつけると良い。

名前のフォーマットを決めると良い

class 名は CamelCase, 変数名は lower_separated, メンバ変数は後に _ をつける (ex. member_) 等。プロジェクトでこういう規約を作っておくと良さそう。

3章 誤解されない名前

動作が誤解される / 複数思いつく命名は避ける

  • filter → select or exclude
    • some_vec.filter("year <= 2021") だと 2021 以下が残るのか除外されるのかが分かり辛い
    • select (=残る) か exclude (=除外する) が良い
  • clip → remove or truncate
    • clip(text, length) だと後ろ length 文字を削除するのか length 文字になるように削除するのか分かり辛い
    • remove (=最後から length 文字削除) か truncate (=length 文字になるように切り詰める) が良い
    • length も max_length という書き方ができたり、単位が曖昧なのでこれに関する工夫が可能 (ex. max_chars, max_bytes 等)
  • bool 型 → is/has/can/should 等を付与
    • 否定形 (ex. disable_ssl = false) でなく肯定形 (ex. use_ssl = true) を使う (これ何度か問題になってるの見たことあるな...)
  • get*
    • get は常識的にはメンバ変数を返すだけの軽量な処理とみなされるらしい。なので、get 関数で重い処理をするのは NG らしい (本当か?)
    • 重い処理を書くなら、get → compute 等の方が良い。
    • 普段 get* で重い処理書いちゃってるので気をつけないと...。

6章 コメントは正確で完結に

入出力のコーナーケースに実例を使う

単にコメントに実例を書くとわかりやすいということ。sklearn の docstring とか見てても書いてあってわかりやすいよね。↓ みたいな感じ。

# 入力をソートする ex. [2, 1, 10, 9, 3] → [1, 2, 3, 9, 10]
def sort_values(inputs):
    ....
    return sorted_values

7章 制御フローを読みやすくする

条件式の引数の並び順

より『変化する』値を左に、より『安定的』な値を右に書く。例えば「もし君が 18 歳以上ならば」の方が「もし 18 年が君の年齢以下ならば」より理解しやすい。

13章 短いコードを書く

コードを小さく保つ

プロジェクトの巨大化に際し、コードを小さく・軽量に維持する保つコツ

  • util 系のコードを積極的に書く・利用する
  • 不要なコード・機能は積極的に削除する (そこに費やした時間が無に帰するように見えて心苦しいかもしれないが)
  • プロジェクトをサブプロジェクトに分割する
  • コードの『重量』を意識する。軽量で機敏にしておく。

14章 テストと読みやすさ

テストを読みやすくする

  • テスト関数の中の処理についても、できるだけ重複処理や冗長な処理をを関数化してより人間の思考でたどりやすくする。
    • 普段自分が書くテストでは結構テストデータとその加工を直書きしちゃったりしてるので気をつけよう...。
  • 関数が行っていることを極力分割して解釈し、2つ以上ある場合はそれぞれ個別にテスト (+できればどちらもをカバーしたものも) する。
  • テストしやすいように開発すると、より良いコードがかけるようになる。
    • ただし、テストのためにテスト対象のコードの可読性や性能を犠牲にするのはやりすぎ。

以上!

kaggle tweet コンペの闇と光 (コンペ概要と上位解法)

概要

先日 to be twitter masters というチーム名で Tweet Sentiment Extraction コンペ (以下 tweet コンペ) に参加したので、まとめに記事を書いておきます。チームメンバーは筆者@fuz_qwa @Kenmatsu4 @tkm2261 @yiemon773 の 5 人で、結果は 5 位となり見事金メダルを獲得できました。

本記事では上位解法の紹介と、これを理解するために必要なコンペ概要の紹介を主眼とします。また、チームでやったことはもう少し軽めの記事として後日まとめようと思っています。

いつも文字数多すぎてごちゃごちゃするので、できるだけ完結に書きたい...。情報足りないところは回答できるので、気軽に質問ください~~

コンペ概要

データ概要

tweet コンペでは下記のような train 27,481 行、public test 3,435 行のデータが与えられます。

f:id:guchio3:20200619235138p:plain:w500
データ概要

このコンペでは text と、その text が {positive, negative, neutral} の内どの感情に当てはまるかを表す sentiment から、text 中のどの部分を根拠に sentiment を選んだかを表す selected_text を予測します。例えば下図のように positive な text として I'd love that. And, don't think of it as easy. Think of it as enthusiastic. が与えられた時、最も良い予測は selected_text である Think of it as enthusiastic. ということになります。

f:id:guchio3:20200620003450p:plain
データの具体例

ちなみに、このコンペのデータは selected_text が存在しない既存のデータセットに対して kaggle がアノテーションしたもの *1 で、この既存のデータと train として与えられたデータの text が若干違います (つまり kaggle によって若干加工されて渡されている)。この元データには private test data にあたるものも入っているのですが、なんと普通にアクセス可能です *2。元データには selected_text は存在しないが text は存在するため、private test data の分析やこれを使った pseudo labeling も可能でした。

また、実はさらにこの元データは本当の元データ、つまり元の tweet からも若干加工されたものになっています。元の tweet をいくつかみてみると、大半が顔文字のはいったものになっており、少なくとも多くのケースでこの顔文字が除去されているようです。

f:id:guchio3:20200620004744p:plain
元textと元textの元textを追加した例

評価指標

評価指標は Jaccard score です。要はどれだけ過不足なく正解の selected_text を予想できたかという値になります。

baseline 的戦略

upvate が非常に多かった Chris の notebookの戦略を例に上げます。

preprocess

空白が続く等のノイズを除去するため、normalized_text = " "+" ".join(text.split()) の処理をします。先頭に空白を付与しているのは、多くの kernel で使われた RoBERTa が word の先頭に空白があるか否かを区別するためです。

また、sentiment を文字として text に追加します。NLP 系のモデリングメタデータを扱う際にはよく使う方法ですね。

加えて、char level で selected text に入るか否かを判別し、tokenizer で区切られた token 内の char が 1 つでも selected_text に入っていたらその token を選ぶようにラベル付けします。これは tokenizer の token 分割の仕方とアノテーションの区切りにズレが有るためで、このやり方の他にもいくつもの工夫が可能です。

f:id:guchio3:20200620011350p:plain
Chris の notebook の前処理

model

RoBERTa を使います。何故か他のコンペとは異なり、(少なくとも huggingface の transformers を使った場合は) BERT より RoBERTa の方がうまくワークするコンペでした。個人的には Tokenizer の違い (RoBERTa は ByteLevelBPETokenizer) かなぁと思っていますが、ちゃんと検証したわけではないです。

また、RoBERTa に付与する head には若干気をつける必要があり、系列方向の構造を保てる形にしないと経験上スコアが大きく下がります。つまり、例えば入力が 128 tokens で RoBERTa の最終層が (768 * 128) だった場合、これを (768) に圧縮すると良くなく、(128) に圧縮するほうが良いです。この kernel では kernel size 1 の 1d CNN で (768 * 128) -> (128) と加工しています。今回のコンペでは系列方向のどこが selected_text に当たるかを予測するコンペなので、この構造を保つことが重要だったのだと思います。

f:id:guchio3:20200620010724j:plain
chris の notebook の RoBERTa

objective

selected_text の開始 index と終了 index をそれぞれ softmax 関数をかけて予測する形を取ります。この他にも segmentation として解く方法や NER (Named Entity Recognition) として解く方法が議論されていました *3 が、少なくとも自分の経験上は結局開始・終了 index を予測する形が最もスコアが良く出ました。selected_text が必ず 1 つしかなく、また連続した領域だったため、この構造を自然に表現できる開始・終了 index の予測という形がハマったのだと解釈しています。

ちなみに、このやり方だと開始 index が終了 index よりあとになってしまうというありえない状況がモデルの予測結果となる可能性もありますが、これは postprocess で対応します。この Chris の notebook ではこの場合『text 全体を予測結果とする』としていましたが、例えば『自信度の低い方を 0 埋めして再判定する』等、色々と工夫が可能なポイントです。

ラベルノイズ

このコンペでは主に 2 つの観点に起因して正解ラベルにノイズがあり、これをどうハンドルするかが (虚無感ありますが) 勝つために最も重要なポイントです。

1 つ目が HTML codes に起因するアノテーションズレです。要は、kaggle が外部サービスにアノテーションを依頼した際、selected_text の index という形式でラベルを取得していたようなのですが、アノテーション時と kaggle が selected_text を作成するときで HTML codes を生のコードで扱うか記号として扱うかに整合性がなく、この差分だけ index がズレてしまったというものです。この現象は discussion で議論されており、某運営によりコンペ開催後に対応されました (leader board も update された)

f:id:guchio3:20200620014901p:plain
HTML codes に起因するアノテーションズレに関する discussion

2 つ目のノイズが (恐らく) 空白数に起因するアノテーションズレです。これは magic として discussion でも暗に議論されており、運営による対応もなかったため今回上位に入るか否かはこれを適切に扱えたか否かに大きく依存します。例えば下図のようなものがこれに当たります。この例は negative なもので onna が selected_text になっていますが、text を見る限り明らかに正解は miss です。これは実は text に大量の空白が含まれているためで、恐らく運営はアノテーション依頼時に複数の空白を一つに置き換えて依頼し、なぜか selected_text を抽出するときには元の空白数のまま text に対してアノテーションされた index を当てはめたと考えれば辻褄が合います。この前提に立ち、モデルの学習時には空白数分ずらした正解ラベルを使い、推論後に今度は逆方向に空白数分ずらすことで、精度良く学習した後アノテーションズレのある test データに fit させに行くことができます。

f:id:guchio3:20200619233334p:plain
アノテーションズレの例

ちなみに、我々のチームでは空白数分ずらすことはもちろんしたのですがそれだけではデータがきれいになりきらず、恐らく他にもなにかに起因して更にアノテーションズレがあった (単に元のアノテーションがおかしかった可能性もあるが) のではないかと予想しています。

上位解法

(虚無感ありますが) 全ての上位解法で最もスコアに寄与しているのはラベルノイズをどの様に加味するかという部分です。この扱い方は大別すると 2 種類あり、1 つ目が pre-post processing として人手で対応するやり方、2 つ目が char level の tokenizer を使ってモデルにラベルノイズごと扱わせるやり方です。これを踏まえた上で、上位解法の目に止まった部分を紹介していきます。(※ 図を各 solution から引用しています)

1st

いくつかの通常の token base のモデルによる予測を行った後、下図のようにこの予測結果を使って char level の stacking を行うというアプローチを取っています。

f:id:guchio3:20200620091912p:plain
1st の stacking pipeline

char NN としては CNN, Bi-LSTM, そして WaveNet を使っていて、最終的に private で 1st となったモデル構成は下図です。

f:id:guchio3:20200620092042p:plain
stacking のモデル具体構成

また、個々のモデルも色々と工夫が行われており、目に止まったものは下記です。

  • Custom loss (Jaccard-based Soft Labels)
    • 開始・終了 index を下図のようにしてなまらしたもの。2 乗の項は分布の smoothing のために入れられている (n 乗 (to inf) までこれを繰り返していくと jaccard = 1 の部分のみ 1 に近づいていくのでほんとに?という気がしているのでまだ理解できていない...)。
      f:id:guchio3:20200620092545p:plain
      Jaccard-based Soft Labels
    • 分布の例を図示したのが下図らしい。

      f:id:guchio3:20200620093110j:plain
      Jaccard-based Soft Labels 分布例

    • 確か類似アイデア自体は discussion で議論されていた (どの discussion 記事か忘れてしまった...) けど、自分達はうまくいかなかったので活用できててすごいと思った

  • MSD (多分 Multi-Sample Dropout)
  • Sequence Bucketing for faster training
  • Quest の解法であった leakless な pseudo-labeling
    • ちなみに我々は pseudo labeling は普通に試しても全く上手く行かなかったです...
  • Bertweet

2nd

このチームは pre-post process をマニュアルで設計してアノテーションズレに対応しています。

  • pre-postprocess
  • Quest の 1st の解法にある、学習可能な weight をつかって RoBERTa から hidden layer を抽出
  • Multi-Sample Dropout
  • Sentiment Sampler。sentiment 毎に精度がかなり違うため batch 間の sentiment のバランスを調整
    f:id:guchio3:20200620094359p:plain
    SentimentSampler の効果
  • SWA
  • Reranking-model training
    • 一度開始・終了 index を予測した後、予測結果の top-n から最良のものを選ぶモデルを再度作る
      1. top-n を予測し、それぞれについて selected_text との jaccard を計算
        1. の予測結果を元に 1. で計算した jaccard を回帰で求めるモデルを作成
        1. のスコア * 0.5 + 2. の jaccard を指標に、最良の組み合わせを選ぶ
    • モデルが 50 以上できるため、推論高速化のために Sequence Bucketing を使用

3rd

  • char level での予測をする Dieter のモデルと token level での予測をする Khoi のモデルが有り、空白が服う数個ある場合は Dieter のモデルを、そうでないときはアンサンブルを行うという方策。
  • Khoi のモデルは 学習は通常の開始・終了 index の予測のフレームワークで行い、推論時に開始・終了 index をそれぞれ top-3 つずつ (3 * 3 = 9 pairs) 使う。その後、start prob * end prob で最も高い確率のペアを選択。
  • Dieter のモデルは BERT 属モデルの Head に char level RNN を適用し、アノテーションズレを含めて e2e で学習。
    f:id:guchio3:20200620103630j:plain
    Dieter のモデル

4th

  • pre-postprocessing
  • model に 4 つの head を用意し、マルチタスク学習
    1. 1st と似ているが、開始・終了 index のラベルを 0.9、その両隣を 0.05 として KL-divergence loss で学習。文の先頭の場合は 0.9, 0.06, 0.04 とし、逆もまた然り。
    2. segmentation
    3. sentiment によって意味付けされた semantic segmentation
    4. sentiment を当てる (リークしてる...?よくわかっていない)
  • SWA
  • self-distillation に期待していたが、実装ミスかうまく行かなかったらしい
  • 1~3 番目の head の出力 + 4 つの head を持つモデルの出力した top-3 の候補それぞれが正解か否かを当てる external scorer の出力の系 4 つを weighted sum して予測を行う。

5th

これは我々なので詳しくは後日書きますが、概要は以下です。

  • pre-postprocessing
  • 開始・終了 index と segmentation のマルチタスクを行い、segmentation 側の loss 比率を比較的大きく (ex. 0.5 : 0.5 : 2 等) 設定
  • segmentation 用の出力を CuMax 関数で算出
  • segmentation 用の出力をゲートとして開始・終了 index 算出用の logits にかけるモデルも作成
  • batch size をかなり大きめに設定 (ex. batch accum 込で 32 * 16, SentimentSampler 的な効果...?)

11th

  • Psi はこの solo gold で GM に...!
  • merges.txt を修正することで、RobertaToeknizer が明示的に .! を分けて扱えるようにした (ref)
    • これは自分も軽く試したけどやり方わからなかったので復習しておきたい...
  • 開始 index を予測した後、これに依存させる形で終了 index を予測させる
    • これも自分は試したけどワークしなかった...
  • neutral は 1 epoch 目のみ使い、その後は positive, negative のみで学習。neutral 用の学習は別途行う。
    • 最初から neutral を除去するのは試したけどうまく行かなかった... こうすればよかったのか...

12th

  • 開始・終了 index のペアを適切に選択するため、torch.einsum を使ってペアとしてモデリングしてしまう。

13th

  • cutout data augmentation のアナロジーとして、入力の一部を [mask] に置き換える操作を行った。

まとめ

なにはともあれ金メダルをとれてよかった...うれしい...。個人的には magic 系のコンペは虚無感あるしわかるまでは辛いですが、得るものがまったくないかと言うとそうでもなくて、magic を見つけたり利用する分析力は実務でもかなり役に立つものだと思っています。それ以外の部分でもかなり学びのある solution があったので、これらを吸収して次は solo gold 取れるように頑張りたいと思います。

質問・ご指摘等あれば気軽に投げて頂ければと思いま す。あと、6/20 に tkm san youtube live に出るのでもしよかったらそちらも見て頂ければと...!

【rustdef】Rust on Jupyter Notebook で各種統計分布を生成する

はじめに

どうも、最近 Rust を勉強し始めた ぐちお@ihcgT_Ykchi です。

Rust を勉強しだしたのは、huggingface の tokenizer のように、python でコードを書く際にピンポイントで高速化できると良さそうと思ったのが一つですが、正直なところ単に春だし新しい言語勉強するか〜って気持ちになったのが大きいです。

ところで、ちょうど最近同期の @cruelturtle が rust を jupyter notebook で使える rustdef というツールを作ったようで、いい機会なので簡単に記事を書いてみようと思いました。

ちなみに私は Rust を勉強し出してまだ 1 week 程なのでかなり筋の悪い書き方をするかもしれないですが、ご容赦下さい。。

rustdef を使う準備

なんと pip install rustdef だけで ok です。ちなみに、(おそらく) Rust がインストールされている必要があって、また nightly な version が必要です。

その後 Jupyter notebook を起動し、%load_ext rustdef を実行したら準備完了です。あとは下記例の様に %%rustdef と記述したセル内で関数を記述すれば python で呼び出し可能な関数を作ってくれます。(下記例では '4' という出力が得られます。)

# =================================
# cell 1
# =================================

%load_ext rustdef

# =================================
# cell 2
# =================================

%%rustdef

#[pyfunction]
fn sum_str(a: usize, b: usize) -> String {
    (a + b).to_string()
}

# =================================
# cell 3
# =================================

sum_str(1, 3)

ちなみに、dependencies を追加したい場合は %rustdef depends CRATE というセルを実行すればよいです。

各種統計分布を生成してみる

以下、Rust を使って実際に各種統計分布を生成してきます。具体的には 一様分布二項分布指数分布正規分布 を生成していきます。

ちなみに、各種分布の生成にあたっては統計学の赤い本を参照しました。

統計学入門 (基礎統計学Ⅰ)

統計学入門 (基礎統計学Ⅰ)

  • 発売日: 1991/07/09
  • メディア: 単行本

一様分布

まず線形合同法を使って疑似的な一様乱数を生成し、これにより [0, 1] の一様分布を可視化します。

線形合同法には色々と問題があるので本来はメルセンヌ・ツイスタ使ったりした方が良いのでしょうが、まぁ良い乱数生成はこの記事のスコープ外としてとりあえず知ってるものを使いました。

# =================================
# cell 1
# =================================

%%rustdef

#[pyfunction]
fn gen_unif_rands_lgc(seed: i64, a: i64, c: i64, m: i64, sample_num: i64) -> Vec<f64>{
    let mut res: Vec<f64> = Vec::new();

    let mut x = seed;
    for _i in 0..sample_num {
        x = (a * x + c) % m;
        res.push(x as f64 / m as f64);
    }
    res
}

# =================================
# cell2
# =================================

%%timeit
res = gen_unif_rands_lgc(seed=71, a=111112, c=10, m=999999, sample_num=100_000_000)

timeit の結果は 5.88 s ± 442 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) となり、100,000,000 個の一様乱数生成に約 6s かかっている事がわかります。(多分頑張ればもっと高速化できると思います。)

ちなみに、python で同じ処理を書くと下記の様になり、22.1 s ± 400 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) とやはり rust で書いたほうが高速だったことがわかります。

# =================================
# cell 1
# =================================

def gen_unif_rands_lgc_py(seed, a, c, m, sample_num):
    res = []
    x = seed
    for i in range(sample_num):
        x = (a * x + c) % m
        res.append(x / m)
    return res

# =================================
# cell2
# =================================

%%timeit
res_py = gen_unif_rands_lgc_py(seed=71, a=111112, c=10, m=999999, sample_num=100_000_000)

また、分布は下記のようになり、期待通りの分布が得られていることが分かります。

from scipy.stats import uniform

plt.figure(figsize=(8, 7))

# pdf をプロット
x = np.arange(0, 1, 0.001)
y = uniform.pdf(x)
plt.plot(x, y, label='pdf', color='yellow', linewidth=5.0, alpha=0.5)

# 自作関数のヒストグラムを作成
unif_rands = gen_unif_rands_lgc(seed=71, a=111112, c=10, m=999999, sample_num=10_000_000)
plt.hist(unif_rands, bins=20, color='grey', alpha=0.5, normed=True, rwidth=.8)

plt.title('一様分布')
plt.legend()
plt.grid(None)
plt.show()

f:id:guchio3:20200329014456p:plain
一様分布の可視化

二項分布

次に、一様乱数を元に二項分布を生成します。0 ~ 1 の一様乱数を n 個生成し、これらの内 p より小さいものの個数を数えることで二項分布に従う確率変数を 1 つ観測する、というシミュレーションを繰り返すことでこれを実現します。

ここで、本当は上で作った gen_unif_rands_lgc 関数を使って一様乱数生成したかったのですが、実装が悪いのか乱数の質が悪いのか意図通りの二項分布が生成できなかったため、Rust の rand crate を使って乱数生成することにしました。 以降の指数分布、正規分布でも同様に rand crate の乱数生成器を使用します。

# =================================
# cell 1
# =================================

%rustdef depends rand

# =================================
# cell 2
# =================================

%%rustdef

use rand;

#[pyfunction]
fn gen_binomial_rands(p: f64, n: i64, sample_num: i64) -> Vec<i64> {
    let mut res: Vec<i64> = Vec::new();

    for _i in 0..sample_num {
        let mut unif_rands: Vec<f64> = Vec::new();
        for _i in 0..n {unif_rands.push(rand::random::<f64>());}
        let sample = unif_rands
            .into_iter()
            .map(|x: f64| -> i64 { (x < p) as i64 })
            .into_iter()
            .sum();
        res.push(sample);
    }
    res
}

# =================================
# cell3
# =================================

from scipy.stats import binom

plt.figure(figsize=(8, 7))

# pmf をプロット
x = range(11)
y = binom.pmf(x, 10, 0.2)
plt.plot(x, y, label='pmf', color='orange', linewidth=5.0, alpha=0.5)

# 自作関数のヒストグラムを作成
binomial_rands = gen_binomial_rands(p=0.2, n=10, sample_num=10_000_000)
weights = np.ones(len(binomial_rands))/float(len(binomial_rands))
plt.hist(binomial_rands, bins=20, color='grey', alpha=0.5, weights=weights, rwidth=.8)

plt.title('二項分布')
plt.legend()
plt.grid(None)
plt.show()

f:id:guchio3:20200329084832p:plain
二項分布の可視化

指数分布

続いて、指数分布を生成します。指数分布は確率が 0 でない定義域が広く一見生成が難しそうにみえますが、逆変換方という手法により生成できます。

逆変換法は、累積分布関数  F に従う確率変数  X を [0, 1] の一様乱数  U から  X = F^{-1}(U) で求める手法で、指数分布の場合  X = -log(U/\lambda) とかけます。ちなみに、 X = F^{-1}(U) とした時、 X F を累積分布関数とする確率変数となることの証明は下記のようにかけます。

\displaystyle{
    \begin{align}
        P(X \leq x) &= P(F^{-1}(U) \leq x) \\
                           &= P(U \leq F(x))\qquad  (\because P(U \leq u) = u \quad (0 \leq u \leq 1)) \\
                           &= F(x)
    \end{align}
}

コードとしては下記のようになり、可視化結果から期待通りの分布が得られていることが分かります。

# =================================
# cell 1
# =================================

%rustdef depends rand

# =================================
# cell 2
# =================================

%%rustdef

use rand;

#[pyfunction]
fn gen_exponential_rands(lambda: f64, sample_num: i64) -> Vec<f64> {
    let mut res: Vec<f64> = Vec::new();

    for _i in 0..sample_num {
        let unif_rand = rand::random::<f64>();
        let sample = -1. / lambda * (1. - unif_rand).ln();
        res.push(sample);
    }

    res
}

# =================================
# cell3
# =================================

from scipy.stats import expon

plt.figure(figsize=(8, 7))

# pdf をプロット
x = np.arange(0, 20, 0.1)
y = expon.pdf(x)
plt.plot(x, y, label='pdf', color='orange', linewidth=5.0, alpha=0.5)

# 自作関数のヒストグラムを作成
exponential_rands = gen_exponential_rands(1, sample_num=10_000_000)
plt.hist(exponential_rands, bins=20, color='grey', alpha=0.5, normed=True, rwidth=.8)

plt.title('指数分布')
plt.legend()
plt.grid(None)
plt.show()

f:id:guchio3:20200329124559p:plain
指数分布の可視化

正規分布

最後に、正規分布を生成します。ここでは、二項分布の n に大きい値を取り、中心極限定理により正規分布を作成するという方針を取ります。

ちなみに、rustdef ではセルをまたいだ関数の流用が今現在 (2020/3/29) はできないらしく、ここでは二項分布の生成関数を再度書いています。

# =================================
# cell 1
# =================================

%rustdef depends rand

# =================================
# cell 2
# =================================

%%rustdef

use rand;

#[pyfunction]
fn gen_binomial_rands(p: f64, n: i64, sample_num: i64) -> Vec<i64> {
    let mut res: Vec<i64> = Vec::new();

    for _i in 0..sample_num {
        let mut unif_rands: Vec<f64> = Vec::new();
        for _i in 0..n {unif_rands.push(rand::random::<f64>());}
        let sample = unif_rands
            .into_iter()
            .map(|x: f64| -> i64 { (x < p) as i64 })
            .into_iter()
            .sum();
        res.push(sample);
    }
    res
}

#[pyfunction]
fn gen_normal_rands(mu: f64, sigma: f64, sample_num: i64) -> Vec<f64> {
    let p: f64 = 0.5;
    let n: i64 = 10000;
    let binomial_rands = gen_binomial_rands(p, n, sample_num);
    let bi_mu = (n as f64) * p;
    let bi_sigma = ((n as f64) * p * (1. - p)).powf(0.5);
    let res = binomial_rands
        .into_iter()
        .map(|x: i64| -> f64 {((x as f64) - bi_mu) / bi_sigma})
        .into_iter()
        .map(|x| (x * sigma) + mu)
        .collect::<Vec<f64>>();
    res
}

# =================================
# cell3
# =================================

from scipy.stats import norm

plt.figure(figsize=(8, 7))

# pdf をプロット
x = np.arange(-5, 15, 0.1)
y = norm.pdf(x, 5, 3)
plt.plot(x, y, label='pdf', color='orange', linewidth=5.0, alpha=0.5)

# 自作関数のヒストグラムを作成
normal_rands = gen_normal_rands(5, 3, sample_num=1_000_000)
plt.hist(normal_rands, bins=20, color='grey', alpha=0.5, normed=True, rwidth=.8)

plt.title('正規分布')
plt.legend()
plt.grid(None)
plt.show()

f:id:guchio3:20200329125045p:plain
正規分布の可視化

おわりに

rustdef を使うと、python ベースの分析時にピンポイントで Rust を使って高速化するという Rust の使い方が捗りそうで良いなぁと思いました。セル内だと linter がないので (うまくやればできる?)、今回は一度エディタで関数を書いてからセルに移植するという方策をとりましたが、セルに書くくらいの簡単な関数であればそのうち linter なしで書ける様になると思うので問題ないかなぁと思います。

記事を書くのに思ったより時間がかかってしまった...。

Matthews Correlation Coefficient (MCC) について勉強した

本記事の概要

最近 Google AI Blog で紹介された ELECTRA の論文 を読んだのですが、論文内に出てきた Matthews Correlation Coefficient (MCC) をそういえばわかってないなぁと思ったのでまとめておきます。

調べてみると使える場面は多そうで、kaggle でも例えば VSB Power Line Fault Detection (電線コンペ) はこの指標を使っていたりします。また、この指標を理解する過程で precision/recall の非対称性など、今まで意識できていなかった学びがありました。

ちなみに、このブログがすごく参考になったのでより深く知りたい人は是非読んでみて下さい。

MCC とは

MCC は二値分類問題の評価指標です。特に、TP (True Positive), TN (True Negative), FP (False Positive), FN (False Negative) の 4 つからなる混同行列に対して式 (\ref{eq1}) の様に定義されます。

\displaystyle{
      MCC = \frac{TP \cdot TN - FP \cdot FN}{\sqrt{(TP + FP)(TP + FN)(TN + FP)(TN + FN)}}. \tag{1}\label{eq1}
  }

この式からはどのあたりが Correlation Coefficient、つまり 相関係数 なの?って印象を受けると思うのですが、実は正解ラベル  y と予測ラベル  \hat{y}相関係数を変形することで導出されるという背景があります。導出は長くなるので本記事の下の方に記載します。

また、式 (\ref{eq1}) には分母が 0 になると定義できないという問題があります。この場合、wikipedia によると  MCC = 0 とするようです。

If any of the four sums in the denominator is zero, the denominator can be arbitrarily set to one; this results in a Matthews correlation coefficient of zero, which can be shown to be the correct limiting value.

ちなみに、コーナーケースの解釈をまとめると以下のようになります。

  •  MCC = 1
    •  FP = FN = 0 となる場合、つまり完璧に正しい分類ができている場合
  •  MCC = -1
    •  TP = TN = 0 となる場合、つまり完全に間違った分類をしている場合 (逆に言うとラベル付けが間違っているだけで分離は完璧にできている)
  •  MCC = 0
    • 分類がうまく行っておらず、ある意味ワーストケース。正解と予測がほぼ独立とみなせる場合 (特殊な 相関係数 であることを意識すればわかりやすい)

MCC の立ち位置

MCC には二値分類問題に使われる他の指標 (ex. precision, recall, f-score, accuracy) に比べて0, 1 のクラスについて対称0, 1 が imbalance な場合にも使いやすい の 2 つの性質をあわせ持つという特徴があります。

ちなみに、これら 2 つをあわせ持つ評価指標には ROC-AUC もあるなぁと思ったのですが、これは 0 ~ 1 の連続値に適用されるものなのでまた別の話だと自分は解釈しています。*1

二値分類指標の対称性

まず断っておきますが、対称性 という言葉は説明のために筆者が勝手に使っているだけでよく使われるか否かはわかりません。ここでは 対称性0, 1 の立場を入れ替えたときに評価値が変わるか否か という観点で使っています。

実は、二値分類問題で最も代表的な指標の一つである precision や recall はこの対称性が無い指標です。つまり、下記混同行列*2において、 precision = 18/(18+3) \simeq 0.86 recall = 18/(18+2) = 0.9 ですが、Positive と Negative を逆に考えると  precision = 1/(2+1) \simeq 0.33 recall = 1/(3+1) = 0.25 と全く異なる値になります。前者は非常に良い値に見えますが後者は逆にかなり悪い値に見え、これは場合によっては好ましくないはずです。

一方、MCC の場合はどちらの場合についても約  0.169 となります。

f:id:guchio3:20200313152750p:plain
混同行列例

imbalance なデータへの耐性

実は、二値分類問題において代表的な指標の一つである accuracy は、対称性を満たす指標です。つまり、上記の例においては  accuracy = (TP+TN)/(TP+FP+TN+FN) = 19/24 \simeq 0.79 となり、これは Positive と Negative を逆に考えても同じとなります。

一方、accuracy には 0, 1 のサンプル数が imbalance な場合に不都合があるという問題があります。つまり、上記の例において  0.79 の accuray は一見よく見えますが、これはサンプル数の多い Positive なラベルについての予測精度が高いためであり、サンプル数の少ない Negative なラベルについての予測精度が全体の accuray に与える影響が小さくなっています。MCC では Positive ならラベルと Negative なラベルの評価を分けて扱っているので imbalance なデータにも耐性があります。

MCC の導出

前述しましたが、式 (\ref{eq1}) は正解ラベル  y と予測ラベル  \hat{y}相関係数を変形することで下記のように導出できます。(ただし  (TP + FP)(TP + FN)(TN + FP)(TN + FN) \neq 0 においてです)

\displaystyle{
    \begin{align}
        MCC &= \frac{Cov(y, \hat{y})}{\sqrt{\sigma_y \sigma_{\hat{y}}}} \\ 
                  &= \frac{\sum_i (y_i - \mu_y)(\hat{y_i} - \mu_{\hat{y}})}{\sqrt{(\sum_i (y_i - \mu_y)^2)(\sum_i (\hat{y_i} - \mu_{\hat{y}})^2)}} \\
                  &= \frac{A}{B}, \\ \\ 

        A &= \sum_i (y_i - \mu_y)(\hat{y_i} - \mu_{\hat{y}}) \\ 
           &= \sum_i (y_i \hat{y_i} - \mu_{\hat{y}} y_i - \mu_y \hat{y_i} - \mu_y \mu_{\hat{y}}) \\
           &= \sum_i y_i \hat{y_i} - N \mu_y \mu_{\hat{y}} \\
           &= TP - \frac{1}{N}(TP + FN)(TP + FP) \\ 
           &= \frac{1}{N}[TP(TP + FP + FN + TN) - (TP + FN)(TP + FP)] \\
           &= \frac{1}{N}(TP \cdot TN - FP \cdot FN), \\ \\ 

       \sum_i (y_i - \mu_y)^2 &= \sum_i (y_i^2 - 2 \mu_y y_i + \mu_y^2) \\
                                              &= \sum_i y_i - N \mu_y \qquad  (\because y_i \in {0, 1} \to y_i^2 = y_i) \\
                                              &= TP + FN - \frac{1}{N}(TP + FN)^2 \\
                                              &= \frac{1}{N}(TP + FN)(N - (TP + FN)) \\
                                              &= \frac{a}{N}(N - a) \qquad (a = (TP + FN)), \\ \\

       \sum_i (\hat{y_i} - \mu_{\hat{y}})^2 &= \frac{b}{N}(N - b) \qquad (b = (TP + FP)), \\ \\

       B &= \sqrt{\frac{a}{N}(N - a) \frac{b}{N}(N - b)} \\
          &= \sqrt{\frac{ab}{N^2}(TN + FP)(TN + FN)} \\
          &= \frac{1}{N} \sqrt{(TP + FP)(TP + FN)(TN + FP)(TN + FN)}, \\ \\

        \therefore MCC &= \frac{TP \cdot TN - FP \cdot FN}{\sqrt{(TP + FP)(TP + FN)(TN + FP)(TN + FN)}}. \\ 

    \end{align}
}

なお、上記導出には以下を使っています。

\displaystyle{
    \begin{align}
        \sum_i y_i \hat{y_i} &= TP,   \qquad  \sum_i y_i = TP + FN,   \qquad   \sum_i \hat{y_i} = TP + FP,  \\ \\

        \mu_y &= \frac{1}{N} \sum_i y_i = \frac{1}{N} (TP + FN), \\ \\

        \mu_{\hat{y}} &= \frac{1}{N} \sum_i \hat{y_{i}} = \frac{1}{N} (TP + FP), \\ \\

        N &= TP + FP + TN + FN. \\ \\

    \end{align}
}

まとめ

本記事では Matthews Correlation Coefficient, MCC について筆者の興味がある点をまとめました。二値分類問題はシンプルゆえ非常に身近でかつ奥の深い問題ですが、MCC やその背景を知っているとよりうまく付き合っていけるのではないかと思いました。

*1:自信ないので、こうじゃないかという意見があれば是非ご指摘頂きたいです。

*2:冒頭でも紹介した素晴らしい記事から引っ張ってきています。

Google QUEST Q&A Labeling の反省文

本記事の概要

kaggle の NLP コンペである Google QUEST Q&A Labeling に参加し、その社内反省会を主催したので、その時の資料をブログに落としておきます。筆者は 1,571 チーム中 19 位でした。

NLP コンペには初めて参加してのですが、系列データを NN でさばく上での学びが多く非常に楽しめました。個人的には良いコンペだったと感じていて、コンペ終了後にはブログ化する方々*1や勉強会を開催する方々がいつもより気持ち多かったような気がします。

一方で、post-process のスコアへの寄与度が大きすぎたこと等に起因する苦言も散見されてはいました。*2

コンペ概要と基礎知識

データ

データは非常にシンプルで、train.csv, test.csv, smaple_submission.csv の 3 つからなります。特に説明が必要なのは train.csv だと思うのでこれについて説明すると、6079 行 41 列のデータであり、カラムは 11 種類の説明変数と 30 種類の目的変数からなります。また、各行は説明変数である question_titlequestion_bodyanswer のペアに対応する qa_id についてユニークで、それぞれは fig 1 のような質問を行うサイトの質問のタイトル、内容とそれに対する答えに対応しています。

f:id:guchio3:20200227080139p:plain
fig 1. question_title, question_body, answer の実例

# 11 種類の説明変数
 - qa_id
 - question_title
 - question_body
 - question_user_name
 - question_user_page
 - answer
 - answer_user_name
 - answer_user_page
 - url
 - category
 - host

# 30 種類の目的変数 (公式の説明がないので意味はカラム名から類推して下さい)
 - question_asker_intent_understanding
 - question_body_critical
 - question_conversational
 - question_expect_short_answer
 - question_fact_seeking
 - question_has_commonly_accepted_answer
 - question_interestingness_others
 - question_interestingness_self
 - question_multi_intent
 - question_not_really_a_question
 - question_opinion_seeking
 - question_type_choice
 - question_type_compare
 - question_type_consequence
 - question_type_definition
 - question_type_entity
 - question_type_instructions
 - question_type_procedure
 - question_type_reason_explanation
 - question_type_spelling
 - question_well_written
 - answer_helpful
 - answer_level_of_information
 - answer_plausible
 - answer_relevance
 - answer_satisfaction
 - answer_type_instructions
 - answer_type_procedure
 - answer_type_reason_explanation
 - answer_well_written

30 種類の目的変数を見ると prefix が question なものが 21 種類、answer なものが 9 種類あります。データの説明ページによると、前者は (恐らくは主に) question_title と question_body に、後者は answer に関係する目的変数です。これらの目的変数は 0 ~ 1 の間の連続値なのですが、fig 2 の様に実はこれらの値は数種類の重なり合った値から構成されており、後に説明しますが実はこの性質がこのコンペを戦う上でのキーポイントの一つとなります。

f:id:guchio3:20200227080644p:plain
fig 2. `question_asker_intent_understanding` の例

評価指標

このコンペは評価指標としてカラム毎のスピアマンの順位相関係数の平均を採用しています。スピアマンの順位相関係数ピアソンの積率相関係数を値の順序のみから算出したものであり、同順位がない場合は各行  i の順位差  D_i を使って式 (\ref{eq1}) で表されます。(wikipedia によると、同順位がある場合は別の式になりますが、数が少なければ影響は小さいらしいです。)

\displaystyle{
      \rho = 1 - \frac{6 \sum_i D_i^{2}}{N^{3} - N} \tag{1}\label{eq1}
}

pandas.DataFrame.corr()method='spearman' の場合と rank にしてから method='pearson' にした場合が一致しますし、数式上も下の様に導出されます。

\displaystyle{
    \begin{align}
        \mu_{x} = \mu_{y} = \mu &= \frac{1}{N} \sum_i x_{i} \\
                                                    &= \frac{N+1}{2}, \\ \\ \\

        \sigma^{2}_{x} = \sigma^{2}_{y} = \sigma^{2} &= \frac{1}{N} \sum_i (x_i - \mu)^2, \\ \\ \\

        \rho &= \frac{\sum_{i}(x_{i} - \mu_{x})(y_{i} - \mu_{y})}{N \sigma_{x}\sigma_{y}} \\
                &= \frac{\sum_{i}(x_{i} - \mu)(y_{i} - \mu)}{N \sigma^{2}} \\
                &= \frac{A}{B}, \\ \\ \\

        B &= N \cdot \frac{1}{N} \sum_i (x_i - \mu)^2 \\
           &= \sum_{i}x^2 - N \cdot \mu^2 \\
           &= \frac{1}{6}N(2N+1)(N+1) - \frac{N}{4}(N+1)^2  \qquad   (\because 平方数列和) \\
           &= \frac{1}{12}(N^3 - N), \\ \\ \\

        A &= B - B + A \qquad (結果式の "1 -" の部分を意識) \\
            &= B - \sum_i (x_i - \mu)^2 + \sum_{i}(x_{i} - \mu)(y_{i} - \mu) \\
            &= B - \frac{1}{2}(\sum_i (x_i - \mu)^2 + \sum_i (y_i - \mu)^2 - 2\sum_{i}(x_{i} - \mu)(y_{i} - \mu)) \qquad (\because \sum_i (x_i - \mu)^2 = \sum_i (y_i - \mu)^2) \\
            &= B - \frac{1}{2} \sum_i (x_i - \mu - y_i - \mu)^2 \\
            &= B - \frac{1}{2} \sum_i (x_i - y_i)^2 \\
            &= B - \frac{1}{2} \sum_i D_i^2, \\ \\ \\

        \therefore \rho &= \frac{A}{B} \\
                                   &= 1 - \frac{6 \sum_i D_i^{2}}{N^{3} - N} \\
    \end{align}
}

この指標の性質として、同順位のものを同順位であるとみなすことが重要であるというものがあります。これは discussion でも議論されておりfig 2 のように目的変数に同順位のものがかなり多いことと合わせて考えるとモデルの予測値をうまく丸めることがスコアを伸ばすキーポイントであることが分かります。

一方、もう一つ重要な性質に fig 3 のようにカラム内の値が全て同じ場合に NaN になってしまうというものがあります。次のコンペ形式についての説明で書きますが、多くの参加者がこの性質に起因して苦い思いをしたはずです。

f:id:guchio3:20200227081159p:plain
fig 3. 評価指標が NaN になる例

コンペ形式

このコンペは最近流行りの Synchronous Notebook-only なコンペでした。この形式のコンペでは、submission 用の notebook を kaggle 上で作り、submit 時に private dataset 含めた学習や推論を行うという形で submission を行います。この形式のコンペの特徴として参加者が submission error に苦しむという物があるのですが、今回も例にもれず多くの参加者が submission error に苦しんでいました。恐らくほとんどの場合原因は評価指標が public test set と private test set でそれぞれ分けて計算されることで、submission 時にブラックボックスな形でしか与えられない (つまりほぼデバッグは不可能) private test set で値を丸めたあとのカラム内の値の種類数が 1 になってしまい、スピアマンの順位相関係数が NaN になってしまったのでは無いかと推測しています。*3

また、手元での学習含め external data は使用可能なコンペで、多くの参加者が学習は手元で行い、推論のみ kaggle の notebook で行うという戦略をとっていました。

BERT

近年 NLP 界隈では BERT (Bidirectional Encoder Representations from Transformers) に代表される self-attention 機構を備えた Transformer を bidirectional に組み、大規模なデータセットで (教師なし) pre-training したモデルから finetune してタスクを解く、という手法が猛威を奮っています。筆者の実感としても、最近コンペ (ex. Jigsaw Unintended Bias in Toxicity Classification) や研究領域 (ex. RoBERTaXLNet など) でもこの類の手法をよく見かけています。一応キーポイントである self-attention と bi-directional、および pre-training についてのみ簡単に記載しますが、詳しくは他の人がまとめてくれてる記事を参照してください。この記事とかこのスライドとかわかりやすいです。

ちなみに、BERT 等 Transformer ベースのモデルは huggingface という会社が非常に使いやすい実装を提供してくれおり、これを使うのが圧倒的におすすめです。

self-attention
attention は元々 RNN ベースの encoder-decoder モデルによる機械翻訳を発端として発展してきた手法であり、元々は decoder 側から encoder 側の各単語への参照、つまり複数のモジュール間での参照を行うことによって、時系列方向の情報アクセスをうまく補助してやるというニュアンスの仕組みと解釈できました。一方、近年 Transformer で使われているようにこの attention を 1 つのモジュール内で、補助としてではなくこの attention のみで時系列方向の情報アクセスを担保する目的で使用する self-attention により、様々なタスクでより高い精度を獲得できることがわかってきています。

bi-directional
bi-directional な走査は言語系列を処理する際に前から順に処理するだけでなく、後ろからも処理をすることでより多用な特徴を獲得するというテクニックで、RNN ベースの NLP 等も含め昔から利用されているものです *4。BERT 等 Transformer ベースのモデルでも bi-directional な入力系列を利用しており、具体的には fig 4 のように各 layer において各ノードが一つ前の layer の全ノードに依存するという表現で bi-directional という機構を表現しています。

f:id:guchio3:20200227081424p:plain
fig 4. BERT の bi-directional 性の表現 (ref)

pre-training
大規模なデータセットで pre-training しておき、それを finetune する形で転移学習するという手法はもはや当たり前の如く様々なケースで利用されています *5NLP の分野でもこの手法は利用されており、pre-training を行う手法として最も一般的なものの一つが言語モデルです。言語モデルの詳細は web 上に記事が転がっているのでそちらを参照して頂きたいですが *6、タスクとしては前 (もしくは後ろ) から順に単語系列を走査していき、走査の度にその次の単語が何になるかを当てるという操作を行います。

上記のようなタスクを行うにあたり、複数層の bi-directional な RNN や Transformer で単純にこれを行うとリークを起こすという問題があります。ELMofig 5 のように、bi-directional な処理が結合するのを最終層のみにすることによってうまくリークが起きないようにしたモデルですが、bi-directional な処理の恩恵を受けられるのが最終層のみという弱みがあります。これを克服するため、BERT では Masked Language Model (MLM) と呼ばれる、文章中のランダムにマスクされた単語を当てるというタスクを提案し、これを解いています。このタスクであれば前方向から処理を行っても後ろ方向から処理を行ってもマスクされている単語はわからないので、複数層の bi-directional な RNN や Transformer を利用することができます。また、この他にも Next Sentence Prediciton という連結された 2 つの文が自然に連結されたものか否かを当てるというタスクの提案もあり、これも bi-drectional な処理に関してリークフリーです。*7

f:id:guchio3:20200227085645p:plain
fig 5. ELMo の例 (ref)

solution 紹介

基本的には筆者のメモ用に solution をまとめます。記載の基準は自分の独断と偏見なので、詳しくは kaggle discussion を参照して頂ければと思います。ここに上位 solution へのリンクがまとまっているので、ここ起点で探すと楽です。

solution 紹介からは説明量が膨大になるので、背景知識の説明は割愛します。また、重複する説明は基本的に二重記載しません

まず、筆者含め数名の solution は直接話を伺ったので少し詳しめに書いています。

guchio3

まず筆者の solution を載せます。処理のパイプラインはだいたい fig 6 に載せたように pre-process, modeling, post-process の三段構成になっており、他の solution も大枠は似たようなものになっています。

f:id:guchio3:20200227083538p:plain
fig 6. solution pipeline

pre-process

  • title, question の分離用に special token [NEW_SEP] を追加して入力系列を形成
    • ex. [CLS] title [NEW_SEP] question [SEP] answer [SEP]
  • Category を text として入力系列に埋め込み
    • ex. [CLS] CAT-TECHNOLOGY title [NEW_SEP] question [SEP] answer [SEP]
    • category を embedding layer に入れて後ほど head の入力に concat するやり方もありますが、text に埋め込むと bert 等が系列を処理する段階で category を加味してこれを行えます。
  • title, question, answer それぞれについて、一定の長さ以上になった場合に先頭、末尾から 1 : 1 の長さで切り取り
    • ex. I have a dream that one day on the red hills of Georgia, the sons of former slaves and the sons of former slave owners will be able to sit down together at the table of brotherhood.
-> I have a dream that + at the table of brotherhood.
  • 上記処理の後、🤗 の tokenizer (encode_plus) を使用
    • sentence を word に分割
    • 一部の word を sub-word へと分割 (ex. "playing" = "play" + "##ing")
    • word -> token_id へとマッピング
    • position_ids, token_type_ids に加えて attention_mask 等の生成
    • etc...

modeling

  • question_body を key とした 5-fold GroupKFold
  • BCE
  • Bert, Roberta (valid score best 2 の snapshots), XLNet の加重平均
    • 1 : 0.5 : 0.5 : 1
  • BERT 族最終層を AVG pooling したベクトルを HEAD の入力に使用
  • question 系と answer 系のラベル用にでそれぞれモデルを作成
    • Q : question_title + question_body + 系列長 512 に満たなかったら answer
    • A : question_title + answer + 系列長 512 に満たなかったら question_body
  • Adam w/ cosine scheduler (3e-5 -> 1e-5)
  • 1 epoch 目は BERT 族部分は freeze してその他の部分を学習 (結構効いた)
  • pseudo labeling (soft + soft and hard + hard)
    • soft は生の予測値
    • hard は予測値に thresholding を行い丸めたもの
    • soft and hard は oof のスコアが良くなるカラムのみ thresholding したもの

post-process

  • oof に対しネルダーミード法をかけ得た閾値によって値を丸める
    • percentile ベース (oof の正解ラベルの下から何%~何%が同じ値かという情報) を使って閾値を初期化
    • oof でスコアが伸びるカラムのみに thresholding を適用
    • submission error を防ぐため、public/private test set それぞれで値の種類数が 1 になる場合は thresholding を適用しない
  • question-type-spelling のみルールベースで値を入れ直す
    • 具体的には host = "english.stackexchange.com" なら 0.5, それ以外は 0.0
    • この notebook を参考に oof で検証して良さそうだったので採用

what did not work

  • batch size を変える (8 がベストだった)
  • sequence size を変える (default の 512 がベスト)
    • pre-train の重みを使うには系列長は 512 にしないと思われがちだが、この制約は positional encoding を表現する matrix のみによるものなので、ここをうまく扱えばどうにでもなる
      • positional encoding matrix を拡張 (拡張部分は初期化後の重みを利用) して 512 以上の系列長を利用
      • question_title, question_body, answer それぞれの先頭で position_id を 0 に初期化することで 512 より長い系列長を利用
      • 逆に 512 より小さい系列長を利用
  • category 以外の meta_features の利用
  • BERT 族の output の扱い方を変える
    • [CLS] token に対応する hidden state を output とする
    • 最終 N 層の系列出力を concat して avg pooling したものを output とする
  • loss function を変える
    • MSE
    • pair loss
    • focal loss
  • data augmentation
    • nlpaug
    • マイナーなラベルの over sampling
  • スペル修正等よくある基本的な前処理
  • 難しいカラム用に expert model を作成

sakami

pre-process

  • 4 種類の truncate (文章の切り取り方) により多様性を捻出
    • pre-truncate (title + question + answer として、先頭から系列長 512 になるように切り取り)
    • post-truncate
    • 文頭、文末を 1 : 1 で使う truncate
    • question より answer を多めに使う truncate
  • target を min-max scaling
    • target の最小値、最大値が 0, 1 でない場合があったので正規化し、また分布をきれいにする

modeling

  • 12 models のアンサンブル (solutionに図が載っているので、詳しくはそちらを参照)
    • LSTM + Universal Sentence Encoder
    • BERT base uncased * 2
    • BERT base cased
    • BERT large uncased * 2
    • BERT large cased * 2
    • ALBERT base
    • RoBERTa base
    • GPT2 base
    • XLNet base
  • head に 1 層の linear でなく、2 層の linear (2 層間に activation 無し) を使用
    • 原理的には同値なはずだが結構効いたらしい... (なんで...)
  • activation に gelu でなく new-gelu を使うと良かったらしい
  • cosine warmup scheduler
    • 最初は lr が小さい状態からある程度急峻に上げ、ピークからだんだん小さくする
  • EMA
  • マイナーなラベルに大きめの weight を適用して学習すると良かった
    zero_inflated = (train_y > 0).mean(axis=0) < 0.1
    positive_weighted = np.tile(zero_inflated, (len(train_y), 1))
    positive_weighted *= (train_y > 0)
    one_inflated = (train_y < 1).mean(axis=0) < 0.1
    negative_weighted = np.tile(one_inflated, (len(train_y), 1))
    negative_weighted *= (train_y < 1)
    train_weights = np.where(positive_weighted + negative_weighted, 2., 1.)

post-process

  • 黄金分割探索*8を使い、値を clipping (rounding)
  • optuna を使って weight を決め、モデルの予測値を加重平均

what did not work

kenmatsu4

key points

  • fig 7 に示すような 4 つの bert-base-uncased のアンサンブル
  • oof で良くなるもののみ post-process
    • rank avg した後に、train の percentile ベースで train の値を入れ込む
  • [CLS] に対応する hidden state と系列出力の avg pooling を concat して head の入力に使用
  • 10-fold MultilabelStratifiedKFold

f:id:guchio3:20200227092549p:plain
fig 7. モデルのバリエーション (ref)

what did not work

  • Stackoverflow の 150,000 sentences を使った pre-training
  • Multi-sample dropout (jigsaw コンペの上位解法)
  • BERT 以外のモデル
    • roberta, albert, xlnet, USE+MLP, LSTM w/ gensim
  • meta_features を embedding して head の入力に concat
  • question と answer でモデルを分け、head への入力時にそれぞれの出力を concat して使用
  • BERT の系列出力を LSTM にかけてベクトルに圧縮
  • BERT 内の BertLayer の内、前半半数を freeze して学習できる表現力を制限
  • custom loss
    • BCE + MSE, focal loss
  • word count features
  • マイナーなラベルの over sampling

次に、上記以外で筆者が覚えておきたいと思った solution を羅列していきます。

1 st

key points

  • pytorch kernel base で始めたらしい
  • multi-sample dropout (つまり彼らはうまくいった...?)
  • encoder (BERT 等の部分) と head で lr をそれぞれ別に設定
    • deserves a paper らしい
  • 全 BertLayer の [CLS] に対応する hidden state を weighted avg
    • weight を正 ^ 計 1 になるよう成約付けし、trainable な parameter に...!
  • stackexchange のデータを使った Masked Language Modeling w/ 6 targets
    • question_score, question_view_count, question_favorite_count,answer_score, answers_count, is_answer_accepted
  • pseudo labeling
    • fold ensemble を pseudo labeling 用の予測値とするのではなく、各 fold 用の pseudo label は各 fold の train model のみ使用
      • fold ensemble すると、各 fold についてその oof を学習に使った model の予測結果を使うことになり、pseudo label される対象が train set と似たデータだった場合に間接的に train を見ているのと同じになる
      • cv 0.414 -> 0.445 とかなり伸びるのに対し lb が伸びないこともあり、リークに気付いた
    • pseudo labeling 用に post-process はしなかった
  • BARTの使用
    • Transformer base のモデルを seq2seq で denoising autoencoder として学習
    • BERT との相関が高くなく、また単体性能も結構良いらしい
  • post-process
    • train の分布に基づいて行ったらしいけどコード読めてないです。。
  • Mag という自作ツールが実験管理に良かったらしい

what did not work

  • stackexchange の meta_features
  • GPT, GPT2 medium, GPT2 large
  • back translation
  • stacking

2 nd

  • target のをカラム数を増やす形で変形して学習
    • 例えば target が  t \in [0, \frac{1}{3}, \frac{2}{3}, 1] の場合、 p(0), p(\frac{1}{3}), p(\frac{2}{3}) が予測対象となり、 p(t) はこのカラムの値  v t を上回っているという意味。こう設計すると  t は以下の式 (\ref{eq2}) で復元可能
    • 単純に OHE の形で softmax 関数等使って学習すると大小関係が表現し辛いので良くないらしい
\displaystyle{
  \begin{align}
      t(0) &= 1, \\
      t\left(\frac{1}{3}\right) &= p(0) - p\left(\frac{1}{3}\right), \\
      t\left(\frac{2}{3}\right) &= p\left(\frac{1}{3}\right) - p\left(\frac{2}{3}\right), \\
      t(1) &= p\left(\frac{2}{3}\right) - 0, \\ \\

      t &= 0 \cdot t(0) + \frac{1}{3} \cdot t\left(\frac{1}{3}\right) + \frac{2}{3} \cdot t\left(\frac{2}{3}\right) + 1 \cdot t(1) \tag{2}\label{eq2}
  \end{align}
}
  • 5-fold GKF に以下 2 点の工夫を追加
    • question : answer が 1 : N (N > 1) のものについて、validation の度に 100 sample とってきて median を計算 (これの解釈が正しいかちょっと自信ないです)
    • question_type_spelling カラムを除外
      • このカラムは偏りがひどく、予測値が安定しない
  • fig 8 のような Dual Transformer と Siamese Transformer の二形式のモデルをアンサンブル
    • Siamese Transformer の weighted average 用の重みは trainable
    • 最終的に作ったモデルは 5 種類
      • 2x dual roberta-base
      • dual roberta-large (2x 256 tokens)
      • dual xnet-base
      • siamese roberta-large with weighted averaged layers

f:id:guchio3:20200227092832p:plain
fig 8. Dual Transformer と Siamese Transformer (ref)

4 th

  • link1, link2, link3

  • Original -> Spanish -> English の inverse translation による data augmentation

  • fig 9 の構造のネットワークに BERT、XLNet を使ったもののアンサンブル
    • output は concat([1, 2]) + 3 + 4 で計算
  • 下記コードで post-process
    • bin を 1~200 個で切ったときに oof で一番良いものを使用
    • 個人的には bin の幅ではなく bin の数の方を可変にしているのが珍しくて良いなぁと思った
    def metric3(ytrue,ypred):

        import copy
        y = copy.deepcopy(y_pred) #make_copy
        list_of_max_voters=[] #  list_of_max_voters[i] = how many voters did label the data (instead of 90 for all the columns)
        for i in (range(y_pred.shape[1])):
            best_score= 0 #initilize score for the the column i
            best_max_voters=1 #
            history_score=[]
            for max_voters in range(1,200):
                y[:,i]= (y_pred[:,i]//(1/max_voters))*(1/max_voters)
                score = spearmanr(y_true[:, i], y[:, i]).correlation
                history_score.append(score)
                if score > best_score:
                    best_score = score
                    best_max_voters= max_voters
            list_of_max_voters.append(best_max_voters)

            y[:,i]= (y_pred[:,i]//(1/best_max_voters))*(1/best_max_voters)
        return np.mean([spearmanr(y_true[:, ind], y[:, ind]).correlation for ind in range(y.shape[1])]),list_of_max_voters

f:id:guchio3:20200227093010p:plain
fig 9. 4th のモデル構造 (ref)

8 th

  • BERT 等各 model の最終層に 1 layer (ex. BertLayer) の random initialize な層を追加
  • roberta-base に関して def cln(x): return "".join(x.split()) で 0.005 程伸びたらしい
  • external data を使った pseudo labeling
    • 論文に着想を得ているらしい

9 th

  • modeling において、BERT 等の最後の 4 layers を freeze し、その output を concat した後 1-layer LSTM に入力して sequencial output を作成
  • blending において、各モデルで target 毎に得意不得意が異なることに着目し、target 毎に weight を変えて blend

10 th

  • link1, link2
  • BCE を weight と共に使用
    • weight をどうやって決めたかが謎
  • post-process に k-means を利用
    • oof と test をあわせて適用し、適用先のカラムは一部のみ

15 th

  • fast.ai を使っていい感じに lr をチューニングしたらしい

16 th

  • target を rank 化して 0 - 1 に scaling (+0.01 - 0.05)
  • ランダムな token を "[PAD]" に変えて augmentation

18 th

  • データセットの作成に関する仮説に基づいた solution で、おもしろいので是非一度仮説を見てみて下さい
  • modeling の際に、question_title, question_body, answer の入力に対応する箇所の出力それぞれについて 3 種類の pooling を行い head に入力 (fig 10 参照、ちょっと解釈があっているか自信ないです)

f:id:guchio3:20200227093128j:plain
fig 10. 18th のモデル構造 (ref)

23 th

  • post-process を LGBM の max_depth = 1, lr = 0.1 の stacking で表現
    • ノード内で各カラムについて bin を切る機能を利用
  • margin ranking loss + BCE (1 : 1) で 0.002 伸びたらしい (筆者は効かなかった...)

まとめ

本記事では kaggle の Google QUEST Q&A Labeling コンペの概要と筆者が気になった各種 solution を個人的なメモとして紹介しました。前回まとめた細胞コンペも非常に学びが多かったですが、このコンペでも多くの学びを得ることができ、またゲームとしても非常に楽しむことができたと思っています。

各種 BERT の派生形モデルや Universal Sentense Encoder 等、ちゃんとわからず使っていたものもあったので、このあたりは復習して、もし気が向いたら記事にしたいと思います。あと、jigsaw コンペは復習したほうが良いなぁと思いました。

次こそ金圏...

*1:ex. yuko ishizaki san, takamichi toda san, Y.Nakama san, jshirius san

*2:これとかこれとか

*3:submission error については結構 discussion も議論されていましたが、コンペ中に明確な対処法が共有されることはなかったので、submission error に苦しんで終わる参加者もいた事は想像に難くないです。

*4:ex. Schuster and Paliwal, 1997

*5:ex. 以前紹介した細胞コンペ

*6:これとかこれとか

*7:このスライドで視覚的にわかりやすく説明してくれてます。

*8:わかりやすい説明