読者です 読者をやめる 読者になる 読者になる

NO AD NO LIFE

ベンチャー企業の広告エンジニアのブログ

作りながら学ぶ広告サーバ - DSP その2

はじめに

DSPを作りながら詳細について解説しております.

前回は,RTBとDSPの概要,インフラ設計,データ設計の説明をしました.

今回は,入札機能の詳細と実装の説明をします.記事内で紹介しているコードは,Githubに公開しております.

前回のエントリーを読んでいない方は,下記からお進みください.

inchom.hatenadiary.jp

(注意)共通標準であるOpenRTBの仕様をベースに開発を進めるので,現状動いているシステムのRTBの仕様とは異なっていたり,一部私の認識が間違っている箇所もあるかもしれません,そういった場合はコメント等いただけたらと思います.

入札機能の概要

入札機能の概要について説明していきます.下図の赤枠で囲んだ部分が入札機能です.

f:id:inchom:20160507110441p:plain

DSP視点での入札機能の処理の流れになります.

  1. SSPから送信されるRTBリクエストを取得する
  2. 広告枠情報から入札するかを決めて,入札する場合は入札価格を決めてRTBレスポンスを返す
  3. SSPから落札通知が送られてきた場合は広告マークアップSSPに送信する

以下,順を追って説明していきます.

1. SSPから送信されるRTBリクエストを取得する

SSPは,ユーザがメディアを開いたタイミングで,RTBリクエストをDSPに送信します.

RTBリクエストとは

RTBリクエストとは,「このような広告枠に広告を出したい人は入札してください!ただし,最低入札単価は○○円です!」というような広告枠の情報を持った入札のリクエストです.

実際のRTBリクエストのサンプルを張っておきます.

{
    "id": "IxexyLDIIk",
    "imp": [
        {
            "id": "1",
            "banner": {
                "w": 728,
                "h": 90,
                "pos": 1
            },
            "bidfloor": 50,
            "bidfloorcur": "JPY"
        }
    ],
    "app": {
        "id": "agltb3B1Yi1pbmNyDAsSA0FwcBiJkfIUDA",
        "name": "Yahoo Weather",
        "cat": [
            "IAB15"
        ],
        "ver": "1.0.2",
        "bundle": "628677149",
        "publisher": {
            "id": "agltb3B1Yi1pbmNyDAsSA0FwcBiJkfTUCV",
            "name": "yahoo",
            "domain": "www.yahoo.com"
        },
        "storeurl": "https://itunes.apple.com/id628677149"
    },
    "device": {
        "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 6_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/5.1 Mobile/9A334 Safari/7534.48.3",
        "ip": "123.145.167.189",
        "model": "iPhone",
        "os": "iOS"
    },
    "user": {
        "id": "ffffffd5135596709273b3a1a07e466ea2bf4fff",
        "yob": 1984,
        "gender": "M"
    }
}
  • impフィールドには,広告枠の詳細情報が入ります.
    • 最低入札価格(imp.bidfloor)*1
    • 最低入札価格の通貨(imp.bidfloorcur)*2
    • imp.bannerフィールドは,バナー広告のみが持つフィールドで,バナーの情報が入ります.
      • バナーの大きさ(imp.banner.w, imp.banner.h)
      • 位置(imp.banner.pos)
  • appフィールドは,メディアがアプリの場合のみ持つフィールドで,アプリメディアの情報が入ります.
    • アプリのカテゴリ(app.cat)
    • バージョン(app.ver)
    • AppStoreのURL(app.storeurl)
  • deviceフィールドには,ユーザのデバイスの詳細情報が入ります.
    • アクセス元のIP(device.ip)
    • モバイルOS(device.os)
    • 位置情報(device.geo)
  • userフィールド,ユーザの情報が入ります.
    • 生まれ年(user.yob)
    • 性別(user.gender)

上記のものは,ごく一部のものですので,詳細を知りたい方はOpenRTB v2.3 Specificationをご覧下さい.

