iOSアプリのメモリリークを発見、改善する技術

こんにちは。事業開発部の岡村 (@iceman5499) です。 普段はクックパッドアプリ(iOS)を開発しています。

先日、アプリケーションが特定の条件で意図せぬ状態に陥り、アプリケーションが重くなって端末が発熱する、というバグが発見されました。 調査の結果、このバグはメモリリークが原因で発生していました。 この反省を踏まえメモリリークを検知するテストを導入したため、本記事ではその事例を紹介したいと思います。

(本記事ではクックパッドアプリとはiOS版の「クックパッド」アプリのことを指すものとします)

クックパッドアプリにおけるメモリリークの影響

クックパッドアプリはレシピの検索をコア機能としています。 検索は重い処理ですがAPIを通してサーバ上で行われるため、アプリは結果を表示するだけです。そのためメモリを多く必要としません。 これまでにも何度かメモリリークが発生している状況はありましたが、メモリを多く必要としないため多少の無駄があってもアプリの動作に影響がありませんでした。

クックパッドアプリで用いられているクラスの大半は自力で動くようなことはせず、RunLoop等のイベントループによって動作します。 UIKitを使用しているとインスタンスのRunLoopからの除去は自然に実現できるため、メモリリークが起こってもそのインスタンスは止まっていて、無害な状態であることが多いです。

しかしながら、今回は不運にも単独でイベントループを発生させるインスタンスがメモリリークしてしまいました。 本来はそのインスタンスがメモリから解放されたタイミングでイベントループが止まるはずでしたが、メモリから解放されなかったことにより停止されないイベントループが永遠に無駄な処理を続けていました。 その結果、そのインスタンスが多数メモリリークしてしまうとアプリケーションの動作に影響するほど負荷がかかってしまいました。

どのようにしてメモリリークが起こってしまうのか

iOSアプリケーション開発の現場で起こるメモリリークは大抵、循環参照が原因となっています。 循環参照によるメモリリークは参照カウント方式のガベージコレクション環境において発生しうる問題で、SwiftやObjective-Cを使っていると起こりうるものです。(なお、ここでは循環参照がどのような状態であるかの説明は省略します。) クックパッドアプリではRxSwiftというライブラリを多用しているため、クロージャを経由してうっかりメモリリークする形の循環参照を引き起こしてしまうケースが多いです。

よくある循環参照の例

実際にクックパッドアプリで起こっていた循環参照の実装の例を紹介します。

自身が所持するObservableのobserverとして自身が所持されるパターン

ちょっとタイトルがややこしいですが、最もシンプルなタイプのものです。

// ViewController.swift
let stream: PublishSubject<Void>
let disposeBag = DisposeBag()
func bind() {
    stream.subscribe(onNext: {
        self.doSomething() // selfがキャプチャされている
    })
    .disposed(by: disposeBag)
}

RxSwiftでは Observable を購読するとその購読がobserverオブジェクトとして Observable に保持されます。(例の PublishSubjectObservable の一種です。) この例ではobserverはさらに onNext: に渡されているクロージャを保持します。そしてさらにクロージャは self を保持しています。ここで self はこの実装を持つ ViewController クラスであるとします。 その結果、 self → stream → observer → クロージャ → self として循環参照となります。

この循環参照の解決策としては、次のように self を弱参照でキャプチャする方法が挙げられます。

stream.subscribe(onNext: { [weak self] in
    self?.doSomething()
})

暗黙クロージャ渡しパターン

次の例はどうでしょうか。これもたまにある例で、気づくことが難しい循環参照です。

// ViewController.swift
let stream: PublishSubject<Int>
let disposeBag = DisposeBag()
func bind() {
    stream.subscribe(onNext: doSomething) // この場合もselfが強参照されてしまう
        .disposed(by: disposeBag)
}

func doSomething(_ value: Int) { ... }

onNext: にクロージャを使わずに関数を渡すことで、すっきりとした表記になっています。 ところがこの doSomething 、一見関数ポインタを渡しているように見えますが、実際はコンパイラが裏側で self をキャプチャしたクロージャを生成しているため、次のコードと同じ意味になっています。

