得られた知見をフリーズドライ〜情報共有のための仕組み Report.md の紹介〜

こんにちは、会員事業部の新井(@SpicyCoffee66)です。今年はレシピサービスにおける体験改善を主な業務としていました。
サービス開発かラブライブ!の話をすると早口になります*1。今日はついにスマブラが発売されるのでおそらく早退します。

さて、本記事ではサービス開発において重要な要素である施策結果・知見のプールや共有について、社内でどのような取り組みが行われているのかを紹介したいと思います。

施策の結果から最大限に学びを得たい

私たちはサービス開発を進める中で日々多くの施策を実施することになります。
サービス開発のプロセスにおいて、施策は実施して終わりではなく、その結果からいかに多くの学びを得るのかということが重要になります。
施策の結果から学びを得るためには、その施策の意図や結果を可能な限り 正しく 解釈し、それを(将来入ってくるメンバーを含めて)より多くの人に 共有 することが必要です。
しかし、サービス開発の現場では以下のようなことが往々にして発生します。

  • 間違った知見が共有される
    • 結果の数字の読み取り方がおかしい
    • そもそもKPIの設定がおかしい
    • 検証の期間が短すぎて正確性に欠ける
    • 施策の目的と手法が噛み合っていなかった
    • といったようなことに気がつかず正しい知見として共有される
  • 知見がまるで共有されない
    • どこにプールされているか判然としない
    • 知見が属人的になる
    • ならまだいい方で時が経って本人もはっきり覚えていない感じになる
    • 最悪プールすらされてなくて闇に消える
    • 結果新規メンバーが似たような失敗を繰り返す

僕自身、入社して間もない時期には過去に実施された施策の詳細を探すも見つからず、歯がゆい思いをしたことが数多くありました。
クックパッドのように歴史の長いサービスであれば、自分が思いつくアイディアについて過去に誰かが似たような施策に取り組んでいたことも多く、その時の結果を参照できないのは非常にもったいないと感じました。

このような問題を解消するため、社内では昨年度途中から Report.md という仕組みが利用され始めました。

Report.md

Report.md を一言で言うと「施策の結果を Pull Request で管理する仕組み」となります。
具体的には

  1. 担当者は施策の終了後、Markdown形式でその内容をまとめた report を作成し、PRを送信する
  2. チームメンバーがその report をレビューする
  3. いい感じになったらマージし、report をプールする

という手順を踏んで施策の結果をレビューし、ストックしていきます。

f:id:spicycoffee:20181206192507p:plain
PR を出して
f:id:spicycoffee:20181206192525p:plain
レビューして
f:id:spicycoffee:20181206192536p:plain
マージする

こうすることで

  • 施策の結果を正しく解釈する → レビューを通すことによって精度が上がる
  • 多くのメンバーに共有する → report のプール箇所が明確になり参照しやすくなる

といった効果が上がり、前述した二つの目的を果たすことが可能になりました。
コードをレビューして(質を高めて)、マージする(永続化する)という普段エンジニアがやっているフローが、施策の結果に対しても有効だったわけです。

いつ書くか?

上記手順では、Report.md のイメージを持っていただくために施策終了後としましたが、施策の実施前に Report.md を作成することで、仮説や検証内容が明確になり、施策がブレにくくなるという効果があります*2
導入初期は施策後に作成して「結果の共有」のためにのみ利用するのもよいと思いますが、慣れてきたらぜひ施策の実施前に作成するフローで運用することをオススメします。
その場合の手順は

  1. 担当者は施策の実施前に、Markdown形式でその内容をまとめた report を作成し、PRを送信する
  2. チームメンバーがその report をレビューする
  3. いい感じになったらマージし、 report をプールする
  4. マージされた report をもとにして施策を実施する
  5. 施策終了後、その結果を report に追記する
  6. チームメンバーが report をレビューする
  7. いい感じになったらマージし、完成版の report としてプールする

といった形になります。

f:id:spicycoffee:20181207115725p:plain

何を書くか?

Report.md に何を書くかについては、運用しているチームによって様々です。
ここでは、私が見た事例の中で一番よくまとまっていると感じたテンプレートをもとにして内容を紹介していきます。
また、以下にあげる内容のうち、「仮説」「仮説分析」「検証方法」「結果の想定」については、施策の実施前に記載することでその指針とすることができる内容になります。

3 行まとめ

作成された report を読むのは自分だけではないことを考えると、まずは概要を読んでから詳細を読むべきか判断できる構造になっていた方が親切です。

仮説

report の中でも非常に重要な項目です。
仮説をしっかり定義できていないと検証設計がぶれ、結果を次に活かすことが難しくなります。
仮説をどのように表現するかはチームによりますが、社内独自のフレームワークである価値仮説シート*3に沿って表現することが多いです。
形式に迷う場合は
(ターゲット) は (ジョブ/インサイト) したいが (何らか要因でそれができていない状況) なので、 (体験) すると (目的) になる
といったフォーマットで表現してみるのがよいと思います。

仮説分析

そもそも上記の仮説はどこからきたのか、その妥当性はあるのかといったような仮説の価値自体やその詳細を示す項目です。
具体的には

  • ターゲットボリューム
    • 「仮説」で定義したターゲットの条件に当てはまる人がどのくらいいるのかを試算する
  • 事前検証・前回施策の結果
    • この施策に繋がる情報を持った施策 report へのリファレンスを貼っておくとわかりやすい
  • 落としてはいけないコア機能
    • 「仮説」の内容をより明確にするためにコアとなる要素を明記する
  • 諦めること
    • 検証のブレを無くすためにやらないことを明記する
  • 技術的に難しいこと
    • 期間やリソース都合を含め、実装側の都合で諦めたことを明記する