DSPは上記のような情報を参照しつつ,広告をクリックされやすい広告枠,あるいはクリックしやすいユーザーかを推定して,枠に対して入札を行います.

実装

RTBリクエストを取得・検証する部分の実装を見ていきましょう.

func parseRequest(r *http.Request) (*Request, error) {
    // Read bid request in json format
    body, err := ioutil.ReadAll(r.Body)
    if err != nil {
        return nil, err
    }

    // Unmarshal json to BidRequest
    var br openrtb.BidRequest
    err = json.Unmarshal(body, &br)
    if err != nil {
        return nil, err
    }

    return &Request{
        BidRequest: &br,
    }, nil
}

RTBリクエストの取得は,POSTリクエストでBody部分にJSON形式のデータが入ってくるため,それをパースするだけです*3

パース先の構造体は,OpenRTBの仕様に沿ったものを,こちらで公開されていたため,それをインポートする形で使っています.

Bodyのデータが読めなかったり,JSONが壊れていたり,JSONのデータがOpenRTBの仕様に沿っていない場合は,エラーが返ります.

正常に処理が成功した場合は,RequestオブジェクトにBidRequestオブジェクトを持たせて返します.

次に,取得したリクエストの検証部分です.

func (r *Request) validate() error {
    // Required attributes must be exsit
    if r.BidRequest.ID == "" {
        return errors.InvalidRequestParamError{"BidRequest.ID", ""}
    }
    if r.BidRequest.Imp == nil {
        return errors.InvalidRequestParamError{"BidRequest.Imp", ""}
    }

    // Currency type is valid
    if !r.validCurrency() {
        return errors.InvalidCurError
    }
    return nil
}

func (r *Request) validCurrency() bool {
    // currency not exist
    if len(r.BidRequest.Cur) == 0 {
        return true
    }
    // currency exist
    for _, cur := range r.BidRequest.Cur {
        if cur == consts.DefaultBidCur {
            // currency including default bid currency
            return true
        }
    }
    // currency NOT including default bid currency
    return false
}

最低限の検証として,必須フィールドが無かったり,DSPで扱っているデフォルトの通貨とは違うリクエストがきた場合は,検証エラーを返します.

実際にプロダクションで扱う場合には,ホワイトリストやブロックリストの値を見るような検証も必要かと思います.

2. 広告枠情報から入札するかを決めて,入札する場合は入札価格を決めてRTBレスポンスを返す

RTBリクエストを受け取ったDSPは,広告枠情報を参照して,配信可能な広告候補から入札するかを決定します.入札する場合は入札価格を決めてRTBレスポンスを返します.

入札の決定

1で説明した広告枠の情報を参照しつつ,入札するべきかを決めます. 入札を行うかどうかの基本的な考え方は,「枠ごとに配信可能な広告のeCPMを考慮して入札価格を計算し,最低入札単価を上回っている場合は,入札を行う」かと思います.

下図で説明すると,最低入札価格が50円となっているときに,手元の広告のこの広告枠に対してのeCPMを計算します. 結果,eCPMが最も高いものが100円ですので,この枠に対して入札することになります.

f:id:inchom:20160507122028p:plain:w400

実装

送信されてRTBリクエスト内のimpが正しいかを検証します.

func validateImp(imp *openrtb.Imp) error {
    // Native Ad is not supported
    if imp.Native != nil {
        return errors.NoSupportError{"native"}
    }
    // Video Ad is not supported
    if imp.Video != nil {
        return errors.NoSupportError{"video"}
    }
    // Check bid currency
    if imp.BidFloorCur != "" && imp.BidFloorCur != consts.DefaultBidCur {
        return errors.InvalidCurError
    }
    return nil
}

ネイティブ広告やビデオ広告はサポートしておりませんので,それらのimpだった場合は入札しないようにしています. また,impごとに設定されている入札通貨がDSPのデフォルトと異なっている場合は入札しません.