stream.subscribe(onNext: { self.doSomething($0) })

これは先程と同じパターンの循環参照となります。 対処法としては先程と同じように [weak self] を用いるのが良いでしょう。 あるいは、 doSomething の処理が self に依存していないならばそれをstatic関数にしてしまうという手もあります。(static関数になった場合その関数の実行に self が必要なくなるため、上で紹介したコンパイラが裏側でやる処理が無くなります。)

func bind() {
    stream.subscribe(onNext: Self.doSomething)
        .disposed(by: disposeBag)
}

static func doSomething(_ value: Int) { ... }

複数クラスにまたがって循環しているパターン

// Presenter.swift
class Presenter {
    let value: Observable<Int>
    init(view: View, interactor: Interactor) {
    value = interactor.stream
        .filter { view.isXXX } // ここで view を強参照でキャプチャしている
        .map { ... }
    }
}

// View.swift
class ViewController: View {
    var presenter: Presenter!
    var isXXX: Bool {
        ...
    }
    let disposeBag = DisposeBag()

    func bind(presenter: Presenter) {
        presenter.value.subscribe(...)
            .disposed(by: disposeBag)
        ...
        self.presenter = presenter
    }
}

これは複数のクラスにまたがる例です。 Presenter が生成する value には view がキャプチャされており、その川を view である ViewController クラスが購読します。 つまり ViewController からみて、 self → presenter → value → filterの内部で使われるオブジェクト → クロージャ → view(== self) として参照関係が発生し、循環参照が成立します。

self がクロージャにキャプチャされている場合はなんとなくアンテナが反応しやすいのですが、そうでないケースはうっかり見過ごされやすいです。 これの対応も同様に弱参照を用いることになります。

.filter { [weak view] _ in view?.isXXX == true }

メモリリークを検知するテストの導入

「よくある循環参照の例」を見てわかるように、循環参照はうっかり見逃しやすいため人目のレビューをすり抜けてしまいます。 またコンパイラによって検知することもできません。

そこで、XCTAssertNoLeakを使ってテストを書くことにしました。

github.com

XCTAssertNoLeakは対象のインスタンス内でメモリリークが発生しているかを検知する機能を提供するテスト用ライブラリです。 2019年のtry!Swiftで発表されたライブラリで、メモリリークを検知するテストを書くことができます。

f:id:iceman5499:20200303111804p:plain
XCTAssertNoLeak

https://github.com/tarunon/XCTAssertNoLeak のREADME.mdより

ただ引数にオブジェクトを渡すだけで、簡単にリークしているオブジェクトをリストしてくれる素敵なライブラリです。

XCTAssertNoLeakの動作原理

XCTAssertNoLeakはどのようにしてメモリリークを検知しているのでしょうか。 基本的な戦略としては、インスタンスをweakポインタに格納し、スコープが変化したタイミングでポインタの中身が nil になっているかどうかで判定をしています。

インスタンスが持つプロパティ群に格納された子インスタンスや、そのさらに孫インスタンスを全て確保するためには Mirror が用いられています。

Mirrorを使い全プロパティを探索して参照型の値を全てweakポインタに確保し、スコープを抜けたあとそのweakポインタがちゃんと nil になっているか確認する、という感じです。

テスト記述に関する注意点

XCTAssertNoLeakは非常に簡単に利用できるようになっていますが、仕組み上いくつか気をつけないといけない部分があります。

ローカル変数のスコープに注意する

XCTAssertNoLeak は引数に渡したオブジェクトが検査されますが、ローカル変数にオブジェクトを保持してしまうとそのローカル変数が参照を持つためにテストに用いられるweakポインタが nil にならず、メモリリーク扱いになってしまいます。

// NG
let viewController = MyViewController()
XCTAssertNoLeak(viewController) // faild! 

回避するためには XCTAssertNoLeak の引数にオブジェクトを右辺値として渡す必要があります。 クックパッドアプリでは次のようにしてテストを記述しています。

