レシピのタイトルから材料を予測する🚀

研究開発部のサウラブ(bira)です。

本稿ではユーザがレシピの作成にかける労力を減らすために取り入れた、機械学習を利用した機能の一つについて 解説します。この機能を利用すると、ユーザがレシピのタイトルを入力することで、利用されるであろう材料が予測できます。

要約

  • レシピのタイトルから材料を予測できるモデルを作りました。
  • 投稿開発部と協力してレシピエディタに材料提案機能を追加しました。

App Storeで入手可能な最新のCookpadアプリ(v19.6.0.0)でこの機能を使用できます。

モデルはどうなっているか

1. Embed

f:id:bira:20190220104854p:plain

  • 学習(Training): Word EmbeddingとSentence Embeddingを学習してS3にアップロードします。(次のセクションで説明
  • 前処理(Preprocessing): 特殊文字を削除します。 多くのCookpadユーザーはテキストに特殊文字を使用しています。 例:"✧おいしい♡タンドリーチキン♡^-^✧"に特殊文字が含まれています: , ,^-^。特殊文字には材料に関する情報が含まれていないので、それらを削除します。特殊文字を削除するには、次のpython Functionを作成しました:

コードを表示する

  import re
  def remove_special_characters(text):
      non_CJK_patterns = re.compile("[^"
                                    u"\U00003040-\U0000309F"  # Hiragana
                                    u"\U000030A0-\U000030FF"  # Katakana
                                    u"\U0000FF65-\U0000FF9F"  # Half width Katakana
                                    u"\U0000FF10-\U0000FF19"  # Full width digits
                                    u"\U0000FF21-\U0000FF3A"  # Full width Upper case  English Alphabets
                                    u"\U0000FF41-\U0000FF5A"  # Full width Lower case English Alphabets
                                    u"\U00000030-\U00000039"  # Half width digits
                                    u"\U00000041-\U0000005A"  # Half width  Upper case English Alphabets
                                    u"\U00000061-\U0000007A"  # Half width Lower case English Alphabets
                                    u"\U00003190-\U0000319F"  # Kanbun
                                    u"\U00004E00-\U00009FFF"  # CJK unified ideographs. kanjis
                                    "]+",  flags=re.UNICODE)
      return non_CJK_patterns.sub(r"", text)

  • トークン化する(Tokenize): MeCabを使ってテキストをトークン化します。
  • Embedding: Word EmbeddingとSentence Embedding モデルを使用して、Cookpadデータベース内の各レシピのタイトルをベクトルに変換します。
  • 索引付け(Indexing): Faissを使用してベクトルにインデックスを付け(method = IndexFlatIP=Exact Search for Inner Product)、インデックスをS3にアップロードします。Faiss(Facebook AI Similarity Search)は、ベクトルの効率的な類似検索のためにFacebook AIによって開発されたライブラリです。 Faissは10億スケールのベクトルセットで最近傍検索をサポートします。

    2. Search&Suggest (API Server)

    f:id:bira:20190220104850p:plain

  • S3からWord EmbeddingモデルとSentence EmbeddingモデルとFaiss Indexをダウンロードします。
  • Word EmbeddingモデルとSentence EmbeddingモデルとFaiss Indexをメモリにロードします。
  • Embeddingモデルを使用して、入力されたタイトルをベクトルに変換します。
  • Faissを使用してk個の類似するレシピを検索します。
  • 類似するレシピの中で最も一般的な材料を提案します。

Embeddingsを学習する:

レシピのタイトルデータでWord Embeddingモデル(Fasttext)を学習します。

gensimでFasttextを使っていました。gensimはとても使いやすいです。

コードを表示する

from gensim.models import FastText
# recipe_titles : [.....,牛乳で簡単!本格まろやか坦々麺,...]
# tokenize recipe titles using MeCab and then train fasttext model
# recipe_title_list(tokenized) : [...,['牛乳','で','簡単','!','','本格','まろやか','坦々','麺'],....]
ft_model = FastText(size=100,min_count=5,window=5,iter=100, sg=1)
ft_model.build_vocab(recipe_title_list)
ft_model.train(recipe_title_list, total_examples=ft_model.corpus_count, epochs=ft_model.iter)

なぜFasttextを選んだのですか?

Fasttext(これは本質的にword2vecモデルの拡張です)は、各単語を文字n-gramで構成されているものとして考えます。 そのため、単語ベクトルは、これらの文字数n-gramの合計で構成されます。例:”中華丼”の単語ベクトルはn-gram”<中”、”中”、”<中華”、”華”、”中華”、”中華丼>”、”華丼>”のベクトルの合計です。Fasttextはサブワード情報で単語ベクトルを充実させます。それゆえ: - 稀な単語に対してもより良いWord Embeddingsを生成します。たとえ言葉が稀であっても、それらの文字n-gramはまだ他の単語中に出現しています。そのため、その Embedding は使用可能です。例:”中華風”は”中華丼”や”中華サラダ”のような一般的な単語と文字n-gramを共有することは稀であるため、Fasttextを使用して適切な単語のEmbeddingを学習できます。 - 語彙外の単語 - 学習用コーパスに単語が出現していなくても、文字のn-gram数から単語ベクトルを作成できます。

Sentence Embeddingモデルを学習します。

二つの Sentence Embedding モデルを試してみました:

  • Average of Word Embeddings:文は本質的に単語で構成されているので、単に単語ベクトルの合計または平均を取れば文のベクトルになると言えるかもしれません。 このアプローチは、Bag-of-words表現に似ています。これは単語の順序と文の意味を完全に無視します(この問題で順序は重要でしょうか?🤔)。

コードを表示する

  import MeCab
  VECTOR_DIMENSION=200
  mecab_tokenizer_pos = MeCab.Tagger("-Ochasen")
  def sentence_embedding_avg(title, model=ft_model):
      relavant_words = [ws.split('\t') for ws in mecab_tokenizer_pos.parse(title).split('\n')[:-2]]
      relavant_words = [w[0] for w in relavant_words if w[3].split('-')[0] in ['名詞', '動詞', '形容詞']]
      sentence_embedding = np.zeros(VECTOR_DIMENSION)
      cnt = 0
      for word in relavant_words:
          if word in model.wv
              word_embedding = model.wv[word]
              sentence_embedding += word_embedding
              cnt += 1
      if cnt > 0:
          sentence_embedding /= cnt
      return sentence_embedding

  • トークン化する(Tokenize): MeCabを使用して文を形態素解析します。
  • フィルタ(filter) :名詞、形容詞、動詞だけを残して、他の単語を除外します。
  • 平均(Average): フィルタ処理した単語のWord Embeddingを取得し、それらを平均してタイトルベクトルを取得します。

  • Bi-LSTM Sentence Embeddings: Cookpadのレシピデータを使って教師あり学習によってSentence Embeddingを学習します。ラベルは2つのレシピ間のJaccard Similarityから導き出します。レシピを材料のセットと見なすと、2つのレシピ間のJaccard Similarityは次のように計算されます。 f:id:bira:20190220115601p:plain

    アイデアは、それらの間の高いJaccard Similarityを持つレシピのレシピタイトルベクトルをSentence Embeddingスペース内で互いに近くに配置することです。

    • データセットを作成します: 2つのレシピのタイトルと、これら2つのレシピの類似度を表すJaccardインデックスを含む各サンプル行を持つデータセットを作成します。{title_1, title_2, Jaccard_index}
    • 下のネットワークを学習します: f:id:bira:20190220104951p:plain 上記のネットワークは2つの設定で学習することができます:
      • Regression: g(-) : sigmoid と y = Jaccard Index
      • Classification: g(-): dense+dense(softmax) と y = Jaccardインデックスから派生したクラスラベル 5クラスの分類設定で上記のネットワークを学習することによって学習されたF( - )は、最もよく機能するようです。ネットワークにとって、回帰問題よりも分類問題の方が解きやすい場合があります。

      Kerasでネットワークを実装する:

コードを表示する

    from keras import backend as K
    from keras import optimizers
    from keras.models import Model
    from keras.layers import Embedding, LSTM, Input, Reshape, Lambda, Dense
    from keras.layers import Bidirectional
    import numpy as np
    def cosine_distance(vects):
        x, y = vects
        x = K.l2_normalize(x, axis=-1)
        y = K.l2_normalize(y, axis=-1)
        return K.sum(x * y, axis=-1, keepdims=True)

    title_1 = Input(shape=(MAX_SEQUENCE_LENGTH,))
    title_2 = Input(shape=(MAX_SEQUENCE_LENGTH,))
    word_vec_sequence_1 = embedding_layer(title_1)  # Word embedding layer(fasttext)
    word_vec_sequence_2 = embedding_layer(title_2)  # Word embedding layer(fasttext)
    F = Bidirectional(LSTM(100))
    sentence_embedding_1 = F(word_vec_sequence_1)
    sentence_embedding_2 = F(word_vec_sequence_2)

    similarity = Lambda(cosine_distance)([sentence_embedding_1, sentence_embedding_2])
    similarity = Dense(5)(similarity)
    y_dash = Dense(5, activation='softmax')(similarity)
    model = Model(inputs=[title_1, title_2],  output=y_dash)

    model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
    model.fit([train_title_1, train_title_2], y)  # [train_title_1, train_title_2], y are respectively input titles and class label
    np.save('bilstm_weights.npy', F.get_weights())

  • 前のステップで学習したF(-)を文のEmbeddingとして使用します:

コードを表示する

    from keras.models import Model
    from keras.layers import Embedding, LSTM, Input, Reshape, Lambda, Dense
    from keras.layers import Bidirectional
    import numpy as np

    title = Input(shape=(MAX_SEQUENCE_LENGTH,))
    word_embedding = embedding_layer(title)
    F = Bidirectional(LSTM(100))
    sentence_embeddding = F(word_embedding)
    sentence_embedding_model = Model(input=title, output=sentence_embedding)

    sentence_embedding_model.layers[2].trainable = False
    sentence_embedding_model.layers[2].set_weights(np.load('bilstm_weights.npy'))
    def sentence_embedding_bilstm_5c(text):
        txt_to_seq = keras_tokenizer.texts_to_sequences([mecab_tokenizer.parse(text)])
        padded_sequence =  sequence.pad_sequences(txt_to_seq,maxlen=MAX_SEQUENCE_LENGTH)
        return K.get_value(sentence_embedding_model(K.cast(padded_sequence,float32)))[0]

結果

以下はサービスにおける利用率です。例えば、3 out of 5 suggested ingredients matches actual は 5 個 suggest したうち 3 個が利用された割合です。

3 out of 5 suggested ingredients matches actual(%) 2 out of 5 suggested ingredients matches actual(%)
Average of word embeddings 53% 80%
Bi-LSTM Sentence Embeddings 50% 76%

Average of word embeddings(これはBag-of-Wordsに似ています)はBi-LSTM Sentence Embeddingよりもこの問題に適しています。これは、レシピのタイトルは短いテキストであるために、単語順序の情報は材料を予測するのにはあまり役に立たないからだと思われます。

まとめ

  • レシピのタイトルから材料を予測できるモデルを作りました。
  • 投稿開発部と協力してレシピエディタに材料提案機能を追加しました。

いかがでしたでしょうか。 Cookpadでは、機械学習を用いて新たなサービスを創り出していける方を募集しています。 興味のある方はぜひ話を聞きに遊びに来て下さい。

Prebid.js 導入による Header Bidding 改善の舞台裏

こんにちは。メディアプロダクト開発部の我妻謙樹(@itiskj)です。 サーバーサイドエンジニアとして、広告配信システムの開発・運用を担当しています。好きな言語は Go と TypeScript です。

以前、"Header Bidding 導入によるネットワーク広告改善の開発事情" というタイトルで、

  • Header Bidding の仕組み
  • 弊社の広告配信のクライアント側の設計
  • Transparent Ad Marketplace(以下、TAM)導入の過程

についてご紹介しました。今回は、TAM に次いで Prebid.js をあわせて導入した際の知見についてご紹介します。

What is Prebid.js?

Prebid.js とは、OSS で開発されている Web 向け Header Bidding ライブラリです。 http://prebid.org/ において開発されているサービスの1つで、他にはアプリ向けの Prebid Mobile、サーバーサイド向けの Prebid Server などが開発されています。

https://github.com/prebid/Prebid.js/

Prebid のサービス群を用いると、以下二種類いずれかの Header Bidding に対応できます。

  • Client-to-Server Header Bidding(以下、C2S)
    • Prebid.js 及び Prebid Server を用いる
  • Server-to-Server Header Bidding(以下、S2S)
    • Prebid Server を自前でホスティングするか、すでに提供されている第三者サーバーを利用する

C2S 及び S2S の Pros/Cons は以下の通りです。

Type Pros Cons
C2S 対応事業者数が多い/トータルでの実装コストが低い クライアントのネットワーク帯域をより消費する
S2S サーバーサイドで制御できる/クライアント側からは Single Request 対応事業者が C2S と比べて限られている/Cookie Sync の技術的課題

今回は、「対応事業者数が多い」こと、及び「実装コストの改修」を考慮して、C2S 方式を導入しました。

Glossary

本文で言及している用語のうち、ドメイン知識のため説明が必要と思われる用語の一覧です。

word description
事業者 Header Bidding で入札リクエストを送っている先の事業者のこと。ここでは基本的に SSP(DSP を接続する場合もある)のことを指す。
スロット 広告が実際に表示される枠のこと。
DFP DoubleClick For Publisher の略。新名称は Google Ad Manager。
APS Amazon Publisher Services の略。TAM などの一連の広告サービスを提供する総称。
TAM Transparent Ad Marketplace の略。APS のサービスの一つで、Header Bidding を提供する。

Development with Prebid.js

Prebid.js を導入する際に、基本的に 公式ドキュメント | Getting Started 及び Publisher API Reference を参考することになります。公式ドキュメントがかなり充実しているので、基本的なユースケースであれば、ほぼ嵌らずに実装できるでしょう。

以下は、Getting Started に紹介されている、最小限の実装例です。ここで使用されている API のうち、主要なものについて紹介します。

なお、Google Publisher Tag(以下、GPT)との併合を前提としています。

<html>

    <head>
        <link rel="icon" type="image/png" href="/favicon.png">
        <script async src="//www.googletagservices.com/tag/js/gpt.js"></script>
        <script async src="//acdn.adnxs.com/prebid/not-for-prod/1/prebid.js"></script>
        <script>
            var sizes = [
                [300, 250]
            ];
            var PREBID_TIMEOUT = 1000;
            var FAILSAFE_TIMEOUT = 3000;

            var adUnits = [{
                code: '/19968336/header-bid-tag-1',
                mediaTypes: {
                    banner: {
                        sizes: sizes
                    }
                },
                bids: [{
                    bidder: 'appnexus',
                    params: {
                        placementId: 13144370
                    }
                }]
            }];

            // ======== DO NOT EDIT BELOW THIS LINE =========== //
            var googletag = googletag || {};
            googletag.cmd = googletag.cmd || [];
            googletag.cmd.push(function() {
                googletag.pubads().disableInitialLoad();
            });

            var pbjs = pbjs || {};
            pbjs.que = pbjs.que || [];

            pbjs.que.push(function() {
                pbjs.addAdUnits(adUnits);
                pbjs.requestBids({
                    bidsBackHandler: initAdserver,
                    timeout: PREBID_TIMEOUT
                });
            });

            function initAdserver() {
                if (pbjs.initAdserverSet) return;
                pbjs.initAdserverSet = true;
                googletag.cmd.push(function() {
                    pbjs.setTargetingForGPTAsync && pbjs.setTargetingForGPTAsync();
                    googletag.pubads().refresh();
                });
            }

            // in case PBJS doesn't load
            setTimeout(function() {
                initAdserver();
            }, FAILSAFE_TIMEOUT);

            googletag.cmd.push(function() {
                googletag.defineSlot('/19968336/header-bid-tag-1', sizes, 'div-1')
                   .addService(googletag.pubads());
                googletag.pubads().enableSingleRequest();
                googletag.enableServices();
            });

        </script>

    </head>

    <body>
        <h2>Basic Prebid.js Example</h2>
        <h5>Div-1</h5>
        <div id='div-1'>
            <script type='text/javascript'>
                googletag.cmd.push(function() {
                    googletag.display('div-1');
                });

            </script>
        </div>
    </body>

</html>

pbjs.addAdUnits

事業者ごとの設定項目を、スロットごとに追加します。 実質的には、pbjs.adUnits フィールドに渡された引数を追加しておくだけです。

Source Code: - pbjs.addAdUnits()

この際、事業者ごとの設定項目を設定する必要がありますが、全て以下のドキュメントに記述されています。

入札者ごとの設定項目ドキュメント: http://prebid.org/dev-docs/bidders.html

ただし、以下の注意点があります。

  • 事業者ごとに、パラメータの型が String / Number / Object で差異がある
    • ex. placementId が、文字列のこともあれば数字のこともある
  • ドキュメント上は任意(optional)だが、事業者から「必ず付与してください」と言われる場合がある
  • 一部ドキュメントが古い可能性があり、そのタイミングで事業者に最新のパラメーターを聞く必要がある

pbjs.requestBids

実際に Header Bidding 入札リクエストを行っている、要のメソッドです。

  • 設定された全事業者に対してリクエストを行う準備をする
  • auctionManager クラスを通して、auction を生成する
  • auction.callBids() で実際にリクエストを行い、入札結果レスポンスが返ってきたら、callback を実行する

Source Code: - pbjs.requestBids() - auctionManager.createAuction() - auction.callBids()

pbjs.setTargetingForGPTAsync

Header Bidding 入札結果を、GPT の Key/Value に設定します。したがって、入札が完了した後に呼び出す必要 があります。

Source Code: - pbjs.setTargetingForGPTAsync() - targeting.setTargetingForGPT()

Prebid.js のソースコードを追っていくとわかりますが、入札結果の存在したスロットに対して、gpt.PubAdsService.setTargeting() を呼び出しています。

  /**
   * Sets targeting for DFP
   * @param {Object.<string,Object.<string,string>>} targetingConfig
   */
  targeting.setTargetingForGPT = function(targetingConfig, customSlotMatching) {
    window.googletag.pubads().getSlots().forEach(slot => {
      // ...
      slot.setTargeting(key, value);
    })
  };

Debugging Prebid.js

Prebid.js には、公式で数々のデバッグ方法やベストプラクティスが紹介されています。主に以下のドキュメントに詳しいです。

開発者用デバッグモードやChrome Extension などのツールも揃っており、入札フローが複雑な割には比較的デバッグがしやすい印象です。

主要なものについて紹介します。

Debug Log

pbjs.setConfig API には、Debugging option が提供されています。以下のように Option を渡すと、必要十分なログを出力してくれます。

pbjs.setConfig({ debug: true });

しかし、ブラウザリロード(pbjs の再読込)の度に設定がリセットされてしまうので、ブラウザの Console から打ち込む用途として利用するのでは不便です。

一方、弊社の広告配信サーバーの JavaScript SDK のビルドプロセスでは webpack を利用しており、ビルド環境(production/staging/development)を define-plugin を用いてソースコードに埋め込んでいます。

この仕組を利用し、ステージング及び開発環境では、デフォルトでデバッグログを有効にします。

// index.js
this.pbjs.setConfig({
  debug: process.env.NODE_ENV === "development",
})
// webpack.config.js
plugins: [
  /**
   * DefinePlugin create global constants while compiling.
   *
   * @doc https://webpack.js.org/plugins/define-plugin/
   */
  new webpack.DefinePlugin({
    "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
  }),
],

Snippets

Chrome で提供されている Snippets という機能を利用し、本番環境でも手軽に入札リクエストや入札結果の中身をログに出力することができます。

これらのデータは Network タブから各事業者への入札リクエスト・レスポンスを覗くことでも確認できなくはないのですが、視認性が低いため Snippets を利用しています。

例えば、以下は Tips for Troubleshooting で紹介されている Snippets です。スロットごとに、全事業者に対する入札の結果、最終的に win した入札を表示してくれます。

var bids = pbjs.getHighestCpmBids();
var output = [];
for (var i = 0; i < bids.length; i++) {
    var b = bids[i];
    output.push({
        'adunit': b.adUnitCode, 'adId': b.adId, 'bidder': b.bidder,
        'time': b.timeToRespond, 'cpm': b.cpm
    });
}
if (output.length) {
    if (console.table) {
        console.table(output);
    } else {
        for (var j = 0; j < output.length; j++) {
            console.log(output[j]);
        }
    }
} else {
    console.warn('No prebid winners');
}

Chrome Extension

Prebid.js の公式ツールとして、Headerbid Expert という Chrome Extension が公開されています。

こちらのツールは、エンジニアだけでなくディレクターやプロジェクトマネージャーなども、気軽に自社の Header Bidding 入札結果を確認できるツールです。このツールを使うことで、事業者ごとの選別や、各社ごとのタイムアウト設定の見直し、インプレッション損失のリスクの洗い出しなどに利用できます。

f:id:itiskj:20190212120755j:plain
headerbid expert screenshhot

分析結果の見方については、Prebid.js Optimal Header Bidding Setup というドキュメントページに詳しく紹介されています。ある特定の事業者のタイムアウトに引っ張られて機会損失をしているパターン、何らかの設定ミスで DFP へのリクエストが遅れて機会損失をしているパターンなどが紹介されています。

Prebid.js Modules

Prebid.js は Module Architecture を導入しており、各事業者ごとのアダプターや通貨関連の共通処理をまとめたモジュールなどが提供されています。そして、ファイルサイズを可能な限り最小限に抑えるため、自社が必要なモジュールのみを Prebid.js Download ページからダウンロードしたものを利用することが基本です。

Source Code: - src/modules/*.js

今回は、そのうちでも特に主要なモジュールについて紹介します。

Currency Module

Prebid.js を開発していると、入札結果の金額に関して、例えば以下のような要件が必ずと言っていいほど発生するはずです。

  • 事業者ごとに、net/gross が違うが、入札結果からオークションする前に net/gross の単位を統一したい
  • 入札金額の粒度をより細かくして、機会損失を最小限に抑えたい
  • JPY/USD などの通貨設定が事業者ごとに違うが、DFP にリクエストする前に通貨単位を統一する必要がある
  • 通貨単位を統一する場合、為替を考慮する必要がある

その場合は、公式で提供されている Currency Module を使うことになります。Currency Module を導入すると、pbjs.setConfig() に以下の設定項目を渡すことができるようになります。

Source Code: - currency.js

例えば、以下は設定例です。

this.pbjs.setConfig({
    /**
     * set up custom CPM buckets to optimize the bidding requests.
     */
    priceGranularity: "high",
    /**
     * setting for the conversion of multiple bidder currencies into a single currency
     * http://prebid.org/dev-docs/modules/currency.html#currency-config-options
     */
    currency: {
        adServerCurrency: 'JPY',
        conversionRateFile: 'https://currency.prebid.org/latest.json',
        bidderCurrencyDefault: {
            bidderA: 'JPY',
            bidderB: 'USD',
        },
        defaultRates: {
            USD: {
                JPY: 110,
            }
        },
    },
});