impが正しい場合は,枠ごとに入札候補(Index)となる広告を取得します.

func GetIndex(ctx context.Context, imp *openrtb.Imp) (Index, error) {
    // Get key for getting value in redis
    key, err := makeKey(imp)
    if err != nil {
        return nil, err
    }

    // Get value from redis
    cli := redis.GetConn(ctx, consts.CtxRedisKey)
    value, _ := redis.GetCmd(cli, key)
    var out Index
    err = msgpack.Unmarshal([]byte(value), &out)
    if err != nil {
        // Failed to get valid value
        return nil, err
    }
    return out, nil
}

入札候補は,枠ごとに作成され,データストアであるRedis内にMsgpackでパックされた形で保存されています.Indexのデータ構造は第一回で説明していますのでご覧下さい.

入札候補には,広告情報のリストが入っています. 具体的には,広告IDや遷移先URL,eCPMなどです.

下記,入札候補データのイメージです*4

key => value

index:banner => [ 
  {
    eCPM: 200.0,
    CampaignID: "1234",
    CreativeID: "12345",
    Nurl: "http://xxxx.xxx/xxx/win",
    Adm: ...
  },
  {
    eCPM: 180.0,
    CampaignID: "1235",
    CreativeID: "12346",
    Nurl: "http://xxxx.xxx/xxx/win",
    Adm: ...
  }
]

Redisからデータを取得できなかったり,Msgpackでアンパックできなかったときは,エラーを返します.

現状,Redisから直接取得する形になっていますが,IOコストかかるため実際のプロダクション環境ではプロセスキャッシュを間に挟む方よいです.

次に,取得した候補から一つずつ広告を取り出し,広告の検証を行います.

func validateAd(imp *openrtb.Imp, ad *data.Ad) error {
    // Check bid price greater than bid floor price
    if ad.CalcBidPrice() <= imp.BidFloor {
        return errors.LowPriceError
    }
    return nil
}

入札価格が最低入札価格を上回っていることのみを条件としています.

将来的には,ブロックされているカテゴリやドメインでないかをチェックする必要があります.

この検証を通った広告が一つ以上あれば入札することになります.

入札価格の計算

入札する場合は,入札価格を決める必要があります. 上の図のケースで,100円で入札してしまっては,DSP側に利益がでません.(もちろん推定以上にeCPMが高いケースは別ですが)

DSP側の利益を最大化することを考えると,入札できる価格で最も低く入札することが必要になります.

f:id:inchom:20160507122827p:plain:w300

入札できる価格の推定は,複数DSPが関わってくるので常に変動することになります.また,入札価格を下げては配信ボリュームがでませんし,入札価格を上げすぎては利益をつぶすことになります. まだ,私自身不勉強で入札価格をどのように決定するかは調べきれていませんが,入札価格と落札結果をフィードバックさせて決めているのではないかと妄想しています.

実装

検証に通った広告の中で,最も収益性の高い広告を選びます.

func choiceBestAd(va []data.Ad) (data.Ad, bool) {
    var revenue, maxRevenue float64
    index := -1
    for i := range va {
        revenue = va[i].PeCPM - va[i].CalcBidPrice()
        if revenue > maxRevenue {
            maxRevenue = revenue
            index = i
        }
    }
    if index == -1 {
        return data.Ad{}, false
    }
    return va[index], true
}

収益性はeCPMから入札価格を引いた値として,広告の中で最も値の大きいものを選択します.

入札価格の計算は,かなり簡易的なものにしています.

func (ad *Ad) CalcBidPrice() float64 {
    return ad.PeCPM * (1.0 - consts.RevenueRatio)
}

eCPMに対する収益比率を決めて,その分を差し引いて入札するだけです.

例えば,収益比率が10%とした場合には,eCPMが100円だったときに90円で入札して10円は収益になる,という感じです.

eCPMの推定