func build() -> AnyObject {
    RecipeDetailsViewBuilder.build(....) // 初期化処理
}
XCTAssertNoLeak(build())

初期化処理をローカル関数にラップし、返り値をそのまま XCTAssertNoLeak に放り込むことでローカル変数にテスト対象のインスタンスを保持しないようにしています。

シングルトンは例外設定

次のテストを見てみましょう。 f:id:iceman5499:20200303111725p:plain

NotificationCenterがリークしていると怒られています。 シングルトンは開放されないため、XCTAssertNoLeakから見るとメモリリークしているものとして判定されます。 このような状況に対応するために CustomTraversable というプロトコルが用意されています。

extension NotificationCenter: CustomTraversable {
    var ignoreAssertion: Bool { true }
}

メモリリークしていると判定されるクラスに対してextensionで ignoreAssertion: Bool を実装することで、そのエラーを無視することができます。 CustomTraversable にはこの手のケースに対応するための口がいくつか用意されています。

シングルトンが保持するオブジェクト

シングルトンを無視設定するところまでは良かったですが、 ignoreAssertion するだけではそのオブジェクトに連なっているオブジェクトがさらにリーク判定されてしまいます。( ignoreAssertion はそのインスタンスの子プロパティ群ごと無視はしません)

class AwesomeObject {}

class MySingleton {
    static let shared = MySingleton()
    private let object = AwesomeObject()
}

extension MySingleton: CustomTraversable {
    var ignoreAssertion: Bool { true }
}

class MyViewController: UIViewController {
    let object = AwesomeObject()
    let dependency = MySingleton.shared
}

class NoLeakTests: XCTestCase {
    func testMyViewController() {
        // failed - 1 object occured memory leak.
        //        - self.dependency.object
        XCTAssertNoLeak(MyViewController())
    }
}

このコードの例の場合、 self.dependency.object がリークしたという判定になります。 あまりこのようなケースはありませんが、現実のアプリケーションは複雑であるためまれにこのケースに遭遇します。

これの対応を考えることは難しいです。例えば次のようにシングルトンに持たれた AwesomeObject のみ例外設定することを考えます。

extension AwesomeObject {
    var ignoreAssertion: Bool {
        self === MySingleton.shared.object // コンパイルエラー: 'object' is inaccessible due to 'private' protection level
    }
}

このように書きたいところですが、アクセス修飾子の関係でこの処理は記述することができません。 対象のオブジェクトがアプリケーション内に実装されていればやりようはあるかもしれませんが、外部のライブラリなどとなると難しくなってきます。

クックパッドアプリではこのケースは諦めて AwesomeObjectignoreAssertion は常に true を返すようにしています。

まとめ

XCTAssertNoLeakのおかげで、メモリリークを検知するテストを実現することができました。 このテストを実装してすぐ、僕の変更で循環参照を引き起こしてしまいテストに怒られてしまったので早速効果を発揮しました。 テストを導入する過程で見つかったメモリリークもいくつかあり、今まで見つかってなかったメモリリークも浮き彫りにすることができました。

このようにしてiOSアプリのメモリリークは解消しましたが、モバイルアプリの品質安定にはまだまだ手が足りていない状況です。 クックパッドではモバイルアプリの品質を安定させたいiOS/Androidエンジニアを募集しています。 https://info.cookpad.com/careers/

AWS リソース管理の Terraform 移行

技術部 SRE グループの鈴木 (id:eagletmt) です。クックパッドでは Codenize.tools を用いて様々なリソースをコードで管理してきましたが、現在では大部分が Terraform へと移行しています。Terraform の使い方等については既に沢山のドキュメントや紹介記事があるので本エントリでは触れず、なぜ Terraform へと移行しているのか、どのように Terraform を利用しているのかについて書いていきます。

Terraform 移行の理由