「仮説」を所与のものとしても report は成立しますし、あまり情報量が多くなっても report の骨子が読みづらくなるため、施策によって項目は柔軟に変更するのがよいと思います。
なるべく外部資料や別 report への参照を貼るよう工夫したり、<details> タグを利用して情報を畳んでおくのもおすすめです。

検証方法

「仮説」の項目で書いた「体験」を実現するための方法を示し、その内容を具体的に解説します。

  • 検証項目
    • 施策によって答えを出したい問い
    • 仮説そのものに対する問いになることもあれば、仮説をもとに考えた機能の有用性に対する問いになることもあります
  • 検証方法・提供機能
    • 仮説に対してどのような機能を提供してそれを検証するか
    • あるいはどのような資料をもとにユーザーにヒアリングを行うか
  • 検証資料・機能詳細
    • それらの機能や資料の詳細
  • 検証期間・人数
  • 評価方法

といったような内容を盛り込むとよいでしょう。

結果の想定

「検証方法」で定義した「評価方法」に対し、どのような結果が出たときに「仮説」をどう評価すればよいかについて可能な限り明記しておく。

  • 定量検証の場合
    • CVR が x % 以上向上した場合は仮説が正しかったと考える
  • 定性検証の場合
    • ユーザーインタビューで y というような反応が z 人以上見られた場合は仮説が正しかったと考える

根拠を持って詳細な数値を設定するのは難しいことが多いですが、過去の施策やチーム内での議論をもとに目安を設定してメンバーの目線を揃えておくことが重要です。

検証結果

検証の結果をまとめます。
主観的な考えや分析は後の「考察」に記載するとし、ここではそれに必要な結果のみを記載します。

考察・ギャップ分析

「結果の想定」と「検証結果」を比較し、自分たちの仮説や認識について合っていたことと間違っていたことを明らかにしていきます。
長文で書き綴るよりも検証項目ごとに箇条書きで簡潔にまとめる方が振り返りやすくてよいかと思います。

Next Action

「考察・ギャップ分析」の内容を受けて、この施策を次にどうするか、具体的なアクションを記載します。

Report.md 運用の所感

メリット

Report.md を社内で運用していくうちに、以下のようなメリットがあることが感じられるようになりました。

  • 施策結果の解釈の精度が上がる
  • 施策そのものがブレづらくなる
  • 施策の結果を気軽に共有できるようになる
    • Slack で「こういう感じのことやったことある人いないですか?」「お、それなら前にやりましたよ(report のリンクを貼る)」といったやり取りが数多く見られるようになりました
  • サービス開発者の成果が可視化されやすくなった*4
    • 期末の評価期間に自己評価を執筆する際に上長に成果を提示しやすくなりました

デメリット

個人的には非常に有用な仕組みだと感じてはいますが、デメリットも存在しているとは思います。
最も大きいのは「施策に関して目先の実行速度が遅くなる」ことです。
Report.md をしっかり作成しようとすると、それ相応のコストが掛かります。
その分施策に対する理解が深まったり、後々参照できる資産になったりという大きなメリットがあるとは思いますが、サービスのフェーズやチームの雰囲気によっては、メリットがコストに見合わないかもしれません。
そういった場合には、記載する項目を取捨選択したり施策と同時並行で作成するなどの工夫によって、report 作成のコストを抑えるのがいいでしょう。

今後の課題

今後の課題として、report を通した情報共有をより活発にしたいと思っています。
Report.md 自体はあくまでも「施策の結果がチームごとに一箇所に集まる」ものでしかなく、組織横断的に情報を提供してくれる仕組みではありません。
したがって、より容易に情報が社内に行き渡るような仕組みと組み合わせることでその効果をさらに高めることができるのではないかと考え、その方法を模索しています*5

まとめ

本記事では、サービス開発において実は失敗しがちな知見のプール・共有について社内でどのような取り組みがなされているかを紹介しました。
クックパッドでは、技術力を大切にしているのはもちろんのこと、サービス開発そのものについてもその手法を洗練させていくことで、よりすばやくユーザーに価値を届けようと日々努力しています。
そして、そういった想いのもと一緒にサービスを作り上げていってもらえるメンバーを募集中です!
このような姿勢や働き方に興味を持っていただけたなら、ぜひ一度採用サイトをチェックしてみてください。
興味はあるけどいきなり採用の話は……という方は、気軽に @SpciyCoffee66 まで連絡してください。
クックパッド名物の(?)キッチンラウンジで美味しいご飯を食べながら社内の様子についてお話しましょう!

*1:早口になるほど好きなのでサービス開発者のコミュニティである s-dev talks を運営しています。

*2:施策の実施前にどのようなことを考えればいいかについては別の記事を投稿させていただいているので、興味のある方はご一読ください。

*3:具体的なフォーマットは TechConf 2018 での講演や、この夏に開催されたインターンシップの資料をご参照ください。

*4:Report.md の前身となった取り組みに関する記事でも少し触れられています。

*5:最近になってまずは専用のドメインを切った社内ブログに集約してみるという動きが始まりました。

BERT with SentencePiece で日本語専用の pre-trained モデルを学習し、それを基にタスクを解く

研究開発部の菊田(@yohei_kikuta)です。機械学習を活用した新規サービスの研究開発(主として画像分析系)に取り組んでいます。 最近は、社内の業務サポートを目的として、レシピを機械学習モデルで分類して Redshift に書き込む日次バッチを開発・デプロイしたりしてました。

ここ数ヶ月で読んだ論文で面白かったものを3つ挙げろと言われたら以下を挙げます。