今回はあまり深く触れませんが,重要な話として,eCPMをどのように推定するかを考える必要があります. eCPMの推定の精度が低いと,入札すべきかの判定や入札価格を決めるのが難しく,また,収益性を担保することが難しくなります.

eCPMの推定は,基本的には過去の広告枠の配信ログからクリック率を推定し,計算することになるかと思います. バッチで定期的に計算されているものが配信候補リスト内に入っていることを前提としています.

実装

配信ログとして,RTBリクエストに対して何の広告を返したかの情報を作成し,fluentd経由で送るようにしています.

func sendLog(ctx context.Context, req *openrtb.BidRequest, resp *openrtb.BidResponse) {
        fluent.Send(ctx, consts.CtxFluentKey, "request", makeRequestLog(req, resp))
        for _, bid := range resp.SeatBid[0].Bid {
                fluent.Send(ctx, consts.CtxFluentKey, "bid", makeBidLog(&bid))
        }
}

func makeRequestLog(r *openrtb.BidRequest, br *openrtb.BidResponse) map[string]interface{} {
        return map[string]interface{}{
                "request_id": r.ID,
                "cur":        br.Cur,
                "bid_num":    len(br.SeatBid[0].Bid),
        }
}

func makeBidLog(bid *openrtb.Bid) map[string]interface{} {
        return map[string]interface{}{
                "bid_id":      bid.ID,
                "imp_id":      bid.ImpID,
                "price":       bid.Price,
                "ad_id":       bid.AdID,
                "campaign_id": bid.CID,
                "creative_id": bid.CrID,
        }
}

BidRequestとBidResponseから,「どの枠に対してどの広告をいくらで入札したか」のログデータを構築しています.

eCPM推定の精度を上げるためには,メディア情報やユーザ,デバイス情報などもログするようにしていく必要があるかと思います.

RTBレスポンスの返送

RTBレスポンスは,RTBリクエストに対して入札するかどうか,入札する場合はどんな広告をいくらで入札するかといった情報を持ちます.

以下に簡単サンプルを張っておきます.

{
    "cur": "JPY", 
    "id": "IxexyLDIIk", 
    "seatbid": [
        {
            "bid": [
                {
                    "adid": "1234", 
                    "adm": "<a href=\"http://test.noadnolife.com/v1/click/12345?impid=${AUCTION_IMP_ID}&price=${AUCTION_PRICE}\"><img src=\"http://test.noadnolife.com/img/12345?impid=${AUCTION_IMP_ID}&price=${AUCTION_PRICE}\" width=\"728\" height=\"90\" border=\"0\" alt=\"Advertisement\" /></a>", 
                    "cid": "1234", 
                    "crid": "12345", 
                    "id": "ed5dd1c0-05f1-4166-9370-4b07864d1136", 
                    "impid": "1", 
                    "iurl": "http://test.noadnolife.com/img/12345.png", 
                    "nurl": "http://test.noadnolife.com/v1/win/12345?impid=${AUCTION_IMP_ID}&price=${AUCTION_PRICE}", 
                    "price": 225
                }
            ]
        }
    ]
}
  • idフィールドにRTBリクエストIDが入ります
  • seatbid.bidフィールドに入札情報が入ります
    • キャンペーンID(seatbid.bid.cid)
    • クリエイティブID(seatbid.bid.crid)
    • インプレッションID(seatbid.bid.impid)
    • 落札通知の送信用URL(seatbid.bid.nurl)
    • 広告マークアップ(seatbid.bid.adm)
    • 入札価格(seatbid.bid.price)

SSPに対して,「どの枠にどの広告をいくらで入札します」ということを上記の情報を送ることで実現します.

実装

入札した結果取得した広告が一つ以上あれば,BidReponseの構造体にマッピングします.

func makeBidResponse(br *openrtb.BidRequest, ads []*data.Ad) *openrtb.BidResponse {
        sb := makeSeatBid(ads)
        return &openrtb.BidResponse{
                ID:      br.ID,
                SeatBid: sb,
                Cur:     consts.DefaultBidCur,
        }
}