クックパッドでは自分と同じく SRE グループに所属している菅原 (id:winebarrel) によって開発された Codenize.tools のツール群を利用して IAM、Route 53、CloudWatch Alarm、CloudWatch Events 等をコードで管理し、いわゆる GitOps を実践してきました。Codenize.tools による AWS リソース管理は基本的に1アカウント内のすべてのリソースを対象に動作します *1。これに従うと、ある AWS サービスに属する全てのリソースの管理は1つのリポジトリに集約されることになります。実際、社内には cloudwatch というリポジトリや iam というリポジトリが存在します。この構成は1つのチームが1つの AWS アカウント内のすべてのリソースを管理しているような場合は抜け漏れ無くコードで管理できるため非常に有効です。cloudwatch と iam を1つのリポジトリにまとめるか別のリポジトリに分けるかという選択肢はありますが、1つの AWS アカウント内のすべてのリソースを SRE グループが管理していたクックパッドでは自然な構成でした。

しかしマイクロサービス化が進みセルフサービス化が進むと、様々なチームで様々なリソースが必要になり、SRE グループがあらゆる AWS リソースを管理することが困難になっていきました。新しくアプリケーションをデプロイしたい人たちにとっても、複数のリポジトリに別々の pull-request を出す必要があり面倒に感じられていました。また、ELB + ECS/EC2 + RDS という伝統的な構成ではなく AWS SAM (Serverless Application Model) を利用したサーバレスな構成も選択されるようになり、CloudFormation で管理されるリソースも増えていきました。このような状況では Codenize.tools の「1アカウント内のすべてのリソースを対象に動作」という挙動は次第にフィットしなくなっていきました。

そこで、選択的にリソースを管理することができ、多くの現場で利用されている Terraform へと移行する方針になりました。これまでも Codenize.tools の対象外だった Auto Scaling Group や RDS インスタンス等の管理に Terraform は使われていましたが、Codenize.tools の対象でも Terraform を利用するように移行が始まりました。現時点では IAM、Route 53 以外の AWS リソースは一通り Terraform への移行が完了しています。

Terraform 運用

全面的に Terraform へと移行するにあたって、いくつか工夫した点があるのでそれぞれ紹介します。

tfstate の単位

Terraform では管理対象のリソースに関する情報を state ファイル (以下 tfstate と呼ぶ) にまとめているわけですが、Terraform を利用するにあたってこの tfstate をどのような単位で分割するのかという話題があります。1つの tfstate ですべての AWS リソースを管理するのは少なくともクックパッドの規模では無謀で、もしそうしたら terraform plan の時間が非常に長くなってしまいます。 クックパッドでは1つのリポジトリに Terraform ファイルを集約し、その中でプロジェクト単位でディレクトリを分けて記述していくことにしました。1つのディレクトリが1つの tfstate に対応します。

.
├── service-1
│   ├── aws.tf
│   ├── backend.tf
│   └── rds.tf
├── service-2
│   ├── acm.tf
│   ├── aws.tf
│   ├── backend.tf
│   ├── iam.tf
│   ├── rds.tf
│   ├── security_group.tf
│   └── vpc.tf
└── service-3
     ├── acm.tf
     ├── aws.tf
     ├── backend.tf
     ├── elb.tf
     ├── s3.tf
     ├── sg.tf
     └── vpc.tf

1つのリポジトリにしたのは一覧性を確保するためと、後述する CI の整備を楽にするためです。

linter の整備

AWS リソースの追加、削除、変更は Terraform 用のリポジトリへの pull-request で行うわけですが、pull-request に対する CI として terraform plan の結果を表示したり terraform fmt 済みかチェックしたりすることに加えて、独自に用意した linter を適用しています。Terraform 向けの linter というと tflint が既に存在していますが、tflint がカバーしているような一般的なルールではなく、社内独自のルールを強制したかったため自作しました。 ルールとしては現時点では

  • タグ付け可能なリソースには Project タグを必ず設定する
    • クックパッドの AWS アカウントではコスト分配タグとして Project というタグが設定されており、コスト管理のために Project タグを設定しなければならない
  • Aurora MySQL を使うときに特定のエンジンバージョンを禁止
    • クックパッドでの典型的なワークロードで致命的な問題が発生するエンジンバージョンがあるため、そのバージョンの指定を避ける

を強制しています。

