既存実装を活用しつつJetpack Composeを用いてクックパッドAndroidアプリの買い物機能を高速に開発している話

こんにちは、クックパッド事業本部 買物サービス開発部の佐藤(@n_atmark)です。 2019年新卒で入社後、クックパッドの新規サービスである「クックパッドマート」の開発に従事しており、2020年からはレシピサービス「クックパッド」iOSアプリの買い物機能を開発していました。

レシピサービス「クックパッド」の買い物機能は、iOSアプリ(以下: クックパッドiOS)で先行リリースしており、現在Androidアプリ(以下: クックパッドAndroid)でも同様の機能開発を行っています。 本稿ではクックパッドAndroidの買い物機能の開発について紹介します。

買い物機能とは

生鮮食品ECサービス「クックパッドマート」の仕組みと連携し、レシピサービス「クックパッド」のアプリから食材を注文できます*1

詳しくはiOSアプリ向けの買い物機能の開発について紹介している​​ SwiftUI を活用した「レシピ」×「買い物」の新機能開発 をご覧ください。

画面スクリーンショット
買い物機能の画面例

なぜやるのか

クックパッドで目指している「毎日の料理を楽しみにする」の実現のためです。

現状のレシピサービスでは、料理に制約があります。冷蔵庫にある材料からレシピを探し、冷蔵庫にあるもので料理をつくる ━━ この流れは非常によくある形ですが、「冷蔵庫にある食材」に制約された窮屈な体験とも言えます。

あり物の食材からレシピを探している図
冷蔵庫にある材料からレシピを探す体験

楽しみが広がる、まったく新しい「買い物」を

クックパッドマートは、流通の仕組みから開発しているまったく新しい生鮮食品ECサービスです。

  • 1品からでも送料無料
  • 価格も割安
  • 新鮮
  • 品揃えも豊富
  • 受け取りも楽にできる

cookpad-mart.com

━━ そのような新たな買い物手段があることで、これまでになかった料理の選択肢や楽しみが広がります。

この新たな「買い物」をレシピサービスにうまく融合させることで、今まで以上に料理を楽しみにできると考えています。

例えば、「めずらしい野菜で作るハロウィン料理、気になる!」「バターナッツかぼちゃ美味しそう!食べてみたい!」のように、食べたいものを起点に料理を作りたくなる体験もその一つです。

ハロウィン料理にぴったりの珍しい野菜が載っているスクリーンショット
食べたいものを起点に料理を作りたくなる体験

この新しい「レシピ」×「買い物」の体験は現在クックパッドiOSでのみ提供していますが、既に多くのユーザーから好評をいただいています。もっと多くのユーザーさんに利用していただけるようにするため、今回クックパッドAndroidにも買い物機能を追加することになりました。

買い物機能を最速でリリースするために選択したこと

「レシピ」×「買い物」の新しい体験はまだまだ成熟しておらず、日々チーム内で新しいアイデアを議論したり、ユーザーインタビューを繰り返しています。

note.com

チームとしてはまだまだ体験を突き詰めたいフェーズなので、利用できるユーザーを増やすためのAndroidアプリ開発だけに全リソースを投入できる状況ではありません。そのため、最低限のリソースで最速にリリースを行い、リリースされてからの体験改善に時間やリソースを使いたいと考えています。

再利用可能なAndroid実装の活用

冒頭でも紹介した通り、今回開発中の買い物機能は生鮮食品ECサービス「クックパッドマート」の仕組みを利用しています。

クックパッドマートの利用に特化した専用アプリ(以下: マートAndroid)も既に開発されています。

下のスクリーンショットは、マートAndroidとクックパッドAndroidの買い物機能のそれぞれの商品詳細画面です。

マートAndroid
クックパッドAndroid

画面の表示内容や方法はデザイン上、似通っていることがわかると思います。 画面構成が同じ箇所に関しては実装の使い回しができるのではないかと考えました。

宣言的UIフレームワークの活用

SwiftUI を活用した「レシピ」×「買い物」の新機能開発 にもあるように、クックパッドiOSの買い物機能は SwiftUI を用いて開発されています。約1年半 SwiftUI を用いてサービス開発をしていたこともあり、チーム開発で宣言的UIフレームワークを活用するためのノウハウも溜まってきていました。

一方、Android界隈でも Jetpack Compose が stable release され、クックパッドAndroidの開発にも Jetpack Compose が既に利用され始めていました。

SwiftUI でのノウハウを踏まえて、Jetpack Compose を活用することで実装コード量を減らすことや、プレビューを用いてビルドサイクルを短くすることで、素早く開発を進められるのではないかと考えました。

方針

これらの状況を踏まえて、クックパッドAndroidの買い物機能では Jetpack Compose を利用することで

  • マートAndroidの既存リソースを再利用できる
  • クックパッドiOSの買い物機能で培った宣言的UIフレームワーク活用の知見を利用できる

というメリットを活かす形で開発を始めました。

Jetpack Compose にはナビゲーションを行うためのコンポーネントなどもありますが、それらは使わずに素朴なUIコンポーネントのみをView部分で利用し、画面遷移などは既存のVIPERアーキテクチャ*2にのっかる方針をとっています。