本記事では、BERT というモデルをクックパッドのレシピから得られる日本語テキストで pre-train して、それを fine-tune して特定のタスクを解いた、という話を紹介します。 高いテンションで書いていたらなかなかの感動巨編になってしまいましたが、ぜひご一読ください。

まとめ

  • BERT の multilingual モデルは日本語の扱いには適さないので SentencePiece を使った tokenization に置き換えて学習
  • pre-training にはクックパッドの調理手順のテキスト(約1600万文)を使用
  • 学習は p3.2xlarge インスタンスで 3.5 日程度学習を回し、loss は以下の図のように推移(バッチサイズ32)
  • 学習済みの pre-trained モデルを基に、手順のテキストが料理の手順であるか否かを予測する問題を解き、以下の表のように良い結果
  • (ある程度ドメインを限定すれば)現実的なコストで有用な pre-trained モデルが作れる

pre-training における loss の推移は以下の図の通りです。

Masked Language Model の loss の推移

classification の結果の表は以下の通りです。
baseline は TF-IDF を特徴量として学習した Logistic Regression と、word2vec による embedding を用いた LSTM です。 multilingual は提供されている学習済みの多言語 BERT を基に fine-tune したモデルです。 our model はクックパッドのデータで学習した日本語用の BERT を基に fine-tune した我々のモデルです。 学習データサイズを変えながら、5477 件の検証データに対する正答率を記しています。 比較は色々と手を抜いていますが、自分たちで学習した BERT が有用であることがひと目で理解できます。 特に、fine-tuning なので少量のデータで良い結果を出せるところが強力で、解きたいタスクがあれば数百件から千件程度 annotation すればよいというのは応用上かなり有用です。

学習データサイズ baseline (LR) baseline (LSTM) multilingual our model
1000 87.4% 90.4% 88.6% 93.6%
5000 88.9% 92.2% 91.6% 94.1%
13000 89.5% 92.5% 92.6% 94.1%

以降の節では、この結果に辿り着くまでの要素を詳しく解説していきます。 BERT に関する基本的な説明が結構長いので、BERT を理解している人は SentencePiece による tokenization の置き換え の節まで飛ばしてください。

BERT とはどのようなモデルか

まず、BERT とはどのようなモデルかを簡単に説明します。 本記事で注目するモチベーションは、汎用的な pre-trained モデルを提供し、それに基づいて fine-tune したモデルで各種タスクを解いて良い結果を得るというものです。

モデルそのものは Transformer です。 本来は機械翻訳のモデルとして提案されていますが、BERT においては Encoder 部分のみが重要なので、そこのポイントだけを記して詳細は省きます(やや不適切ですが呼称としては Transformer をそのまま用います)。

  • sequential な構造を使わずに入力 token 列を一度に処理する(GPU とも相性が良い)
  • self-attention によって特徴量を学習する
  • 位置情報が落ちる分は position embedding という token 列の位置に応じて与えられる embedding を token embedding に足すことでカバーする

モデルの肝となる self-attention と positon encoding ですが、ウェブ上でも解説が色々落ちてるので説明は割愛します。 実装まで含めて理解したい方は tensor2tensor の実装 よりも harvardnlp が提供している実装 がオススメです。 notebook で提供されていてところどころに可視化も入っているので、具体的に何をしているかを理解するには良いと思います。 ただし、ここで表示されている position encoding の図は dim をいくつか fix して横軸を position にしてますが、embedding の気持ちを考えるなら position をいくつか fix して横軸を dim にした方が良いと思います(ここでやってるみたいに)。

Transformer の話はこれくらいにしておいて、これに基づいて pre-trained モデルを構築した OpenAI GPT の話に移ります。 この pre-training には教師なしで学習できる言語モデル、一言で言うと位置 t-1 までの token が与えられた状況で位置 t の token が正しく予測するように最尤法で学習、を用いています。 イメージ図は以下で、E_i が各 token の embedding、T_i が特徴量になっていて、特徴量はさらに一層 dense を重ねてその後 softmax につないで token を予測するようになっています。

OpenAI GPT の概念図(図は BERT の論文より引用)

注意すべきは各 layer による connection は前の位置から後ろの位置の方向にしか伸びていない点です。 上述の言語モデルの構造を考えれば、ある位置での token を予測するときにはそれ以前の位置の情報しか使えないので、後ろから前への connection は mask して落とすようにしています。

このモデルは良い結果を残しましたが、明白な改善点として、対象となる token の位置より後ろの位置にある情報も使いたいということが考えられます。 例として 今日は新しく買った〇〇〇でコーヒーを淹れます◯◯◯ を予測をすることを考えてみましょう。 これは前の情報だけでは無理がありますが、後ろの情報があればコーヒー豆やコーヒードリッパーなどの可能性が高いことが予想できます。 この点を考慮してモデリングをしたものが ELMo です。 left-to-right と right-to-left を別々に LSTM で学習して得られる特徴量をタスク特有のネットワークの単語 embedding に concat するという使い方をします。

ここで「別々に」というのがポイントで、上述の言語モデルは明らかに一方向でなければ学習が意味を成さないため、別々に学習する必要があります。 しかし、理想を言えば同じモデルの中で双方向の情報を同時に扱いたいです。 同じコンテキストにおいて、前の情報と後ろの情報をその関係性を考慮した上で合わせて使うことができるからです。

これを実現するための言語モデルとして Masked Language Model (MLM) を提案したというのが BERT の貢献です。 話としてはとてもシンプルで、入力 token 列の 15% を mask して、言語モデルは mask されたものを正しく予測するように学習するというものです。 本当はもう少し細かいことをしていますが、難しい話ではなく学習のためのテクニック的な要素でもあるのでここでは割愛します。 具体的な構造はここまでの話が分かっていれば簡単で、下図のように、後ろから前への connection を mask せずに使った Transformer が BERT になります。