func makeSeatBid(ads []*data.Ad) []openrtb.SeatBid {
        bid := make([]openrtb.Bid, len(ads))
        for i, ad := range ads {
                bid[i] = *makeBid(ad)
        }
        return []openrtb.SeatBid{
                openrtb.SeatBid{
                        Bid: bid,
                },
        }
}

func makeBid(ad *data.Ad) *openrtb.Bid {
        return &openrtb.Bid{
                ID:    uuid.NewRandom().String(),
                ImpID: ad.ImpID,
                Price: ad.CalcBidPrice(),
                AdID:  ad.AdID,
                CID:   ad.CampaignID,
                CrID:  ad.CreativeID,
                NURL:  ad.NURL,
                IURL:  ad.IURL,
                AdM:   ad.AdM,
        }
}

レスポンスのhttpヘッダーに,OpenRTB用の拡張ヘッダーを入れておき,BidResponseのオブジェクトをJSONに書き換えてレスポンスを送ります.

func WriteResponse(w http.ResponseWriter, resp interface{}, code int) {
        w.Header().Set("Content-Type", "application/json; charset=utf-8")
        w.Header().Set("x-openrtb-version", "2.3")
        w.WriteHeader(code)
        json.NewEncoder(w).Encode(resp)
}

OpenRTB用拡張ヘッダー

X-Openrtb-Version: 2.3

一つも広告が無い場合は,ステータスコード204を返します.

3. SSPから落札通知が送られてきた場合は広告マークアップSSPに送信する

リクエストの取得

2で返送したRTBレスポンスが落札された場合,SSPから落札通知(WinNotice)が送られてきます.

送信先のエンドポイントは,RTBリクエスト内のseatbid.bid.nurlに定義します.そこで定義しているurlとパラメータでリクエストが送信されてきます.

今回は,以下のようなnurlを定義しています.

http://test.noadnolife.com/v1/win/12345?impid=${AUCTION_IMP_ID}&price=${AUCTION_PRICE}

nurl内には,マクロを定義することができ,SSPが置き換えたものを送信してきます.

詳細は,OpenRTBv2.3の仕様の4.4に書かれていますので気になる方は参照してください.

実装

送られてくるパラメータを取得,検証します.

func parseRequest(ctx context.Context, r *http.Request) *Request {
        wonPriceStr := r.FormValue("price")
        wonPrice, err := strconv.ParseFloat(wonPriceStr, 64)
        if err != nil {
                wonPrice = 0.0
        }

        impID := r.FormValue("impid")
        creativeID := kami.Param(ctx, "crid")

        return &Request{
                WonPrice:   wonPrice,
                CreativeID: creativeID,
                ImpID:      impID,
        }
}

func (r *Request) validate() error {
        if r.WonPrice <= 0.0 {
                return errors.InvalidRequestParamError{"price", fmt.Sprintf("%f", r.WonPrice)}
        }

        if r.ImpID == "" {
                return errors.InvalidRequestParamError{"impid", r.ImpID}
        }

        if r.CreativeID == "" {
                return errors.InvalidRequestParamError{"crid", r.CreativeID}
        }
        // validation ok
        return nil
}

crid,impid,priceにそれぞれ値が存在し,落札価格が0より大きい場合は有効な落札通知と判定しています.

広告マークアップの返送

WinNoticeを受け取ったDSPは広告マークアップを返します*5.広告マークアップとは,広告を表示するためのマークアップ情報です.

実装

今回は,広告マークアップはRTBレスポンスに含めているため,送らないこととしています.

utils.WriteResponse(w, map[string]interface{}{"message": "ok"}, 200)

ステータスコード200で{"message": "ok"}のみ返します.

落札価格のフィードバック

落札価格は,一般的に二つの方法のいずれかで決められています.

  • ファーストプライスオークション
    • 最も高い入札価格が落札価格となる
  • セカンドプライスオークション
    • 2番目に高い入札価格(+1円)が落札価格となる