ちなみにこの linter を pull-request に対して実行するにあたって、見易さの観点から GitHub の Checks の機能を利用することにしました。linter のように行単位で指摘する箇所が分かる場合、Checks を使うと見易く表示できます。 https://developer.github.com/v3/checks/

remote state の取り扱い

tfstate の保存場所としては S3 を使っていますが、RDS インスタンスの master user のパスワードのようなセンシティブな値の取り扱いを考慮する必要があります。たとえば aws_rds_cluster を新規に作成するとき、master_password に直接パスワードを書くと GitHub リポジトリで社内全体に公開されてしまいます。そこで SSM の Parameter Store に SecureString として保存して aws_ssm_parameter で参照したり、Vault の KV backend に保存して vault_generic_secret で参照したりといった方法を思い付きますが、これにより Terraform ファイル上からはパスワードが消えても tfstate にパスワードが平文で記録されてしまいます。この問題は upstream でも認識されていて tfstate 自体をセンシティブなデータとして扱うことを推奨しています。 https://www.terraform.io/docs/state/sensitive-data.html

しかしながら社内のエンジニアであればどのプロジェクトでも terraform plan は実行できるという状態を目指したかったので、パスワードのようなセンシティブな値は tfstate にはダミーの値を指定するという方針を試してみています。たとえば aws_rds_cluster を新規作成する場合は

resource "aws_rds_cluster" "my-awesome-app" {
  ...
  master_password = "pasuwa-do"
  ...
}

のように記述して terraform apply し、その後 mysql コマンド経由や ModifyDBCluster API で正式なパスワードに変更します。API を通じて master_password を得る手段が無いので Terraform は tfstate にある値を信じるしかなく、tfstate にも Terraform ファイルにも pasuwa-do と書かれているので差分が発生せず、センシティブな値を tfstate にも Terraform ファイルにも書き込まずに Terraform でリソースを管理することができています。

今後

Codenize.tools から Terraform への移行は進んでいるものの、最初の移行時にはプロジェクト単位に分割することを諦めたため、Terraform 管理へと変更はできていても適切なプロジェクト内の tfstate で管理させることはまだ十分にはできていません。現在はたとえば cloudwatch のような tfstate に様々なプロジェクト向けの CloudWatch Alarm が混ざって管理されている状態です。これを分解していくことは地味な作業ではありますが、今後も少しずつプロジェクト単位で管理された状態へと移していこうとしています。

また、多くの現場で実践されていそうな Terraform の自動適用もまだ実践できていません。Terraform 管理への移行や Terraform 管理内での tfstate の変更も徐々に落ち着いていくと思われるので、master にマージされたら自動的に terraform apply される状態を目指したいです。

*1:--exclude や --target で対象を限定できるようになっているものもあります

「このレシピは何人分?」を機械学習で推定する

研究開発部の原島です。在宅勤務中は部のメンバーと 3 時にラジオ体操をしています。今日はラジオ体操の話はおいといてレシピの分量の話をします。

1 人分、2 個分、三枚分、約 4 皿、5 杯くらい、18 cm タルト型、...

クックパッドの一部のレシピは 1 人分のカロリーが計算されています。計算されたカロリーは検索結果の絞り込みや献立の作成などに使用されています。

ここでポイントとなるのは「1 人分」というところです。

レシピには、下図のように、その分量が記入されています。クックパッドの全レシピのうち、大体 50% のレシピの分量は「N 人分」という表記です。これらのレシピは、レシピ全体のカロリーを N で割ることで、1 人分のカロリーが計算できます。

f:id:jharashima:20200225174134p:plain
レシピの分量

一方、残りの 50% のレシピの分量は「N 人分」という表記ではありません。その半分(全体の 25%)はそもそも表記がありません。これは単に分量の記入が任意のためです。では、残りの 25% はどうなっているのでしょうか?

答えは様々です。「M 個分」や「M 枚分」、「M 皿分」のように「人」ではない助数詞による表記もあれば、「M cm タルト型」や「直径 M cm シフォン型」、「M cm 丸形 L 個」のように何らかの型の大きさとその個数による表記もあります。