BERT の概念図(図は BERT の論文より引用)

これまでは入力部分を何となく token の embedding として扱ってきましたが、BERT の入力の形は少し特殊なのでそれを詳しく見てみます。

BERT の入力の概念図(図は BERT の論文より引用)

まず、Input から。 最初の token は [CLS] という token ですが、これは MLM においては重要ではないので後で詳しく見ることにして一旦スキップします。 入力の文章は一般に二つ入り得て、間と最後に区切りを明示するためのの [SEP] という特別な token が入ります。 ここで文章と呼んでいるものは一つ一つが一般に複数の文から構成されるもので、例えば質問応答では一つの文章が passage(これは一般に複数の文から成る)でもう一つの文章が question になります。 また、recurrent 構造を持たないので入力 token 列の長さは fix する必要があり、512 token となっています。 入力が長すぎる場合は二つの文章が同じ長さになるように token を後ろから削っていって、余る場合は 0 padding します。

次に Token Embeddings ですが、これは入力 token 毎の embedding です。 embedding は lookup テーブルで実装されるので、入力 token は辞書に基づいてマッピングされた id になります。 pre-training によってこの embedding が学習されることになります。

次に Segment Embeddings ですが、これは token が文章1から来ているのか2から来ているかを区別するために必要なものです。 self-attention は前の layer の出力特徴量を同時に扱うので、位置情報などは知りえません。 位置情報は Position Embeddings で付与していますが、どの token が文章1でどの token が文章2かはまだ区別できていません。 そこで文章1と2を区別する embedding を追加しています。 これは次の Position Embeddings と同様に、文章の内容には依らずに純粋に1つ目の文章か2つ目の文章かだけで定まる embedding です。

Positon Embeddings は元の Transformer と同じ用途ですが、BERT では pre-training で学習するものとして提供しています。 具体的には この辺のコード を参照してください。

ここまでを理解すれば MLM がどうやって実現されるかがかなり具体的にイメージできるようになると思います。 Input の token をランダムに [MASK] という token に置き換え、この [MASK] token に対応する出力特徴量を dense と softmax につないで置き換え前の token の id を当てるように学習します。 これによって文章中のどの位置にどのような token が現れるかを学習できます。

BERT ではさらに文章1と2が意味的に連続している文章かどうかを分類する IsNext prediction によって、文章間の関係性も考慮できるようにします。 これもシンプルで、ある document から連続している2つの文章を抜き出した場合は IsNext で、文章2としてランダムに持ってきた文章の場合は NotNext として、分類問題を解きます。 この分類問題を解く際に、先程登場した文頭の [CLS] token の出力特徴量を dense と softmax につないで解くことになります。

MLM と IsNext prediction によって pre-traned モデルが得られれば、あとはそれを基に fine-tuning によって様々なタスクを解くことができます。 例えば、single sentence classification では、入力を一つの文章として、[CLS] token の出力特徴量に層を追加して分類問題を解くことになります。 その他にも質問応答など色々なタスクを解くことができますが、本記事で扱う fine-tuning タスクはこの single sentence classification のみです。 BERT 論文ではこの fine-tuning で GLUE の各種タスクを解いて軒並み優秀な結果を叩き出しています。

モデルの具体的なパラメタや学習の非効率性の議論を除けば、本質的なポイントはこのくらいです。 これで 3.3 billion word corpus のデータで 256 TPU chip days(ちなみに GCP の cloud TPU では一つのデバイスで 4 chips なのでそれだと 64 日)で学習したものが公開されている pre-trained となっています。

Google Research から 公式実装 が公開されています。 このレポジトリから学習済みモデルもダウンロードできます。 英語、中国語、multilingual のモデルが公開されています。 英語に関してはモデルパラメタ数が三倍程度の Large モデルも提供されていますが、これは試してないので、本記事で BERT と言えば Base の方を想定しています。 以降ではこのレポジトリの内容に基づいて、特に tokenization の手法と日本語で扱うために変更した点に注目して解説します。

BERT の tokenization はどうなっているか

具体的にどのような単位を token とするのか、という話をしていませんでしたが、ここまで述べてきた token は公式実装における sub-token に対応しています。 実装との比較をするため、この節に限り、token と sub-token という言葉を公式実装における変数名に合わせることにします。 繰り返しになりますが、この節以外で使っている token はこの節で言うところの sub-token に対応していることに注意してください。

sub-token とは sub-word のことですが、sub-word と言ってもどのように区切るかは様々な方法があります。 ということで BERT における tokenization を詳しく見てみましょう。

まず、実際に処理をしている部分のコードは tokenization.py で定義されている FullTokenizer クラスの tokenize メソッドです。

class FullTokenizer(object):
  """Runs end-to-end tokenziation."""

  def __init__(self, vocab_file, do_lower_case=True):
    self.vocab = load_vocab(vocab_file)
    self.inv_vocab = {v: k for k, v in self.vocab.items()}
    self.basic_tokenizer = BasicTokenizer(do_lower_case=do_lower_case)
    self.wordpiece_tokenizer = WordpieceTokenizer(vocab=self.vocab)

  def tokenize(self, text):
    split_tokens = []
    for token in self.basic_tokenizer.tokenize(text):
      for sub_token in self.wordpiece_tokenizer.tokenize(token):
        split_tokens.append(sub_token)

    return split_tokens
...

BasicTokenizer で token に分けてから各 token を WordpieceTokenizer で sub-token に分けていることが分かります。