これはクックパッドiOSの買い物機能と同様の設計方針です。VIPERアーキテクチャの View 部分にのみ SwiftUI を利用する設計を1年半続けた上で大きなハマりもなくメリットを享受できているためです。(詳しくは: SwiftUI を活用した「レシピ」×「買い物」の新機能開発

また、今回開発速度を重視するに当たって、画面のUIを意図的にクックパッドiOSの買い物機能ではなくマートAndroidに寄せている部分があります。

マートAndroidはほぼ全ての画面で RecyclerView を利用しており、各アイテムは Layout XML を用いた従来の View で実装されています。(詳しくは: クックパッドマートAndroidアプリの画面実装を最高にした話【連載:クックパッドマート開発の裏側 vol.4】

この特徴を生かして、Jetpack Compose における相互運用 API として用意されているAndroidView/AndroidViewBinding*3を用いることで、マートAndroid で画面を構成している Layout XML をそのままクックパッドAndroidで利用することで開発速度の向上を図っています。

実装について

Jetpack Compose版VIPERベースのアーキテクチャ

Jetpack Compose版アーキテクチャに関して、2020年のクックパッドAndroidアプリのアーキテクチャ事情 から一部変更している箇所があるので、変更点について紹介します。

アーキテクチャの図
Jetpack Compose版アーキテクチャ

Presentationレイヤーについて

2020年のクックパッドAndroidアプリのアーキテクチャ事情 ではInteractor や Routing との連係は Presenter が行っており、ViewModel は View 実装の一部という扱いで Contract には処理を記載していませんでした。Jetpack Compose版アーキテクチャでは Presenter は廃止され、Interactor や Routing との連係は新たにContract に記載された ViewModel(実態は AAC の ViewModel)が行うようになっています。

データフローについて

2020年のクックパッドAndroidアプリのアーキテクチャ事情 では Rx を用いてレイヤー間の連係を行っていましたが、Jetpack Compose 版アーキテクチャでは Kotlin Coroutines を使用しています。

基盤実装としての ApiClient は Rx ベースで実装されているため、DataStore で kotlinx.coroutines.rx2.await を用いて Rx の Single を suspend 関数に変換しています

import kotlinx.coroutines.rx2.await
import javax.inject.Inject

class ApiProductsDataStore @Inject constructor(
    private val apiClient: ApiClient
) : ProductsDataStore {
    override suspend fun getProducts(): List<Product> {
        return apiClient.get(path = "/products")
            .await()
            .decodeJSONArray()
    }
}

UI層でのみJetpack Composeを活用する実装に関して

上述のようなアーキテクチャの差分を踏まえた上で、ViewとViewModelの連係について紹介します。

View層の概略図
Jetpack Compose を組み込んだVIPER View 層の概略図

Activity/Fragment が ComposeView として Screen-suffix の Composable 関数を持つような形をとっています。 Screen-suffix の Composable 関数には ViewModel を渡しており、Composable 関数からのクリックイベントなどは ViewModel の関数を呼び出すようにしています。また画面表示のために、ViewModel は StateFlow を公開しており、StateFlow の状態変化に応じて画面を更新できるようにしています(詳しくは後述)。

UILayerの実装に関して

クックパッドAndroidでは Activity/Fragment を用いた View ベースの設計になっています。そのため、Jetpack Composeの相互運用 API として用意されている setContent 関数を呼び出して、Compose ベースの UI を追加しています。

Activity

class ProductListActivity : RoboAppCompatActivity() {

    private val viewModel: ProductListCreateViewModel
        by lazy { ViewModelProvider(this).get(ProductListCreateViewModel::class.java) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ProductListScreen(viewModel)
        }
    }
}

Fragment

@AndroidEntryPoint
class ProductListFragment : Fragment() {
    @Inject
    lateinit var viewModelFactory: ViewModelFactoryProvider<ProductListViewModel>

    private val viewModel: ProductListViewModel by viewModels { viewModelFactory }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return ComposeView(requireContext()).apply {
            setContent {
                ProductListScreen(viewModel)
            }
        }
    }

    companion object {
        fun newInstance() = ProductListFragment()
    }
}

ViewModelの実装に関して

ViewModel では Interactor/Routingの連係と、UI の状態と表示するコンテンツを管理しています。 実装クラスは ViewModel (Jetpack) が担当し、ViewModel interface に基づいた画面を実装します。 MutableStateFlow によって画面の状態を管理しますが、状態の変更は内部で行い外部には StateFlow を公開しています。

class ProductListViewModel @Inject constructor(
    private val interactor: ProductListContract.Interactor,
    private val routing: ProductListContract.Routing
) : ViewModel(), ProductListContract.ViewModel {

    private var _state = MutableStateFlow<ScreenState>(ScreenState.Loading)

    override val state = _state.asStateFlow()

    init {
        fetchProducts()
    }

    override fun onProductDetailPageRequested(productId: Long) {
        routing.navigateProductDetail(productId)
    }

    private fun fetchProducts() = viewModelScope.launch {
        runCatching {
            interactor.fetchProducts()
        }
        .fold(
            onSuccess = {
                _state.value = ScreenState.Idle(
                    ProductListContract.ViewModel.ScreenContent(
                        products = it
                    ) 
                )
            },
            onFailure = {
                _state.value = ScreenState.Error(
                    reason = it.errorStatus.reason,
                    reloadAction = ::reload
                )
            }
        )
    }
}

Screenの実装に関して

Activity/Fragment が ComposeView として表示している Composable 関数を Screen という命名にしています。

@Composable
fun ProductListScreen(
    viewModel: ProductListContract.ViewModel
) {
    val state = viewModel.state.collectAsState()
    AsyncLoadSurface(state = state.value) { content: ProductListContract.ViewModel.ScreenContent ->
        ProductListScreenContent(
            products = screenState.products,
            onTapProduct = viewModel::onProductDetailPageRequested
        )
    }
}
 
@Composable
private fun ProductListScreenContent(
    products: List<Product>,
    onTapProduct: (Long) -> Unit
) {
    LazyColumn {
        item {
            HeaderSection()
        }
        items(products) { product ->
            ProductSection(
                product = product,
                onTapProduct = onTapProduct
            )
        }
        item {
            FooterSection()
        }
    }
}

@Composable
private fun ProductSection(product: Product, onTapProduct: (Long) -> Unit) {
    AndroidViewBinding(
        factory = ViewProductSectionBinding::inflate,
        modifier = Modifier
            .background(CookpadColor.Ivory)
            .padding(horizontal = 20.dp)
    ) {
        textView.text = product.name
        button.setOnClickListener {
            onTapProduct(product.id)
        }
    }
}

買い物機能は基本縦スクロールの画面構成となるため、画面実装は LazyColumn をベースにした画面実装にしています。 意味のあるまとまりで画面を要素分割し、LazyColumn の item に要素ごとのまとまりを入れています。この item の各要素をSection という単位で分割しています。