どちらの方法でオークションが行われているかは,RTBリクエストのatフィールドで決められます(デフォルトはセカンドプライスオークション).

いずれのオークションであれ,落札できた入札価格をログしておき,次の入札を最適化する必要があります.

実装

fluentd経由で落札したクリエイティブ,インプレッションID,落札金額のログを送ります.

fluent.Send(ctx, "fluent", "win", map[string]interface{}{
     "WonPrice":   request.WonPrice,
     "CreativeID": request.CreativeID,
     "ImpID":      request.ImpID,
})

集計時に,入札時のログのImpIDと落札通知のImpIDを結びつけて,入札価格と落札価格の差や,入札成功,失敗のフィードバックをかけていきます.(現状はその機構は存在しないのでとりあえず溜めていきます)

その他

eCPMを推定するためには,クリック率が測定できる必要があるためクリックログを保存する必要があります. また,CVRを計測するためには,コンバージョンログを保存する必要があります.

上記は入札機能に付随するものとして今後どこかで説明したいと思います.

おわりに

長くなってしまいましたが,DSPの入札機能の実装について説明しました.記事内で紹介しているコードは,Githubに公開しております.

今回説明した内容は最小限のものですので,実際にはより高精度にeCPMを推定するためや入札価格を決めるため,より細かな情報や高度なアルゴリズムを使う必要があるかと思います.

実装に関しても,非同期にすべき箇所やキャッシュ化すべき箇所などがありますが,簡易的な実装として理解していただけたら幸いです.

次回は,構築について説明していきます.

参考

RTBリクエストの簡単な表(一部)を作りました.

属性 説明
id string; 必須 ビッドリクエストに付与されるユニークなID
imp obeject array; 必須 インプレッション情報; 1要素以上必須
site object; 推奨 メディアのWebサイトの情報; Webメディアのみ適用可能/推奨
app object; 推奨 メディアのアプリの情報; アプリメディアのみ適用可能/推奨
device object; 推奨 インプレッションするユーザのデバイスの詳細情報
user object; 推奨 ユーザ情報
test integer; default 0 テストモード(課金不可)のフラグ; 1=テストモード
at integer; default 2 オークション種別; 1 = 1stプライスオークション, 2 = 2ndプライスオークション
tmax integer 入札のタイムアウト時間(ms)
wseat string array 入札可能なバイヤーのホワイトリスト
cur string array 入札用の通貨の配列; ISO-4217のコードを使用
bcat string array ブロックする広告主のカテゴリ
badv string array ブロックする広告主のドメイン
  • impオブジェクトのフィールド (一部)
属性 説明
id string; required インプレッションに付与されるビッドリクエスト内で一意のID
banner object バナー広告枠情報
native object ネイティブ広告枠情報
video object ビデオ広告枠情報
instl integer; default 0 1の場合は全画面あるいはインタースティシャル広告; 1のときはインタースティシャルではない
tagid string 特定の広告の位置を示すタグID
bidfloor float; default 0 最低入札価格
bidfloorcur string; default "USD" 最低入札価格通
  • bannerオブジェクトのフィールド (一部)
属性 説明
w integer; recommended 広告枠の表示横幅
h integer; recommended 広告枠の表示縦幅
pos integer 広告枠の表示位置
btype integer array ブロックするバナータイプ
battr integer array ブロックするクリエイティブ
api integer array サポートするAPIフレームワーク

*1:CPMベースで1000インプレッションあたりの単価が入ります

*2:最低入札単価はISO-4217で決められているアルファベットで指定されます

*3:go標準パッケージのjsonはクソ説がありますが,今回は簡単に作っているので使っています.気になる方はより速いffjsonを使うのがよいかと思います

*4:実際にはMsgPackでパックされた状態で入っています

*5:2のRTBレスポンスに含めることもできるため,その場合は送る必要はありません