BasicTokenizer の tokenize は以下のように実装されています。

  def tokenize(self, text):
    """Tokenizes a piece of text."""
    text = convert_to_unicode(text)
    text = self._clean_text(text)

    # This was added on November 1st, 2018 for the multilingual and Chinese
    # models. This is also applied to the English models now, but it doesn't
    # matter since the English models were not trained on any Chinese data
    # and generally don't have any Chinese data in them (there are Chinese
    # characters in the vocabulary because Wikipedia does have some Chinese
    # words in the English Wikipedia.).
    text = self._tokenize_chinese_chars(text)

    orig_tokens = whitespace_tokenize(text)
    split_tokens = []
    for token in orig_tokens:
      if self.do_lower_case:
        token = token.lower()
        token = self._run_strip_accents(token)
      split_tokens.extend(self._run_split_on_punc(token))

    output_tokens = whitespace_tokenize(" ".join(split_tokens))
    return output_tokens

各々の処理の実装の詳細までは立ち入りませんが、ざっくりと以下のような処理をしています。

  • 入力テキストは utf-8 で取り扱う
  • unicode category の control のものは除くなどの処理
  • ord() で各ユニコード文字を int に変換し、範囲指定で漢字を見つけて前後にスペースを入れる
  • スペース区切りで tokenize して list を返す

このようにして tokenize された token に対して、更に WordpieceTokenizer で sub-token に tokenize します。 抜粋して実際に処理をしている箇所を載せてみます。

    output_tokens = []
    for token in whitespace_tokenize(text):
      chars = list(token)
      if len(chars) > self.max_input_chars_per_word:
        output_tokens.append(self.unk_token)
        continue

      is_bad = False
      start = 0
      sub_tokens = []
      while start < len(chars):
        end = len(chars)
        cur_substr = None
        while start < end:
          substr = "".join(chars[start:end])
          if start > 0:
            substr = "##" + substr
          if substr in self.vocab:
            cur_substr = substr
            break
          end -= 1
        if cur_substr is None:
          is_bad = True
          break
        sub_tokens.append(cur_substr)
        start = end

      if is_bad:
        output_tokens.append(self.unk_token)
      else:
        output_tokens.extend(sub_tokens)

何をやっているか理解できるでしょうか? ここで最初の方で渡している text は BasicTokenizer で作られた token になります。 例外処理的な部分を除けば、本質的には以下のような処理をしています。

  • まずは与えられた token をそのまま扱う
  • token が辞書に存在すればそれを sub-token として登録
  • token が辞書になければ最後の一文字を削って sub-token を作って辞書マッチングをする、を繰り返す
  • sub-token がマッチしたら、残りの文字に ##ing のように ## をつけて同じ処理を繰り返していく

ここの処理は機械的な処理であるため、sub-token の粒度を司るのは辞書となります。 公式レポジトリからダウンロードできる BERT-Base, Uncased の中の vocab.txt を眺めてみると、organizational のような長めの単語が入っていたり、##able のような典型的な接尾辞が入っていたりします。 また、英語以外にも様々な文字が入っていることも確認できます。 30000 程度辞書に登録されているのでかなりの数になっていて、##a なども登録されているので、英語を処理する場合に [UNK] に遭遇することはほぼないでしょう。

例えば The Higgs boson is an elementary particle in the Standard Model of particle physics. という文章を tokenize すると以下の結果が得られます。

['the', 'hi', '##ggs', 'bo', '##son', 'is', 'an', 'elementary', 'particle', 'in', 'the', 'standard', 'model', 'of', 'particle', 'physics', '.']

BERT の multilingual モデルによる日本語の取り扱い

BERT では multilingual のモデルも公開されているので、これを使えば日本語もバッチリ!と期待したいところですが残念ながらそうはいきません。 具体的に tokenization の方法を見てきたので何が起きそうかはある程度想像できると思います。

例えば、鶏肉は包丁を入れて均等に開き、両面にフォークで穴を開け塩コショウする。 を tokenize すれば結果はどうなるでしょうか? 正解は以下のようになります。

['鶏', '肉', 'は', '包', '丁', 'を', '入', 'れて', '均', '等', 'に', '開', 'き', '、', '両', '面', 'に', '##フ', '##ォ', '##ーク', '##で', '穴', 'を', '開', 'け', '塩', 'コ', '##シ', '##ョ', '##ウ', '##する', '。']

ほとんど文字ベースみたいなものですね...
漢字もしくはスペースによってある程度の単位に区切られること前提としているので、日本語は相性が悪いです。 これは漢字がところどころに含まれているのでまだマシですが、例えば にフォークで の部分はこれがまとめて WordpieceTokenizer で処理されるのでこのようになってしまいます。 さらに vocab.txt を見てもまともな日本語の単語が登録されていないことが分かります。 使う気になれば文字ベースのモデルとして使えますが、##あ を区別しているなどなかなか厳しいものになっています。

SentencePiece による tokenization の置き換え

日本語にマッチした BERT モデルを作るには、tokenization を日本語にマッチしたものに変える必要があります。 英語版と同じように sub-word 単位で取り扱いをすることを考え、今回は SentencePiece を使用することにしました。

SentencePiece は教師なしで学習可能で、そして学習が速いです。ほんと速い。 SentencePiece が提供する言語モデルベースの分割や detokenization などの詳細はここでは触れないので、本家のレポジトリや 論文 をご覧ください。

ここでは tokenizer の置き換え部分を軽く紹介します。 まず、BERT の元のコードの FullTokenizer クラスを次のように変更します。

class FullTokenizer(object):
  """Runs end-to-end tokenziation."""

  def __init__(self, model_file, vocab_file, do_lower_case=True):
    self.tokenizer = SentencePieceTokenizer(model_file)
    self.vocab = load_vocab(vocab_file)
    self.inv_vocab = {v: k for k, v in self.vocab.items()}

  def tokenize(self, text):
    split_tokens = self.tokenizer.tokenize(text)
    return split_tokens