画面をSectionで分割しているスクリーンショット
LazyColumnのitem単位の分割

Section の中で実装している画面は、既存リソースがある場合は AndroidView/AndroidViewBinding を用いて再利用を行い、新規で実装が必要な部分に関しては Jetpack Compose を利用してレイアウトを作成しています。

実際どうだったのか

うまくいった点

  • Jetpack Compose ベースのUI構築をしたことで、RecyclerView を用いる際の Adapter/ViewHolder を書く必要がなくなり、記述量が削減されることで開発速度の向上に繋がった
  • LazyColumn の中身を Section という単位で分割する指針を早いうちに決められた
    • これによって、AndroidView/AndroidViewBinding を用いて Layout XML や Android View などの既存リソースを使う場合も、RecyclerView を利用する際のリストアイテムを苦労なくJetpack Composeベースのレイアウトに組み込むことができた
  • SwiftUI でチーム開発している時に課題となったコンポーネントの粒度について早めに共通認識をつくることで、読みやすく保守しやすい状態を維持して開発できた*4

うまくいかなかった点

  • Jetpack Compose に慣れてない段階では、既存の Layout XML をコピーして一部を変更する方が速かったので、AndroidViewBinding を活用してレイアウトできることがメリットになっていた
    • しかし、Jetpack Compose に慣れてくると、該当画面を探してコピーしてくるより、一から Jetpack Compose で書く方が速くなり、恩恵を受けていたのは最初のうちだけだった

まとめ

買い物機能を最速でリリースするために、利用可能な既存実装の再利用や Jetpack Compose を用いて、速度向上を図っている事例を紹介しました。買い物機能は2022年頭にファーストリリースを目指して絶賛開発中です。今後も開発状況を発信していきたいと思っていますので、ぜひ応援よろしくお願いします。 リリースされたらぜひ使ってみてくださいね!

クックパッドでは一緒に働く仲間を募集しています!

今回は買い物機能の開発にあたっての工夫や Jetpack Compose の活用事例についてご紹介しました。

新しいフレームワークを活用して、高速にサービス開発を進めることで事業をドライブしたいエンジニアを大募集しています。

カジュアル面談や学生インターンシップなども随時実施していますので、ぜひお気軽にご連絡ください。

info.cookpad.com

*1:近隣地域の生産者や市場直送の新鮮でおいしい食材を、1品から送料無料で購入できる。https://info.cookpad.com/pr/news/press_2020_1015

*2:クックパッドAndroidアプリはVIPERアーキテクチャを用いて開発されています。 詳しくは 2020年のクックパッドAndroidアプリのアーキテクチャ事情 を参照ください。

*3:AndroidView/AndroidViewBindingはJetpack Composeの相互運用 API として用意されているもので、AndroidView や XML Layout を Compose UI のレイアウト階層に含めることができる。

*4:コンポーネント粒度に関して共通認識をつくるための取り組みに関して チームでSwiftUIを書くために ~読みやすく保守しやすい設計について考えたこと~ にまとめていますので、こちらも参照ください。

DroidKaigi 2021 において、「2020年代の WebView 実装」というタイトルで発表しました

こんにちは。モバイル基盤部のこやまカニ大好きです。

先日行われた DroidKaigi 2021 で「2020年代の WebView 実装」というタイトルで発表させていただいたので、今日はその発表を簡単にまとめようと思います。 当日聴いて頂いた参加者の方、本当にありがとうございました。 流れていくコメントを眺めていると他社でも WebView 増殖はやっぱり発生するんだなあという気持ちが沸き起こり、胸が熱くなりました。

現時点で話せる内容は大体話せたと思うので、この記事では DroidKaigi公式の動画と発表資料の共有、当日うまく答えられなかった質問への回答だけ記載しようと思います。 最高の WebView を作る作業はまだ継続中ですので、完全版 WebView に関しては完成し次第別記事でお知らせいたします。

動画

https://www.youtube.com/watch?v=IOnpHyOg5sc

資料

https://speakerdeck.com/nein37/saikou-no-webview-2021

補足

動画(とコメント)を見直していて、なぜそこまで WebView が必要なのかという部分についてうまく説明できていなかったと思ったので少し補足します。

クックパッドアプリの WebView の用途は大きく分けて3つ存在します。

  1. 新規登録などの一部の特殊な画面
    • 新規登録画面は過去ネイティブで実装されていましたが、2019年に Web ページ側の新規登録フローを改善した際にアプリでも WebView から新規登録するように変更しました
    • この部分の WebView は役割が非常にはっきりしていて分かり易く、クックパッドアプリの WebView の中でもかなり良い使い方だと思います
  2. 課金導線(LP)の表示
    • LPはキャンペーン施策によって画面表示を一斉に、大きく切り替えたい場合があります
    • 利用規約、プライバシーポリシーの変更や無料期間の修正などは即時切り替える必要があります
  3. APIが存在しない機能の表示
    • 古いサービスなのでそういうページもあります…
    • これはリソースを割けばネイティブなUIに置き換えられますが、リソース配分など様々な理由で現在でも WebView として提供しています

このうち、 1. の用途についてはこれまでほとんど問題なく動作していました。 もともとここだけ Kotlin + VIPER で実装されていたという事情もありますが、ネイティブ実装された画面に遷移しづらい新規登録画面ということも大きいと思います。 この画面は WebView でかなりうまく動いているので、これからも WebView のままになると思います(10年間 WebView のままかどうかはわかりませんが)

2. の課金導線ページ表示はかなり複雑です。 アプリ内の様々な箇所から課金導線ページへの遷移があり、課金導線ページ内からもレシピページなどネイティブ実装された画面への遷移が存在します。 課金導線ページはコンテンツの表示をサーバサイドで細かくコントロールしたい事情があるので、これからも WebView で実装され続けることでしょう。 この課金導線ページの表示をできるだけ簡単に実装できるようにするのがクックパッドアプリにおける最高の WebView への道だと考えています。