conversionRateFile には、為替レートを変換する時に参考にする為替レートが格納されたファイルの URL を設定することができます。独自で更新したい場合は、こちらを自社の S3 Bucket などを見るようにしておいて、別途ファイルを更新するような仕組みを導入すればよいでしょう。デフォルトでは、jsDelivr と呼ばれる Open Source CDN に配置されてあるファイルを見に行くようになっています。

// https://github.com/prebid/Prebid.js/blob/master/modules/currency.js#L8
const DEFAULT_CURRENCY_RATE_URL = 'https://cdn.jsdelivr.net/gh/prebid/currency-file@1/latest.json?date=$$TODAY$$';

もちろん、毎回 Network を通じてファイルを取得しているわけではなく、Currency Module 内部でオンメモリに為替レートをキャッシュしています。

// https://github.com/prebid/Prebid.js/blob/c2734a73fc907dc6c97d7694e3740e19b8749d3c/modules/currency.js#L236-L240
function getCurrencyConversion(fromCurrency, toCurrency = adServerCurrency) {
  var conversionRate = null;
  var rates;
  let cacheKey = `${fromCurrency}->${toCurrency}`;
  if (cacheKey in conversionCache) {
    conversionRate = conversionCache[cacheKey];
    utils.logMessage('Using conversionCache value ' + conversionRate + ' for ' + cacheKey);
  }
  // ...

なお、https://currency.prebid.org/latest.json というファイルが、https://currency.prebid.org にて提供されています。curl --verbose した結果が以下のとおりです。

curl --verbose http://currency.prebid.org/ | xmllint --format -
* TCP_NODELAY set
* Connected to currency.prebid.org (54.230.108.205) port 80 (#0)
> GET / HTTP/1.1
> Host: currency.prebid.org
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: application/xml
< Transfer-Encoding: chunked
< Connection: keep-alive
< Date: Fri, 08 Feb 2019 04:17:03 GMT
< x-amz-bucket-region: us-east-1
< Server: AmazonS3
< Age: 43
< X-Cache: Hit from cloudfront
< Via: 1.1 31de515e55a654c65e48898e37e29d09.cloudfront.net (CloudFront)
< X-Amz-Cf-Id: XEpWTG_WXRO4w44X9eIrOV2r_sR-i9EyoZpUwhIRkzXwzqr71w1GyQ==
<
{ [881 bytes data]
100   869    0   869    0     0  27899      0 --:--:-- --:--:-- --:--:-- 28032
* Connection #0 to host currency.prebid.org left intact
<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
  <Name>currency.prebid.org</Name>
  <Prefix/>
  <Marker/>
  <MaxKeys>1000</MaxKeys>
  <IsTruncated>false</IsTruncated>
  <Contents>
    <Key>latest-test.json</Key>
    <LastModified>2018-10-15T21:38:13.000Z</LastModified>
    <ETag>"513fe5d930ec3c6c6450ffacda79fb09"</ETag>
    <Size>1325</Size>
    <StorageClass>STANDARD</StorageClass>
  </Contents>
  <Contents>
    <Key>latest.json</Key>
    <LastModified>2019-02-07T10:01:03.000Z</LastModified>
    <ETag>"6e751ac4e7ed227fa0eaf54bbd6c973d"</ETag>
    <Size>1331</Size>
    <StorageClass>STANDARD</StorageClass>
  </Contents>
  <Contents>
    <Key>test.json</Key>
    <LastModified>2018-12-05T11:00:47.000Z</LastModified>
    <ETag>"c4a01460ebce1441625d87ff2ea0af64"</ETag>
    <Size>1341</Size>
    <StorageClass>STANDARD</StorageClass>
  </Contents>
</ListBucketResult>

結果から、次のことがわかります。

  • Amazon S3 に格納されている
    • Server: AmazonS3
  • CloudFront で配信されている
    • X-Cache: Hit from cloudfront
  • latest.json / test.json / latest-test.json が提供されている

特に理由がないのであれば、こちらのファイルを使うことで概ね十分だと言えるでしょう。

また、net/gross の変換には、pbjs.bidderSettings | bidCpmAdjustment を用います。

this.pbjs.bidderSettings = {
    bidderA: {
        bidCpmAdjustment : (bidCpm) => bidCpm * 0.85,
    },
    bidderB: {
        bidCpmAdjustment : (bidCpm) => bidCpm * 0.80,
    },
};

Integration with TAM

"Header Bidding 導入によるネットワーク広告改善の開発事情" でお伝えしたとおり、いくつかの事業者についてはすでに TAM 経由で Header Bidding 入札を行っていました。今回は、TAM と並行する形で Prebid.js の導入をする必要がありました。

以下に、全体のデータフローを示しました。par の部分で、TAM および Prebid.js 経由の Header Bidding 入札を並行して行い、両者から結果が返ってきたら DFP にリクエストします。

type description
ads 社内広告配信サーバ
display.js 広告表示用の JavaScript SDK
cookpad_ads-ruby display.js を埋め込むための Rails 用ヘルパーを定義した簡易な gem
apstag TAM の提供する Header Bidding 用ライブラリ
googletag DFP の提供するアドネットワーク用ライブラリ
pbjs Prebid.js 用ライブラリ
SSP SSP 事業者(実際は複数事業者が存在している)

f:id:itiskj:20190212120829j:plain
Sequence Diagram for Prebid.js and TAM migration

DFP にリクエストをする前に、TAM と Prebid.js 両者の入札を完了させておきたかったので、Promise.all でリクエストを行い、待ち合わせる形で実装しました。以下は、本番で利用しているコードの抜粋です(エラーやロギングなど、本質ではない行を削除したもの)。

  requestHeaderBidding(slots) {
    const prebidPromise = this.requestPrebid(slots);
    const apsPromise = this.requestAPS(slots);

    return Promise.all([prebidPromise, apsPromise])
      .then(() => this.headerBiddingFinishCallback())
      .catch(err => Logger.error(err));
  }

  requestPrebid(slots) {
    return new Promise((resolve) => {
      pbjs.que.push(() => {
        pbjs.addAdUnits(this.getPrebidAdUnits);

        pbjs.requestBids({
          bidsBackHandler: (result) => {
            resolve({
              type: "prebid",
              result: result || [],
            });
          },
          timeout: this.prebid_timeout,
        });
      });
    });
  }

  requestAPS(slots) {
    return new Promise((resolve) => {
      apstag.fetchBids(thihs.apstagBidOption, (bids) => {
        resolve({
          type: "aps",
          bids: bids || [],
        });
      });
    });
  }

  headerBiddingFinishCallback() {
    googletag.cmd.push(() => {
      pbjs.setTargetingForGPTAsync();
      apstag.setDisplayBids();

      googletag.pubads().refresh();
    });
  }

Conclusion

アドテク関連のエンジニア目線での事例紹介や技術詳解はあまり事例が少ないため、この場で紹介させていただきました。特に、Prebid.js は、開発自体はドキュメントが丁寧な分嵌りどころは少ないものの、実際の導入フローにおける知見は、日本においてほとんど共有されていません。そこに問題意識を感じたため、この機会に Prebid.js の導入フローを紹介させていただきました。

広告領域は、技術的にチャレンジングな課題も多く、かつ事業の売上貢献に直結することが多い、非常にエキサイティングな領域です。ぜひ、興味を持っていただけたら、Twitter からご連絡ください。

また、メディアプロダクト開発部では、一緒に働いてくれるメンバーを募集しています。少しでも興味を持っていただけたら、以下をご覧ください。

DroidKaigi 2019 にクックパッド社員が1名登壇&ブースでお待ちしております!

こんにちは!広報部のとくなり餃子大好き( id:tokunarigyozadaisuki )です。

さて、エンジニアが主役のAndroidカンファレンス、DroidKaigi 2019の開催まであと二日となりました!

クックパッドは、本カンファレンスにゴールドスポンサーとして協賛します。そして、クックパッドに所属する @litmonが登壇し、@nshiba@shanonim が当日スタッフとして関わってくれております。 約20名のクックパッド社員が、DroidKaigi 2019 に参加致しますので、会場でお見かけの際にはお声がけいただけますと嬉しいです。

登壇の詳細

はじめに、登壇スケジュールと内容を紹介します。 @litmonが登壇するのは、2日目の最後のセッションです! 

2月8日(金)18:30〜 Room 4

門田福男 (@litmon) : Google Play Consoleのリリーストラックを有効活用してリリースフローの最適化を行った話

概要 : Google Play Consoleにはアプリのリリースを行う際にいくつかのトラック(alpha, beta, production)を選択することができる。また、2018年には新たにinternalトラックが開放された。 クックパッドアプリでは、これらのトラックを有効活用し、リリース自動化を行い、人間によるリリーススケジュールの管理をやめたときの話をする。 また、その際にぶつかった技術的制約などにどう対応したか、リリース自動化に向けて行った様々なTipsを紹介する。

コメント

クックパッドアプリは、複数の部署が協力して一つのアプリを開発しています。他部署とのコミュニケーションコストが肥大化していく中、打開策として機械による毎週自動でリリースを行う仕組みを実現しました。本セッションではどうしてクックパッドが自動リリースを行うようにしたのか、またAndroidアプリではそれをどのように実現したのか、その結果どうだったかなどを発表します。アプリのリリースフローについて悩んでいる方や、自動化に興味のある方、ぜひ遊びに来てください!

ブース

クックパッドは、DroidKaigi 2019 にてブースを出展いたします。Androidカンファレンスならではのクックパッドノベルティを数量限定でご用意いたしております! ぜひ、お立ち寄りくださいね。

Cookpad.apk #2 を開催します

本カンファレンス後、2/18(月)には昨夏#1を実施した「Cookpad.apk」の第2回を開催することにいたしました。DroidKaigi 2019 で惜しくも不採択となってしまったトークを中心に、現在社内で行っているAndroidアプリ開発に関する知見や学びについて共有いたします。懇親会の時間もございますのでお楽しみに! 

※本イベントはDroidKaigi 実行委員会が運営する公式イベントではありません。
※ご好評を頂き、全て満席となりましたのでご了承下さい。たくさんのお申し込みありがとうございました。

cookpad.connpass.com

おわりに

発表内容へのご質問やクックパッドにご興味をお持ちの方は、お気軽にブースまでお越しください! みなさまにお会いできることを楽しみにしております。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/