...

これは単純に tokenize メソッドを SentencePieceTokenizer での処理に変更しているだけですね。 SentencePieceTokenizer クラスは次のように定義します。

class SentencePieceTokenizer(object):
    """Runs SentencePiece tokenization (from raw text to tokens list)"""

    def __init__(self, model_file = None, do_lower_case=True):
        """Constructs a SentencePieceTokenizer."""
        self.tokenizer = spm.SentencePieceProcessor()
        if self.tokenizer.Load(model_file):
            print("Loaded a trained SentencePiece model.")
        else:
            print("You have to set the path to a trained SentencePiece model.")
            sys.exit(1)
        self.do_lower_case = do_lower_case

    def tokenize(self, text):
        """Tokenizes a piece of text."""
        text = convert_to_unicode(text)
        output_tokens = self.tokenizer.EncodeAsPieces(text)
        return output_tokens

これも難しいことは何もなくて SentencePiece の SentencePieceProcessor クラスの EncodeAsPieces メソッドを使っているのみです。 学習済みモデルさえ別途準備できれば、この程度の変更で置き換えが可能です。

ここまでで必要な道具が出揃ったので、あとはデータを準備して実際に学習をしていきます。

使用するデータ

クックパッドには 300 万品を超すレシピが投稿されています。 レシピには調理手順毎に説明の文章が付与されているので、今回はこの手順のテキストを学習データにします。 手順のテキストは十分なデータ量があること、手順のテキストには材料や調理器具や調理法などレシピの分析に必要な要素が含まれていること、などが理由です。

一つの手順のテキストを一行に格納したデータを作成した結果、約 1600 万行のテキストデータとなりました。

まず、SentencePiece の学習データですが、これは扱いが簡単で、学習時に作成したテキストデータの path を与えるだけです。 この規模のデータでも MacBook Pro で一時間程度で学習が終わるので、かなり使いやすいです。 今回は unigram モデルを使い、vocab_size は 32000 としました。 vocab_size がかなり大きめですが、これは後の BERT の学習を考慮し、BERT の英語モデルにおける辞書の要素数と同じくらいにするという意図で設定しました*1。 学習したモデルで先ほどと同じ例を tokenize してみると、以下の結果が得られます。 vocab_size が大きいので token も大きめになっていますが、クックパッドっぽい token に分かれてますね〜。

['▁鶏肉は', '包丁を入れて', '均等に', '開き', '、', '両面に', 'フォークで穴を開け', '塩コショウする', '。']

次いで、BERT の pre-training の学習データです。 create_pretraining_data.py によって、テキストデータに [MASK] や IsNext の情報などが付与された後に .tf_record 形式に変換されます。 テキストデータは IsNext 情報の作成のために、一行一文でかつ document 間には空白行を挿入することが推奨されています。 先ほど使ったテキストデータを、一つのレシピが一つの document になるようにして間に空白行を挿入し、一行には一手順のテキストを入れるように作りました。 後はこのスクリプトを学習した SentencePiece の tokenization に基づいて実施すれば必要な学習データが作成できます。 ただし、このスクリプトは全データをメモリに読み込む仕様になっているので、大量のデータではメモリ不足になってしまいます。 後の pre-training では複数のファイルを学習データとできるので、ここでは 1/10 ずつ .tf_record を作りました(一つのファイルが約 2 [GB])。 どれくらいデータを複製するかなどの各種パラメタは、公式レポジトリのものと揃えています。

ここで vocab.txt は少し注意が必要です。 SentencePiece が出力する .vocab ファイルには存在しない [PAD], [CLS], [SEP], [MASK] などを編集して追加し、それに合わせて vocabulary を扱う関数を少し変更する必要があります。 こうすると SentencePiece のモデル内部が有する token:id のマッピングとズレが生じるので少しイケてないですが、モデルは純粋に tokenize をするためだけに使われるので問題は生じません*2

最後に、fine-tuning タスク用のデータも準備しておきます。 今回は手順のテキストが料理に関するものか否かを二値分類するタスクを扱います。 手順のテキストには「つくれぽありがとうございます」というような、料理に関するものでないテキストが含まれる場合があります。 例えば音声による手順の読み上げを考えた場合、このような料理に関するものでないテキストまで読み上げられるのは望ましくないため、自動で分類することは価値があります。 過去に annotation された 18477 件のデータがあったため、13000 件を学習データ、残りの 5477 件を検証データとして使用します。

BERT の pre-training

学習は AWS EC2 p3.2xlarge に nvidia-docker 環境を構築して実施しています。 nvidia/cuda:9.0-cudnn7-devel-ubuntu16.04tensorflow-gpu==1.12.0 を使っています。 この環境では計算機パワー不足では?と思われるかもしれませんが、料理というドメインに限定したデータを対象としており、予備実験をして様子を見てもいけそうだったので、これで学習を回しました。

モデルのパラメタは BERT の英語モデルのものと基本的に同じで、vocab_size を合わせて、learning_rate=5e-5train_batch_size=32max_seq_length=128 で回しました。 以降の結果はステップ数が 1,000,000 回における結果です。 学習経過の確認として、loss の振る舞いを見る以外にも、適当なタイミングで学習を止めて fine-tuning タスクを解いてその性能が向上していることなども確認していました。

loss の推移は下図の通りです。

Masked Language Model の loss の推移

pre-train したモデルの性能は以下のようになりました。