3. が存在する理由は単純で、 WebView でしか表示できないコンテンツがあるため WebView を使って表示しています。 このパターンのWebページからはネイティブ実装されたレシピ詳細画面や検索結果画面への遷移が頻繁に発生するため、最も複雑な実装になっています。 今回の発表にあった Routing に実装したネイティブ画面への遷移処理のほとんどは、このパターンのWebページの表示のために必要になった実装です。 WebView 以外の解決方法としてネイティブ画面で再実装することもできますが、アプリ開発に使えるリソースは有限です。 アプリが大きくなるにつれて、大きくコストを割けない機能というものはどうしても出てきます。 そういったコンテンツの表示をサポートし、ユーザーに価値を届けられる仕組みとして WebView は必要だと考えています。

クックパッドアプリの実装では 3. 用途の WebView に若干 2. の機能が混ざったりしているので、今後の改修でどんどんシンプルで使いやすい WebView にしていく予定です。

最後に

発表の中でもお伝えしましたが、クックパッドではAndroidエンジニアを募集しています!

  • WebView 大好きなエンジニア
    • 一緒に最高のWebViewにしましょう!!!
  • WebView が好きじゃないエンジニア
    • 社内のほとんどのモバイルアプリエンジニアはそうなので大丈夫です!
  • WebView にできれば触りたくないエンジニア
    • こやまカニ大好き以外のメンバーは今ほとんど WebView の実装には触ってないので大丈夫です!

上記に該当しない方でも募集中なので気軽にご応募下さい

https://info.cookpad.com/careers/

RecBole を用いてクックパッドマートのデータに対する50以上のレコメンドモデルの実験をしてみた

こんにちは。研究開発部の深澤(@fufufukakaka)です。

本記事では最近面白いなと思って watch しているレコメンド系のプロジェクト RecBole を紹介いたします。また、クックパッドが展開している事業の一つであるクックパッドマートのデータを使って数多くのレコメンドモデルを試す実験も行いました。その結果も合わせて紹介します。

TL;DR:

  • レコメンドモデルは作者実装に安定性がなく、またモデルをどのように評価したかも基準がバラバラで、再現性が難しいとされている(from RecSys 2019 Best Paper)
  • 再現性に取り組むプロジェクトとして 2020年12月に始まった RecBole がある。 RecBole を利用することでなんと 50個以上のレコメンドモデルを大体1コマンドで試せる
  • クックパッドマートでユーザに対してアイテムをレコメンドするシチュエーションを想定し実験を行った。その結果、テストデータの6000ユーザに対して2000ユーザ(三分の一)に正しい推薦を行うことができるモデルを発見できた

正しく強いレコメンドモデルを探すのは難しい

サービスの中で機械学習といえばレコメンド、といわれる機会は非常に多いかと思います。が、レコメンドは機械学習の中ではかなり特殊な問題設定です。クラス分類したり回帰したり、と様々な解き方をすることができるタスクなのがその要因です。「ユーザがこのアイテムを買ってくれる確率」を推定しても良いし「ユーザが好きなアイテムのランキング」を予測しても良い。そもそもアイテムの数が数万種類くらいある中で「このユーザはこれを買ってくれそう」を予測するのは非常に難しいです。つまり、レコメンド系は実社会サービスでの需要が高く非常に難しい問題、と言えます。

おかげでたくさんの研究が日々発表されています。それ自体は素晴らしいことです。RecSys というレコメンドのみを取り扱う国際会議も存在しています。最近ではDeep Learning を活用した研究が殆どを占めています。

ですが、この日々公開されている研究には再現性がないことが指摘されています。2019の RecSys ベストペーパーは「Are We Really Making Much Progress? A Worrying Analysis of Recent Neural Recommendation Approaches」でした。「本当にニューラルネットワーク系の手法で精度は上がっているのか?」というこの論文では衝撃の事実が明かされており

  • トップ会議(KDD, SIGIR, WWW, RecSys)のDNN関連研究18本を追試した
    • 18本のうち、現実的な努力を行った上で再現できたのが7本(半分以下!)
      • (RecSysでの発表によると、)実装が再現できない場合は、実装を原著者らに問い合わせて1ヶ月待った
    • 再現できたとしても 6/7がkNNベース(シンプルなモデル)+ハイパーパラメータ最適化に負けてしまった
    • 残りの1つもDNNではない線形の手法を調整したものに負ける場合もあった