こういった分量を「N 人分」に換算するにはどうすれば良いのでしょうか。同じ「M 個分」でもマフィンのレシピとケーキのレシピで N は違うでしょう。また、M は全角の時も半角の時も漢数字の時もあります。接頭辞や接尾辞も付いたり付かなかったりします。さて、どうすれば良いのでしょうか。

人手で頑張る

やっぱりまずはこれでしょう。人間の能力はすごいです。どんな表記でもちゃんとパースして、常識的な N に換算することができます。悩んだ時もレシピのタイトルや完成写真、各材料の分量などを参考にして、違和感のない N を選択することができます。

実際、1 日に十数レシピ(1 年で数千レシピ)の分量が人手で「N 人分」に換算されています。これは、配信記事で取り上げるレシピのカロリーを計算する過程などで換算されるものです。換算の際は、まず、一人のアノテーターが仮の N を決定します。そして、別のアノテーターがチェックして、最終的な N を決定します。

なお、チェック時のアノテーター間の一致率は大体 67% です。また、残りの 33% もそれほど大きな違いがないケース(e.g., 一人が N = 3、もう一人が N = 4)が多いです。これは、そもそも N の候補が少ないことが幸いしています。クックパッドのレシピは家庭用のものが多いので、大体 75% のレシピで N は 1 〜 4 です。

このように、できるのであれば、人手で換算するのが一番です。ただ、表記が「N 人分」でない(かつ、表記はある)レシピは数十万品あります。これらの分量を全て人手で換算するのはさすがに大変です。1 年で数千レシピを換算する今のペースでは大体 100 年くらいかかりそうです(その間に新しいレシピが投稿されるので、実際はもっとかかりそうです)。

機械学習を試す

そこで、機械学習の出番です。最近はライブラリやマネージドサービスが充実し、マイクロサービス化も促進されたので、機械学習をサービスで利用するハードルがぐっと下がりましたね。もちろんまだハードルはありますが、4 〜 5 年前と比較するとだいぶ楽になりました。機械学習は誰でも利用できる手段になりつつあります。

さて、今回開発したモデルは二つあります。一つ目は下図の single-source model です。このモデルはレシピの分量(もしくは、レシピのタイトル)を入力として N を出力します。より具体的な挙動は以下の通りです。

  1. 分量(もしくは、タイトル)をサブワードに分割
  2. 分割されたサブワードをエンコーダーに入力
  3. エンコーダーの結果を全結合層に入力
  4. softmax でいずれかの N(後述する実験では 1 〜 20)に分類

f:id:jharashima:20200305093505p:plain
single-source model

二つ目は下図の multi-source model です。このモデルは分量とタイトルの両方を入力として N を出力します。より具体的な挙動は以下の通りです。入力が複数になったことと、エンコーダーの出力を concat すること以外は single-source model と同じです。

  1. 分量とタイトルをそれぞれサブワードに分割
  2. 分割されたサブワードを各エンコーダーに入力
  3. エンコーダーの出力を concat して全結合層に入力
  4. softmax でいずれかの N に分類

f:id:jharashima:20200305093256p:plain
multi-source model

かなりシンプルなモデルではないでしょうか?単に、分量かタイトル(もしくは、分量とタイトル)の情報から N を推定するというだけです。表記のパースは最初から諦めて、サブワードに分割してニューラルネットワークに突っ込んでいます。

補足することがあれば、回帰問題でなく、分類問題としていることくらいでしょうか。これは、タルトやケーキなどのレシピにおける N が 8 や 6、4 のことが多かったからです。最初は回帰問題としていたのですが、N を連続値とするより離散値とするメリットの方が大きそうでした。

正解率は?

さて、このシンプルなモデルがどこまで通用するのでしょうか。同僚の @himkt にも手伝ってもらって、実験してみました。

実験には、分量表記が「N 人分」でない(かつ、表記がある)5,279 品のレシピを使用しました。これらを人手で「N 人分」に換算し、その 80% を訓練データに、10% を開発データに、10% をテストデータに使用しました。