INFO:tensorflow:***** Eval results *****
INFO:tensorflow:  global_step = 1000000
INFO:tensorflow:  loss = 2.1979418
INFO:tensorflow:  masked_lm_accuracy = 0.5468771
INFO:tensorflow:  masked_lm_loss = 2.1357276
INFO:tensorflow:  next_sentence_accuracy = 0.9775
INFO:tensorflow:  next_sentence_loss = 0.061067227

公式実装の pre-trained モデルと比べると masked_lm_accuracy の値が低くなっています(これは多クラス分類なので 54.7 % は全然当てられてないわけではないです)。 手順のテキストには記号も結構な割合で含まれていて、これは当てるのが困難というのも影響していると思います。 とはいえ masked_lm_loss がまだまだ下がりそうなのと token 列が長い場合の positon encoding の学習もしてないので、引き続き学習を継続して様子を見ていきたいと思います*3

次節の二値分類タスクは、この pre-trained モデルを基に fine-tune した結果となります。

二値分類タスクによるモデルの比較

準備した fine-tuning 用のデータを用いて、各種モデルを比較してみました。 使用したモデルは以下のものです。

  • baseline (LR): TF-IDF を入力とする Logistic Regression を fine-tuning 用の学習データのみで学習したもの
  • baseline (LSTM): word2vec の embedding を入力とする LSTM を fine-tuning 用の学習データのみで学習したもの
  • multilingual: 公式レポジトリで提供されている multilingual モデルを fine-tune したもの
  • our model: 今回作った pre-trained モデルを fine-tune したもの

fine-tuning の有用性を確認する上でも、学習データサイズを変えながら試した結果が以下の表です。

学習データサイズ baseline (LR) baseline (LSTM) multilingual our model
1000 87.4% 90.4% 88.6% 93.6%
5000 88.9% 92.2% 91.6% 94.1%
13000 89.5% 92.5% 92.6% 94.1%

有用性を確認するためだけの比較実験なので、色々と雑にやっていますが、自分たちで学習したモデルが良い結果を返していることが分かります。 特に、学習データサイズが小さくてもかなり良い結果を返していることが分かります。 それほど難しくないタスクなので baseline も悪くない結果を出していますが、学習データサイズが小さい方が差が大きくなっています。 これはドメインを限定したクックパッド用の pre-traiend モデルを使っているので期待通りの振る舞いですが、この事実は社内における機械学習を用いたサービス開発に有用です。 試したいタスクがあれば千件程度の annotation されたデータがあれば十分であることを示唆しているので、社内の annotator の方々に依頼すれば数日で結果を出すところまで確認できます。 ちなみに fine-tuning に要する時間は数分とかその程度です。 pre-trained モデルと SentencePiece のモデルが準備できた現状ならば、色々なタスクにすぐに試すことができるので、trial and error を高速に回せるので嬉しいですね!

CPU では学習は厳しいですが、予測に関しては数千件であればバッチ処理で使うのも可能な程度の処理速度なので、その用途なら他の機械学習モデルと同様の運用コストで使っていけます。 処理件数が多かったりスピードが要求される場合はやはり GPU(もしくは TPU)が必要になります。

今後は、引き続き pre-training を回して性能をチェックしつつ、BERT の特性を活かして様々なタスクに適用していきたいと考えています。

本節における baseline の結果を出すのには @studio_graph3 にも手伝ってもらいました。スペシャルサンクス。

再びまとめ

だいぶ長くなりましたが、BERT with SentencePiece で日本語専用のモデルを作って、そこから fine-tune してタスクを解く話をしました。 ドメインが限定されれば single GPU でも十分に有用なモデルが構築できるのではないかと思います。 pre-trained モデルが得られれば、少量の annotation さえあれば高い性能を発揮するので、サービスに導入するために様々な trial and error を実施しやすいというのは大きな利点です。

また、本記事では紹介しませんでしたが、Google Colaboratory を使えば無料で TPU を使って BERT を試すこともできます(結果ファイル出力に GCS が必要ですが)。

いかがでしたでしょうか。 このように弊社では機械学習の新しい発展を活用してサービスの研究開発がしたい、という方を募集しています。 興味がある方は @yohei_kikuta までご連絡ください、クックパッドで美味しいご飯でも食べながら議論しましょう。 もしくは 応募フォーム から直接ご応募ください。

*1:サイズを変えて 8000 でも試しましたが、tokenization としてはこれくらいでも十分な印象です。

*2:Twitter で SentencePiece の学習時に --control_symbols オプションで ID が予約できることを教えてもらいました。まさに今回やりたいことそのものです。この issue も参考のこと。@tsuchm さん、@taku910 さん、ありがとうございました。

*3:ちなみにこの後も学習を回していて、1,800,000 ステップでは loss が 1.971 まで下がっています。

【開催レポ】Cookpad TechBar #9 〜秋の最高LT大会〜 & ライブ配信の裏側

こんにちは。新卒採用担当の小久保です。

2018年11月21日に、Cookpad TechBar #9 〜秋の最高LT大会〜を開催しました。

Cookpad TechBarとは

Cookpad TechBarは学生向けのイベントで、クックパッド社員とカジュアルな雰囲気で気軽に交流していただけるイベントです。 f:id:bonami:20181130142831j:plain

今回のテーマ

今回は、「最高のLT」をテーマに、3名の若手社員と1名の内定者がLTをしてくれました。

具体的な発表内容としては、クックパッドの業務にほとんど関係のないLTを実施したのですが、堅苦しい説明会とは違う、TechBarならではの雰囲気を楽しんでいただき、大好評でした。懇親会では、参加者から多くの質問をしていただき、有意義な時間を過ごすことができました。

f:id:bonami:20181130143510j:plain

最高だと感じてもらう工夫

ただ楽しいLTイベントではなく、最高なLTイベントだと感じてもらうために実施した2つの取り組みについてご紹介します。