(refs. https://qiita.com/smochi/items/98dbd9429c15898c5dc7 )

国際会議で「state-of-the-artだ」と主張している論文の殆どが、実際には10年以上前から存在しているシンプルな手法に負けてしまう、というサーベイ結果が出ており、非常に面白い論文でした。

この論文が示したように、レコメンドの研究は数多く発表されているものの殆どの実装に再現性がなく、また正しい比較ができていない、というのが現状です。当然ですが、論文内で提示されている GitHub リポジトリの実装は人によってまちまちです。再現実装は再利用が可能なものから環境構築自体が困難なものまで色々あります。前提としてコードを上げてくれることは非常にありがたいのですが、それぞれの手法で土台を揃えた実験を行うのはそもそもが難しい状況です。

RecBoleについて

RecBole は中国人民大学・北京大学の研究室が共同で始めたプロジェクトのようで、去年の11月に arxiv に登場しました。今年の8月に提供しているモジュールがv1を迎えて、本格的に色々な人が利用するようになったようです。

RecBole 最大の魅力は、上述してきた再現性の難しいレコメンドモデルを統一したインタフェースで実装し、比較を容易にしているところにあります。そして実装されているモデル、適用できるデータセットの数が凄まじいです。モデルは現時点で70以上(モデルリストがすごい )、データセットは20以上のものについて即座に試せます。どれくらい即座に試せるかと言うと

pip install recbole
python run_recbole.py --model=<your favorite model> --dataset_name ml-100k

これだけで、レコメンド界隈の中で最も有名なベンチマークである MovieLens-100k データセットに対して70以上のモデルを即座に(追加の設定が必要なやつもありますが)試せます。これだけのモデル・データを試すことができる環境はそうないと思われます。また70以上の収録されているモデルたちは全て PyTorch ベースで丁寧に再実装が行われており信頼性は非常に高いです。predict関数などの基本的なインタフェースは統一されており、実験のし易い環境が整えられています。

RecBole を自分たちのデータで使えるようにする

実際に RecBole を使えるようにするためにはどうするばよいのか、について簡単にまとめてみました。

  1. ユーザとアイテムのアクション履歴をまとめたデータを用意する
  2. データをコントロールするクラスを用意する
  3. 配布されているスクリプトを使ってデータを RecBole が読める形式に変換する
  4. 学習に必要な設定ファイルを用意する
  5. 学習スクリプトを走らせる

1. ユーザとアイテムのアクション履歴をまとめたデータを用意する

自分の使いたいデータを持ってきて、以下のようなファイルで保存しておきます。

今回はクックパッドマートを対象としてデータを作りました。interact.csv ではあるユーザがあるアイテムを購入したログが表現されています。MovieLens のような Rating (Explicit Feedback)のついていない Implicit Feedback なデータセットです。

なお、ここで紹介している user_id, item_id などはいずれもダミーとなっています。

interact.csv

user_id,item_id,timestamp(Unix timestamp)
1,1,1630461974
2,2,1630462246
3,2,1630462432

items.csv

item_id,item_name,item_category_id
1,豚バラ,9
2,にんじん,7

users.csv

user_id,feature1,feature2
1,286,130
2,491,3
3,342,32

2. データをコントロールするクラスを用意する

続いて、RecBole 内でこれらのデータを扱うためのクラスを用意します。基本的には BaseDataset と同じインタフェースを用意して、その内部をデータに合わせて調整するような作業になります。

import os

import pandas as pd
from src.dataset.base_dataset import BaseDataset  # https://github.com/RUCAIBox/RecSysDatasets/blob/master/conversion_tools/src/base_dataset.py をコピーして所定の場所に配置しておく

class CookpadMartDataset(BaseDataset):
    def __init__(self, input_path, output_path):
        super(CookpadMartDataset, self).__init__(input_path, output_path)
        self.dataset_name = "ckpd_mart"

        # input_path
        self.interact_file = os.path.join(self.input_path, "interact.csv")
        self.item_file = os.path.join(self.input_path, "items.csv")
        self.user_file = os.path.join(self.input_path, "users.csv")

        self.sep = ","

        # output_path
        output_files = self.get_output_files()
        self.output_interact_file = output_files[0]
        self.output_item_file = output_files[1]
        self.output_user_file = output_files[2]

        # selected feature fields
        # 型について -> https://recbole.io/docs/user_guide/data/atomic_files.html#format
        self.interact_fields = {
            0: "user_id:token",
            1: "item_id:token",
            2: "timestamp:float",
        }

        self.item_fields = {
            0: "item_id:token",
            1: "item_name:token",
            2: "item_category_id:token"
        }

        self.user_fields = {
            0: "user_id:token",
            1: "feature1:token",
            2: "feature2:token",
        }

    def load_inter_data(self):
        return pd.read_csv(self.interact_file, delimiter=self.sep, engine="python")

    def load_item_data(self):
        return pd.read_csv(self.item_file, delimiter=self.sep, engine="python")

    def load_user_data(self):
        return pd.read_csv(self.user_file, delimiter=self.sep, engine="python")

3. 配布されているスクリプトを使ってデータを RecBole が読める形式に変換する

https://github.com/RUCAIBox/RecSysDatasets/blob/master/conversion_tools/run.py

ここで公開されているスクリプトを使って RecBole 内で利用できる形式の Atomic Files に変換します。 refs

python src/dataset/convert.py --dataset ckpd_mart \
--input_path data/mart_data --output_path dataset/ckpd_mart \
--convert_inter --convert_item --convert_user

すると ckpd_mart.inter ckpd_mart.item ckpd_mart.user というファイルが所定の場所に配備されます。これでデータの準備は完了です。

4.学習に必要な設定ファイルを用意する

https://recbole.io/docs/user_guide/config_settings.html

RecBole が用意してくれている config 設定を読みながら自分のデータに合わせた設定ファイルを書いていきます。

# general
gpu_id: 0
use_gpu: False  # GPUを使う時はTRUEにする
seed: 2020
state: INFO
reproducibility: True
data_path: 'dataset/'  # 使うデータが格納されている場所
checkpoint_dir: 'saved/'  # モデル保存先
show_progress: True
save_dataset: False  # True にすればtrain, valid, test で使ったデータを保存してくれる
save_dataloaders: False

# Atomic File Format
field_separator: "\t"
seq_separator: "@" # 文字列があった場合この文字で区切られる。特徴量読み込み時にバグってしまう可能性があるため、できるだけデータを事前に処理しておき絶対に出現しない保障が取れている記号を書くべき(日本語の場合)

# Common Features
USER_ID_FIELD: user_id
ITEM_ID_FIELD: item_id
RATING_FIELD: ~  # implicit feedback の場合
TIME_FIELD: timestamp

# Selectively Loading
# 使うデータだけを選んで loadします
load_col:
    inter: [user_id, item_id, timestamp]
    user: [user_id, feature1, feature2]
    item: [item_id, item_name, item_category_id]
unused_col:  # データとしては読み込むけど学習には使いたくないカラムはここで指定する
    inter: [timestamp]

# Training and evaluation config
epochs: 50
stopping_step: 10  # 10 step valid_metric が改善しない場合は止める
train_batch_size: 4096
eval_batch_size: 4096
neg_sampling:  # implicit feedbackなデータを扱っていて positive,negative両方のラベルが必要な手法を試す際に、negative samplingすることでデータを用意できる
    uniform: 1
eval_args:
    group_by: user  # user 単位でアイテムを集約して評価に使う。基本的にこれ以外使うことはない
    order: TO  # Temporal Order。時系列順で train, valid, test を分けてくれる
    split: {'RS': [0.8,0.1,0.1]}  # 80%, 10%, 10% で分けてくれる
    mode: full
metrics: ['Recall', 'MRR', 'NDCG', 'Hit', 'Precision']
topk: 10
valid_metric: MRR@10  # この指標をtrackする
metric_decimal_place: 4

5. 学習スクリプトを実行する

おまたせしました。あとは実験をするだけです。

モデルによって与えるパラメータが微妙に違ったりするので、そこを吸収する以下のようなスクリプト(run_experiment.py)を用意して

import click
from recbole.quick_start import run_recbole

@click.command()
@click.option(
    "-m",
    "--model_name",
    required=True,
    type=str,
    help="Model Name(see recbole's model list)",
)
@click.option(
    "-d",
    "--dataset_name",
    required=True,
    type=str,
    help="Dataset Name(your custom dataset name or recbole's dataset name)",
)
@click.option(
    "-c",
    "--config_file_list",
    required=True,
    nargs=-1,
    help="config file path",
)
def main(model_name, dataset_name, config_file_list):
    if model_name in [
        "MultiVAE",
        "MultiDAE",
        "MacridVAE",
        "RecVAE",
        "GRU4Rec",
        "NARM",
        "STAMP",
        "NextItNet",
        "TransRec",
        "SASRec",
        "BERT4Rec",
        "SRGNN",
        "GCSAN",
        "GRU4RecF",
        "FOSSIL",
        "SHAN",
        "RepeatNet",
        "HRM",
        "NPE",
    ]:
        # これらは non-sampling method
        # https://recbole.io/docs/user_guide/model/general/macridvae.html などを参照
        parameter_dict = {
            "neg_sampling": None,
        }
        run_recbole(
            model=model_name,
            dataset=dataset_name,
            config_file_list=config_file_list,
            config_dict=parameter_dict,
        )
    else:
        run_recbole(
            model=model_name, dataset=dataset_name, config_file_list=config_file_list
        )

if __name__ == "__main__":
    main()

あとは python run_experiment.py --dataset_name ckpd_mart --model_name <your favorite model> --config_files config/ckpd_mart.yml するだけです。お疲れさまでした。

RecBole を試してみた結果

ここまで頑張って用意した土台を使って、早速 RecBole に収録されているモデルをクックパッドマートの購入履歴データ(2021年9月~10月)で試してみました。先程のスクリプトの引数を変えるだけで次々と実験を行うことができます。追加の設定ファイルが必要なものを除いて、50前後のレコメンドモデルを実験することができました。

それでは以下に結果の表を示します。モデル名と各指標、タイプ(行動データしか使わないgeneral・別の情報を使うcontext-aware、時間情報を用いるsequential)、論文名が一覧になっています。

モデル名 recall@10 mrr@10 ndcg@10 hit@10 precision@10 タイプ 論文名
RecVAE 0.2754 0.2626 0.2474 0.314 0.0367 general RecVAE: A New Variational Autoencoder for Top-N Recommendations with Implicit Feedback
MacridVAE 0.2651 0.2488 0.2364 0.303 0.0347 general MACRo-mIcro Disentangled Variational Auto-Encoder
NAIS 0.2324 0.2452 0.2244 0.2698 0.0325 general Neural Attentive Item Similarity Model for Recommendation
NNCF 0.2248 0.1755 0.1767 0.2567 0.0282 general A Neural Collaborative Filtering Model with Interaction-based Neighborhood
RepeatNet 0.2725 0.1468 0.1766 0.2725 0.0272 sequential RepeatNet: A Repeat Aware Neural Recommendation Machine for Session-based Recommendation.
NeuMF 0.2344 0.1638 0.1699 0.268 0.0304 general Neural Collaborative Filtering
LINE 0.1859 0.1556 0.1529 0.2156 0.0237 general LINE: Large-scale Information Network Embedding
BPR 0.1789 0.1455 0.1442 0.2088 0.0223 general BPR Bayesian Personalized Ranking from Implicit Feedback
SHAN 0.1738 0.1189 0.132 0.1738 0.0174 sequential SHAN: Sequential Recommender System based on Hierarchical Attention Network.
Item2vec 0.121 0.1183 0.112 0.1372 0.0148 general Item 2 Vec-based Approach to a Recommender System
DGCF 0.1703 0.0965 0.1099 0.1931 0.0201 general Disentangled Graph Collaborative Filtering
FFM 0.187 0.0922 0.1096 0.2099 0.0225 context-aware Field-aware Factorization Machines for CTR Prediction
FPMC 0.151 0.0935 0.107 0.151 0.0151 sequential Factorizing personalized Markov chains for next-basket recommendation
NARM 0.1664 0.0847 0.1039 0.1664 0.0166 sequential Neural Attentive Session-based Recommendation
LightGCN 0.1549 0.0794 0.0952 0.1715 0.0174 general LightGCN: Simplifying and Powering Graph Convolution Network for Recommendation
NGCF 0.126 0.0823 0.089 0.1416 0.0148 general Neural Graph Collaborative Filtering
SASRec 0.1142 0.0657 0.0771 0.1142 0.0114 sequential Self-Attentive Sequential Recommendation
HRM 0.0992 0.0684 0.0756 0.0992 0.0099 sequential HRM: Learning Hierarchical Representation Model for Next Basket Recommendation.
EASE 0.1205 0.0752 0.0751 0.1559 0.0204 general Embarrassingly Shallow Autoencoders for Sparse Data
MultiVAE 0.1113 0.0681 0.0751 0.1245 0.0126 general Variational Autoencoders for Collaborative Filtering
NPE 0.123 0.0597 0.0744 0.123 0.0123 sequential NPE: Neural Personalized Embedding for Collaborative Filtering
MultiDAE 0.1011 0.0596 0.0671 0.1127 0.0114 general Variational Autoencoders for Collaborative Filtering
SRGNN 0.1115 0.0515 0.0654 0.1115 0.0112 sequential Session-based Recommendation with Graph Neural Networks
ENMF 0.1075 0.0545 0.0629 0.1261 0.0131 general Efficient Neural Matrix Factorization without Sampling for Recommendation
DCN 0.1085 0.0508 0.06 0.1255 0.013 general Deep & Cross Network for Ad Click Predictions
FOSSIL 0.087 0.0481 0.0572 0.087 0.0087 sequential FOSSIL: Fusing Similarity Models with Markov Chains for Sparse Sequential Recommendation.
ItemKNN 0.0649 0.0666 0.0534 0.094 0.0126 general Item-based top-N recommendation algorithms
DeepFM 0.0873 0.0347 0.0442 0.1029 0.0106 context-aware DeepFM: A Factorization-Machine based Neural Network for CTR Prediction
PNN 0.0851 0.0353 0.0441 0.0994 0.0102 context-aware Product-based neural networks for user response prediction
FM 0.0817 0.0325 0.0412 0.0961 0.0098 context-aware Factorization Machines
BERT4Rec 0.0685 0.0303 0.0391 0.0685 0.0069 sequential BERT4Rec: Sequential Recommendation with Bidirectional Encoder Representations from Transformer
xDeepFM 0.0743 0.0281 0.0371 0.0858 0.0089 context-aware xDeepFM: Combining Explicit and Implicit Feature Interactions for Recommender Systems
NFM 0.0736 0.0288 0.0369 0.0867 0.0088 context-aware Neural Factorization Machines for Sparse Predictive Analytics
AutoInt 0.0741 0.0275 0.0362 0.0872 0.0089 context-aware AutoInt: Automatic Feature Interaction Learning via Self-Attentive Neural Networks
AFM 0.0718 0.0284 0.0361 0.0855 0.0086 context-aware Attentional Factorization Machines: Learning the Weight of Feature Interactions via Attention Networks
FNN 0.0703 0.0274 0.0349 0.0823 0.0083 context-aware Deep Learning over Multi-field Categorical Data
GRU4Rec 0.0682 0.0247 0.0348 0.0682 0.0068 sequential Improved Recurrent Neural Networks for Session-based Recommendations
SpectralCF 0.0745 0.0238 0.0343 0.0876 0.0089 general Spectral collaborative filtering
WideDeep 0.0704 0.0261 0.0342 0.0837 0.0085 context-aware Wide & Deep Learning for Recommender Systems
GCMC 0.0765 0.0229 0.0341 0.0891 0.009 general Graph Convolutional Matrix Completion
DMF 0.0633 0.0276 0.034 0.0767 0.0078 general Deep Matrix Factorization Models for Recommender Systems
FwFM 0.0703 0.0217 0.0315 0.0823 0.0084 context-aware Field-weighted Factorization Machines for Click-Through Rate Prediction in Display Advertising
STAMP 0.0607 0.0208 0.03 0.0607 0.0061 sequential STAMP: Short-Term Attention/Memory Priority Model for Session-based Recommendation
DSSM 0.0582 0.0217 0.0287 0.0693 0.007 context-aware Learning deep structured semantic models for web search using clickthrough data
SLIMElastic 0.0495 0.0226 0.0263 0.0646 0.007 general SLIM: Sparse Linear Methods for Top-N Recommender Systems
LR 0.0528 0.0167 0.0231 0.064 0.0065 context-aware Predicting Clicks Estimating the Click-Through Rate for New Ads
Pop 0.0474 0.0136 0.0201 0.0564 0.0057 general なし
CDAE 0.0026 0.0007 0.001 0.0033 0.0003 general Collaborative Denoising Auto-Encoders for Top-N Recommender Systems

各モデルについて、テストデータに対する以下の指標を掲載しました。 @10 は 10個レコメンドを表出した、という意味です。

  • recall ... ユーザが実際に嗜好したアイテムのうち、レコメンドリストでどれくらいカバーできたかの割合
  • precision ... レコメンドリストにあるアイテムのうち、ユーザが嗜好したアイテム(適合アイテム)の割合
  • hits ... 正解のアイテムを一つ以上含むレコメンドリストを作成できた割合
  • mrr ... mean reciprocal rank。レコメンドリストを上位から見て、最初にヒットしたアイテムの順位を逆数にしたものをスコアとする。それを平均したもの。
  • ndcg ... DCG: アイテムをおすすめ順に並べた際の実際のスコアの合計値 を正規化(normalize)したもの

また、いくつかの古典的なモデルを太文字にしています。

  • Pop ... popularity。人気のアイテムを表出する
  • ItemKNN ... アイテム間の類似度を行動履歴から簡単な計算で定義して(コサイン類似度)、それを使って「あるユーザが過去見ていたアイテムに近いアイテムを出す」というもの。2000年代くらいから。
  • BPR ... Bayesian Personalized Ranking 2009年の手法。行列分解をベイズ的なアプローチで解いてランキングを導出する。

今回試したモデルの全てがこれら3つの手法よりも後に発表され、Deep Learningを使い倒すためにGPUを何枚も用意して実績を積んでいます。当然全てのモデルが上回ってほしいところなのですが... 2019 RecSys ベストペーパーで報告された内容とほぼ同じく、古典的な手法(ItemKNNとBPR)は相当強かったです。

さて、他にもこの表からわかることがいくつかあるのでまとめてみました。

  • general(ユーザとアイテムのアクション履歴のみ使う)なモデルに対して、context-aware(ユーザとアイテムのside infomationも使う)・sequential(どの順番で購入したかの順序情報を使う)モデルは総じて低い結果となりました(付加情報を駆使しているのに...)。
  • RecVAEが圧倒的に強かった。これはユーザとアイテムのヒストリーを行列にした上で、 Variational Auto-Encoder というニューラルネットワークで圧縮・復元の学習を行い、ユーザとアイテムのヒストリー行列を正確に復元できるように学習したモデル(+いくつか工夫あり)です。
    • わかりやすい指標である hits@10 を題材にすると、一番良かった RecVAE が 0.3(30%は正解を含んだレコメンドリストを表出できる)だったのに対して、一番下の CDAE は 0.003(0.3%しか正解を含んだレコメンドができない)というのはかなり差が大きいと感じました
    • なおこの RecVAE の数字は、非常に優秀な数字です
  • タスクやデータの難易度に依存するものの、機械学習に取り組み上でモデル変更のみで20ポイント以上指標に差が開くことをみることはあまり多くはない
  • 推薦において、ユーザとアイテムのアクション履歴から情報を引き出すというタスクが、モデルによって得意不得意がはっきり分かれているのだと思う
  • 古典手法 BPR より良かったモデルはわずか 7モデル (50弱のモデルを実験して)

さて、50弱のモデルを実験するのにかかった時間は1日でした。本来であれば作者の参照実装を見に行って、その使い方を学んで、自分の適用したいデータセットをそれに合わせた方式に前処理して、動かそうとしてみてバグにあたって... 一つのモデルを動かすのに1日かかることのほうが多いです(むしろ1日で終わらない)。

それを非常に短い時間で網羅的に実験を行うことができる環境を得られるのは非常に良いことではないでしょうか。

各レコメンドモデルの挙動の違いについて

ではこれらの結果についてもう少し踏み込んでみましょう。以下のモデルについて様々な指標を見てみます。

  • BPR ... 古典的だが優秀な手法
  • ItemKNN ... 古典的だが優秀な手法2
  • Popularity ... 古典手法
  • Item2Vec ... 商品IDを単語、同じセッションで同時に購入された商品群をcontextとみなしてword2vecを学習するモデル → 実装
  • FFM ... Field-aware Factorization Machines。 context-aware モデル
  • RecVAE ... 今回のチャンピオンモデル

推薦リストに一つでも正解が含まれていたユーザ数

hitsを見れば大体わかりますが、グラフにしてみました。

f:id:fufufukakaka:20211102110803p:plain
(テストデータ)推薦リストに一つでも正解が含まれていたユーザ数

圧倒的に RecVAE でした。ちなみに今回のテストデータは 6000ユーザくらい。2位がBPRで古典手法でした。

過去出現したアイテムを推薦して正解している割合

レコメンドにおいて、そのユーザが過去アクションしたことがあるアイテムをどう出すか、はかなり重要です。RepeatNet というリピートに着目したモデルもあるくらい。EC系のサイトでよくあることなのですが、周期的に同じものを買っている、というのがドメインにもよりますが散見されます。マートはその例にもれず、「またあれ買って食べたい」がよく起きるサービスです。ということで、これをできるだけ取りこぼさずに推薦できると非常に良いだろうと推察できます。

ここでは割合を表示します。(過去出現したアイテムを推薦して正解している数)/(推薦が成功した数)

f:id:fufufukakaka:20211102110951p:plain
過去出現したアイテムを推薦して正解している割合

ここで面白いのは、RecVAE・BPRなど上位モデルの値がほとんど同じで90%以上であることです。RecVAEはたくさん推薦を成功させていますが、過去出現したことのあるアイテムを着実に当てて正解数を伸ばしていたということですね。成績の良かったモデルは取りこぼしが少なかった、と言えるかもしれません。

過去出現していないアイテムを表出して正解しているユーザ数

今度は反対に、そのユーザが一度もアクションしたことがないアイテムを表出して、しかもそれが正解だった、という数を見てみます。レコメンドに求められている新規機会創出という役割をまさに表している性能値だとも言えます。

割合にするとさっきと逆のグラフになるので、ここでは絶対数を見てみます。

f:id:fufufukakaka:20211102111012p:plain
過去出現していないアイテムを表出して正解しているユーザ数

200程度、と大分規模は小さくなりましたが相変わらず RecVAE は上位にいます。リピートも見逃さないし、いきなり今まで買ったことがないアイテムを買った、という人に対しても他のモデルよりは良い精度を出せています。

対してBPRはRecVAEの半分程度となっており、ここで差が開いたように思えます。

また、Item2vec は先程のリピートアイテムで推薦を成功した割合を見ると90%以上となっていました。ここでのグラフの数値を見る限り、ほとんどがリピートアイテムを当てることに特化していたようです。

レコメンドのバリエーション

次に、各レコメンドのバリエーション(coverage)を見てみます。バリエーションというのは、全アイテムを分母として、そのモデルが推薦したアイテムのユニーク数を分子とした時の値を指しています。要するに、同じ人気のアイテムばかり推薦していたら低くなります。

f:id:fufufukakaka:20211102111045p:plain
レコメンドモデルが表出するアイテムのバリエーション

  • popularityが一番低いのは、毎回同じアイテムしか出さないため

  • FFM(context-aware)が低い。point-wiseな推定をするモデルであるためだと思われる

    • point-wise ... Factorization Machine系は「こういう特徴を持っているユーザはこういう特徴を持っているアイテムを買うかどうか」という0,1の学習を行い、ユーザごとのアイテム購入確率を出します。その確率をソートしてレコメンドリストを生成するのですが、確率を点推定しているだけなので、順序関係などは全く気にしません。その結果、人気のアイテムの購入確率が高まりそればっかり出てくる、ということがよくあります。
  • 一番カバレッジが高いのは ItemKNN、ついで Item2Vec・RecVAE と続きます
    • ItemKNN ・Item2vec などアイテムの類似度を利用するモデルがいずれもバリエーション豊かな推薦を行う傾向にありました
    • Deep Learning を利用するモデルは学習設定を正しくしないと over fit により出力が偏ってしまうイメージがあったのですが、RecVAE が予想に反しており驚きました

まとめ

以上、RecBole を使ってクックパッドマートでのユーザに対するアイテムレコメンドを行う設定で、内部実験を行った結果をご紹介いたしました。多種多様なレコメンドモデルを比較検討する上で非常に良い選択肢ではないかと思います。開発したレコメンドモデルに対する有用なベンチマークとなるのではないでしょうか。

今後レコメンドが必要になった際にどんなモデルを実装すればよいのかについて、今回の結果を参考にしていきたいと思います。

最後に、クックパッドでは、サービス開発や基盤開発にチャレンジする就業型インターン・そして新卒採用・中途採用を通年で受付けております。気になった方は是非ウェブサイトよりご応募ください。

/* */ @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;*/ /*}*/