モデルの設定は以下の通りです。

  • 埋め込み層: 20 次元
  • エンコーダー: 20 次元の LSTM(2 層)
  • 全結合層
    • single-source model: 20 次元(2 層)
    • multi-source model: 40 次元と 20 次元(それぞれ 1 層)

次元数などはいずれも開発データで調整しました。また、サブワードの分割には sentencepiece を、その学習にはクックパッドの 310 万品のレシピを使用しました。

タイトル  分量  正解率
RE 47%
ML (single) 28%
ML (single) 63%
ML (multi) 62%

結果は上表の通りです。RE(regular expression)はベースラインで、正規表現にもとづいて N を決定しました。具体的には、「M 個分」や「M 枚分」から M を抽出し、そのまま N としました。一方、ML(machine learning)は提案手法で、N は 1 〜 20 としました。また、正解率は初期値が異なる 5 回の平均値です。

表を見ると、分量の情報を使用したモデルの正解率は 62 〜 63% でした。アノテーター間の初見の一致率が大体 67% なので、なかなか良い正解率といえるのではないでしょうか。一方、タイトルの情報だけを使用したモデルの正解率は 28% でした。さすがに分量の情報を使用せずに推定するのは無理がありそうです。

意外だったのは、分量の情報のみを使用した single-source model の正解率が multi-source model の正解率より高かったことです。本質的には、タイトルの情報を使用せずに N を推定するのは不可能です。上でも言及したように、同じ「M 個分」でもタイトルが「マフィン」のレシピと「ケーキ」のレシピで N は違うでしょう。

single-source model が multi-source model に勝利(その差は 1% ですが)した理由はおそらく二つあります。一つ目は分量の情報のみでも、ある程度は、人数分が推定できたことです。ベースラインの正解率が 47% だったことから、47% のデータは「M 個分」などの M がそのまま正解の N だったことが分かります。こういった場合はタイトルの情報が必要なかったのかもしれません。

もう一つは、単に、multi-source model がタイトルの情報をうまく利用できなかった可能性があるということです。ベースラインで対応できなかった 53% のデータではタイトルの情報が有用に思われます。しかし、今回の実験では、二つのエンコーダーを学習させるには訓練データが少なかったのかもしれません。

以上を踏まえて、今は single-source model を試用しつつ、multi-source model を改善しているところです。訓練データを追加していけばどこかで multi-source model が勝利するのではないかなと予想しています。

タイトル 分量 正解 single multi
素朴なレーズンパン 1 斤 8 8 8
ツナポテトのミニコロッケ☆お弁当にも 8 個分 4 8 4
甘さ控えめのクッキー 鉄板 1 枚分 16 10 10

最後に成功例と失敗例を紹介します。一つ目の例は single-source model と multi-source model の両方が正解しました。「1 斤」を「8 人分」と換算したのはなかなか面白いです。二つ目の例は multi-source model だけが正解しました。「ミニコロッケ」は「2 個分」で「1 人分」と学習してくれたのでしょうか。三つ目の例は両方とも不正解でした。この場合、材料欄の情報(e.g., 各材料の分量)も利用しなければ正解するのは難しそうです。この辺りも今後の課題です。

おわりに

まとめると、クックパッドのレシピの約 25% は分量の表記が「N 人分」ではない(かつ、表記はある)ものの、その 60%(全体の 15%)は機械学習で N を推定できます。残りの 40%(全体の 10%)は訓練データを追加するなり、材料欄の情報を利用することで、N を推定できる可能性があります。

モデル自体を改善するのも一つの手です。Transformer や BERT(流行ってますね)を利用するのもありかもしれません。ただ、運用面を考慮するとモデルは軽量なものが良いので、この辺りは悩ましいところでもあります。やっぱり訓練データを追加するのが一番シンプルで、現実的な気もします。

ところで、最近、複数の学生から「新卒採用ってもう始まっていますか」という質問をいただきました。始まっております。機械学習や自然言語処理、画像認識で毎日の料理を楽しみにすることに興味がある方は、是非、以下のページからご応募ください。お待ちしております。

info.cookpad.com