最高ボタン

f:id:bonami:20181130143023j:plain:w300

この「最高ボタン」はスマートフォンなどからアクセスすることができ、押すと「最高!」の音声が流れます。参加者が発表を「最高!」だと感じたとき、ボタンを押し、音声を鳴り響かせることで、LTの会場がさらに盛り上がる、という仕掛けです。ボタンが押された回数をリアルタイムでスクリーンに表示するようにしたところ、結果的に20,000以上の最高が集まりました。これは、スタッフ担当の内定者が作ってくれました。

f:id:bonami:20181130142824j:plain

イベントのライブ配信

※ここは配信担当の id:koba789 が書いています。

実は、今回のイベントは地方に住む学生にも楽しんでいただけるようライブ配信も実施しました。クックパッドのオフィスで行うイベントとしては初めての試みだったのですが、当日の来場者数と同じくらいの方々に視聴していただくことができました。

当初ライブ配信の予定はなかったのですが、地方にも興味を持ってくれる学生が多いということもあり、かねてから仕事でライブ配信をやってみたかった私が提案して実験的にやってみた、という経緯があります。 こうして「やりたいです」と手を挙げると、好きなこと・得意なことが仕事にできるというのは弊社のいいところだと思います。

配信にあたっては、参加者が学生だということもあって、現地の参加者の姿を映したくない、という強い要件がありました。 しかし、会場の大きなスクリーンと登壇者の姿をひとつのカメラで撮影することは、会場のレイアウト的にもカメラの画角的にとても困難でした。 そこで、スライドの映像と登壇者の姿を別で取り込み、PIP*1 で合成することにしました。

映像の配信用 PC への取り込みや PIP などの配信技術については、私(id:koba789)が趣味で温めていた技術を使いました。

配信用のソフトウェアとして OBS Studio を利用し、PIP やシーン切り替えなどをしました。

OBS Studio に映像ソースとして、登壇者の姿はビデオカメラの HDMI 出力を、スライドは PC の HDMI 出力をそれぞれ取り込む必要がありましたが、これにはよくある USB 接続の HDMI キャプチャデバイスではなく、LKV373 というハードウェアと自作の OBS プラグインを用いました。 実はこの OBS プラグインは Rust で書かれていたりしますが、これらの詳細については私の個人ブログで紹介していますので、そちらをご覧ください。

diary.hatenablog.jp

そして、これらの機材を組み合わせた配線図は以下のとおりです。

f:id:koba789:20181203162654p:plain
配線図

会場に備え付けの HDMI スイッチャーによってスライドの映像の分岐ができたため、構成が簡単になっています。

そして、実際の会場のレイアウトはこのようになっていました。

f:id:koba789:20181203162749p:plain
会場レイアウト
会場端に立ち入り禁止エリアを設けることで、参加者が映り込んでしまわないよう配慮しています。 図には描いていませんが、不意にカメラに映り込むことを避けるために椅子を並べてバリケードを作っていました。

そして、登壇者の姿を撮影していたカメラはこのようになっていました。 f:id:bonami:20181130142839j:plain

カメラの HDMI 出力は、三脚に固定されている LKV373(写真では見切れている) にすぐさま接続されており、配信機材のある演台の横までは LAN ケーブルで映像を伝送しています。 長さが 15m もある HDMI ケーブルは調達するのが大変であったり、ノイズ対策のためにシールドが固すぎて取り回しが大変であったりしますが、LAN ケーブルであれば安価で調達しやすく、しなやかで取り回しも容易です。

最後に、私が張り付いていた機材デスクを紹介します。

f:id:bonami:20181130142741j:plain
※これは同一構成でライブ配信をした別日の写真です

写真に写っている中で、配信用に追加した機材は、MacBook Pro・Rust のステッカーが貼ってある USB オーディオインターフェース*2・ヘッドフォン・USB NIC x2・LKV373 だけです。 その他の白い箱などは会場(オフィス)に備え付けのものです。

写真中央、短い LAN ケーブルが刺さっている黒い箱が LKV373 です。これは演台から来た HDMI を受けています。 こちらはカメラと違い、LAN ケーブルを長距離取り回す必要がないため、極めて短いケーブルで "SLIDE" と書かれた USB NIC と接続されています。 この USB NIC の裏にもう一つ、"ACTOR" と書かれた USB NIC がありますが、これはカメラの映像を受けているものです。

Rust のステッカーが貼ってある USB オーディオインターフェースは、マイクの音を取り込むために利用しました。 下に置いてあるマイクアンプのライン出力から音声を取り込んでいます。

以上、TechBar #9 のライブ配信の裏側をご紹介しました。 自作の OBS プラグインを使うなどのチャレンジもありながら、大きな事故もなく配信を終えることができ、配信担当者としてはホッとした気持ちです。

おわりに

クックパッドでは、2020年新卒採用を開始しております。職種は、ソフトウェアエンジニア、リサーチエンジニア、デザイナーの3職種です。

info.cookpad.com

クックパッドは、ユーザーの課題発見とその解決に真摯に向き合い続けることで、日々の生活を支えるサービスに成長してきました。また、その裏側を高度な技術が支えています。レシピサービスはもちろんのこと、新規サービス、グローバル展開など「毎日の料理を楽しみにする」ために多くの挑戦を行っていきます。興味を持っていただけた方は、ぜひご応募ください。お待ちしております。

・ソフトウェアエンジニア
・リサーチエンジニア
・デザイナー

*1:ピクチャ・イン・ピクチャ。ひとつの大きな映像の隅に、別の小さな映像を重ねること

*2:私が気まぐれで貼り付けただけで、Rust は特に関係ないです