コネヒト開発者ブログ

コネヒト開発者ブログ

Facebook広告で広告費用対効果(ROAS)向上を目指す

こんにちは! @otukutun です。

今回、Facebookで広告費用対効果(ROAS)向上の取り組みをしたのでここで紹介します。

はじめに

インターネット広告では、ユーザーの興味を推定し広告配信しますが、その際に費用対効果などは考慮されていません。例えば、ECショップで広告出稿する際に100円の商品を買う人よりも100万円の商品を買う人に配信した方が売り上げとしては大きいわけですが、現状はそのようになっていません。 Facebook広告では、既に出稿している場合に購入金額の情報をFacebook上に送信することで、例のようなECショップでより高い商品を買う人を機械学習で推定し優先的にターゲティングすることが可能になります(詳しい説明はこちらをご覧ください)

例ではECを出しましたがゲームでも同じようなことができます。スマホゲームで有名なニャンコ大戦争ではROAS対応をすることで、広告費用対効果が前月の2.6倍になるなどの大きな成果が出ています。(下記ページより成果情報を引用)

www.facebook.com

Facebook Conversion APIを導入する

インターネット広告ではコンバージョンイベントを送ることでコンバージョン率を計測できると思います。Facebook広告ではFacebookピクセルでそれが実現できます。FacebookピクセルだけでもROAS向上の対応はできるのですが、サーバー側で処理したかったので今回はFacebookピクセルのコンバージョンイベントとコンバージョンAPIを両方使って対応しました。

設定準備

まずイベントがROAS向上対応(以降バリューへの最適化とします)の条件を満たしているかを確認します。条件はこちらから確認できます。

コンバージョンイベントは「購入イベント」しかバリューへの最適化をオンにできないので注意してください。

また、両者からイベントを送る場合は、イベントが同一であることをfacebookに認識してもらう必要があります。同一の認定ロジックの詳細はこちらをご覧ください。

今回はFacebookピクセルとコンバージョンAPIから同じevent_nameとevent_idを送ることで認識してもらうようにしました。

Facebookピクセルの設定

購入ボタンを押して購入画面が表示されたタイミングなど任意のタイミングで、以下イベントを発火させます。Conversion APIで実際の値段を送るためにここでは0円でデータを送ります。

サーバーからConversion APIにリクエストを送る

まず、グラフAPIエクスプローラから叩いてみるのが簡単に試せるのでオススメです。

developers.facebook.com

上記ページの動画を参考にすると、グラフAPIエクスプローラ上からConversion APIを試しに送れます。その際、test_event_codeを付与することを忘れないでください。そうしないとtestイベントとみなされません。

今回はConversion APIをPHP SDK経由で使うことにしました。各種言語のSDKがあるのでみてみてください。またサービスの利用規約でどのデータを送るかを確認・検討しておくことも重要です。

PHP SDKを使うと以下のように試すことができます。

use FacebookAds\Api;
use FacebookAds\Object\ServerSide\CustomData;
use FacebookAds\Object\ServerSide\Event;
use FacebookAds\Object\ServerSide\EventRequest;
use FacebookAds\Object\ServerSide\UserData;

Api::init(null, null, 'ACCESS_TOKEN');
$client = new EventRequest('PIXEL_ID');

$userData = new UserData();
$userData
    ->setEmail('EMAIL')
    ->setPhone('PHONE_NUMBER')
    // It is recommended to send Client IP and User Agent for Conversions API Events.
    ->setClientIpAddress($_SERVER["REMOTE_ADDR"])
    ->setClientUserAgent($_SERVER['HTTP_USER_AGENT']);

$customData = (new CustomData())
    ->setCurrency('JPY')
    ->setValue(100000);

$event = (new Event())
    ->setEventName('Purchase')
    ->setEventId('event_id') // 任意のevent_idを設定する
    ->setEventTime(time())
    ->setUserData($userData)
    ->setCustomData($customData);


$client->setEvents([$event]);

// debug時はtest_event_codeを設定する
$client->setTestEventCode('TEST_EVENT_CODE');

$client->execute();

こちらで、動かしてみて、test_eventが送信されることに確認できます。また購入情報を0円を送ろうとした場合は、SDK経由だと除外されて不正なリクエストになるので注意してください。(0円の商品を送ることはそもそもないと思いますが念のため)

f:id:azuki_mihomiho:20211210172801p:plain
test_eventの確認

バリューへの最適化をONにする

購入イベントを送るようになってから、任意のタイミングで設定できるようになります。ただし、ある一定条件をクリアしないとバリュー最適化が設定できないため、気をつけてください。実際の設定方法はこちらを参考にしてください。

おわりに

いくつか情報が混在していることと実装上のハマりポイントがありましたが、FacebookからSDKが提供されているので開発自体はシンプルにできるかなと思います。この情報が参考になれば幸いです。

APIクライアントをAPIKit+RxSwiftからURLSession+Combineにしました(中編)

こんにちわ。 iOSエンジニアの ohayoukenchan です。

前編URLRequestを作成するところまでお伝えできたので、今回は実際にURLSessionを前回の変更方針にあった非同期処理を適切に処理したいを解決する内容となっています。

URLSessionとは?

URLSessionはURLで示されるエンドポイントからデータをダウンロードしたり、エンドポイントにデータをアップロードしたりするためのAPIを提供します。 アプリは、このAPIを使用して、アプリが実行されていないときや、iOSではアプリがサスペンドされている間に、バックグラウンドでダウンロードを実行することもできます。

単純なHTTPの非同期通信を行うにはdataTask(with:completionHandler:)を用いればいいので、dataTask(with:completionHandler:)で書くとこのような感じになります。completionHandler:のclosureで処理されるdata, response, errorはすべてoptionalな値なので実際に利用するときはunwrapする必要があります。

let task = session.dataTask(with: url!) { data, response, error in
 
  if let error = error {
     // エラーが返って来た場合エラーハンドリング
    return
  }

  guard let data = data, let response = response as? HTTPURLResponse else {
    // dataが取得出来ない場合のハンドリング
    return
  }

  if response.statusCode == 200 {
    do {
      let json = try JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.allowFragments)
    } catch {
      // デコードに失敗した場合のエラーハンドリング処理
    }
    // 処理...
  }
}
task.resume() // taskをsuspended状態からrunning状態にする

dataTaskPublisherでcombine publisherを操作していく

今回はURLリクエストの返り値としてAnyPublisher<Request.Response, APIServiceError>のようなcombine publisherが欲しいのですが、 UrlSessionにはdataTaskPublisher(for:)があり、combine publisherを返してくれるので返り値の理想に近いこちらを使用していきます。

詳細はこちらをご確認ください。

Request処理とその結果を返すクラスを作っていきます。まずは、Protocolの定義をしていきます。今回はApiServiceとしました。 whereを使ってRequestに型制約をもたせておくと予期しないRequest型で実装してしまった場合、コンパイルエラーで気づくことができます。

protocol ApiService {
    func call<Request>(from request: Request) -> AnyPublisher<Request.Response, APIServiceError>
    where Request: APIRequestType
}

ApiServivceクラスはレスポンスエラーとデコードに失敗したときのパースエラーがあることを明示しておきます。

enum APIServiceError: Error {
    case responseError(Error)
    case parseError(Error)
}

次にApiServiceに準拠したApiServiceクラスを実装していきます。 継承されないようにfinal修飾子をつけておくとどこかで継承して利用される心配がなく安心かと思います。

final class ApiService: ApiService {

    func call<Request>(from request: Request) -> AnyPublisher<Request.Response, APIServiceError>
    where Request: APIRequestType {

        let urlRequest: URLRequest = request.buildRequest() // (1)

        let decorder = JSONDecoder()
        decorder.dateDecodingStrategy = .iso8601
        decorder.keyDecodingStrategy = .convertFromSnakeCase // (2)

        return URLSession.shared.dataTaskPublisher(for: urlRequest)
            .tryMap { (data, response) -> Data in
                if let urlResponse = response as? HTTPURLResponse {
                    switch urlResponse.statusCode {
                    case 200..<300:
                        return data
                    default:
                        throw try decorder.decode(ApiErrorResponse.self, from: data)
                    }
                }
                return data
            } // (3)
            .mapError { error in
                APIServiceError.responseError(error)
            } // (4)
            .flatMap {
                Just($0)
                    .decode(type: Request.Response.self, decoder: decorder)
                    .mapError { error in
                        return APIServiceError.parseError(error)
                    }
            } // (5) 
            .receive(on: DispatchQueue.main) // (6)
            .eraseToAnyPublisher()
    }
}

(1) let urlRequest: URLRequest = request.buildRequest()のところで前回作成したAPIRequestTypeに準拠したStruct型のリクエスト(前回SampleRequestとしたもの)のbuildRequest()を実行してURLRequestをつくります。

(2) dataTaskPublisher(for: urlRequest)の返り値dataをデコードする際の指定で、Date型にするときiso8601形式に、JSON keyがsnakeCaseの場合、structとのmapping ができるようになります。

(3) dataTaskPublisherURL session data taskをwrapしたpublisherを返すので.map()オペレータを使うことができます。publisherの中身は(data, response)のタプルです。レスポンス結果に応じて処理を振り分けたいので.map()ではなくtryMap()を使用しています。この場合、HttpStatusCodeが200から300までの値の場合リクエストは正常なレスポンスを返したとして後続の処理にdataを返します その他の場合はthrowキーワードでpublisherの処理を失敗させます

(4) mapErrortryMap()で処理が失敗した際にthrowキーワードで投げたエラーを別の型に変換します。ここではAPIServiceError.responseError(error)に変換していますが、画面側でAPIを叩いたときに、 URLリクエストの返り値としてAnyPublisher<Request.Response, APIServiceError>のようなcombine publisherを返して欲しいためです。

(5) .flatMapjust($0)ですがこちらは(data, response)のタプルのうちdataのみを扱うpublisherにしたいのでこのようにしています。そしてCodable(decodable)を使用してデコードしています。デコードに失敗した場合はレスポンスエラーのときと同じようにmapErrorを使ってAPIServiceError.parseError(error)に変換しています。

(6) URL sessionはバックグラウンドで処理されますが、UIの更新はメインスレッドで行いたいため、receive(on: DispatchQueue.main)でメインスレッドで返すように指定しています。

これでAPIServiceを実装が完了したので、いよいよUI側の処理を書いていくことが出来ます。

リクエストをcombine publisherで受け取る

UI側の処理はこのように簡潔に書けます。

let apiService = ApiService()

apiService.call(
  from: SampleRequest.List(query: "foo")
)
.sink(
  receiveCompletion: { [weak self] completion in
      switch completion {
      case .finished:
          break
      case .failure(let error):
          self?.errorSubject.send(error)
      }
  },
  receiveValue: { [weak self] result in
      self?.fooSubject.send(result.bar)
  }
)
.store(in: &cancellables)}

ApiServiceに実装したcall(from:)メソッドにリクエストを渡し、.sink(receiveCompletion:, receiveValue:)することでAnyPublisher<Request.Response, APIServiceError>の値を流します。receiveCompletion:ではcompletionが.failureだった場合APIServiceErrorを流してUI側の制御をします。errorSubjectをUI側で購読できるようにして値が流れてきたらUIAlertをだすなどの処理につなげます。値を受け取った場合、receiveValue:で受け取った値をUIに反映させていきます。

Swift5.3からMultiple Trailing Closuresが使えるのでもう少しシュッと書くことができます。

let apiService = ApiService()

apiService.call(
  from: SampleRequest.List(query: "foo")
)
.sink { [weak self] completion in
  switch completion {
  case .finished:
    break
  case .failure(let error):
    self?.errorSubject.send(error)
  }
} receiveValue: { [weak self] result in
  self?.fooSubject.send(result.bar)
}
.store(in: &cancellables)}

インデントが浅くなって可読性があがりました!

これでURLSessionを利用してレスポンスをCombineのAnyPublisherで受け取ることができるようになりました。 しかし、APIが正しく動作するのかのテストが書けていません。

ママリではViewModelの初期化時にApiServiceをDIすることでテストを書けるようにしています。 長くなってしまったのでテストについては後編でお伝えできればと思います。( ^ θ ^ )

余談

WWDC2021のMeet async/await in Swift - WWDC21 - Videos - Apple Developerにもありますが、非同期処理をasync/awaitキーワードを使って自然にかけるようになりましたね。 関数にasync throwsつけて、非同期処理が行われる処理にtry awaitをつけることで非同期処理を安全に処理できるようになります。 ママリにも早く適応させて行きたいず!

struct UserRequest {
    var session = URLSession.shared

    func load(from url: URL) async throws -> [User] {
        let (data, response) = try await session.data(from: url)
        // レスポンスエラーなど何らかの失敗処理

        let decoder = JSONDecoder()
        return try decoder.decode([User].self, from: data)
    }
}

APIクライアントをAPIKit+RxSwiftからURLSession+Combineにしました(前編)

コネヒト株式会社でiOSエンジニアをやっています ohayoukenchan です。

この記事は コネヒト Advent Calendar 2021 23日目の記事です。

最近コネヒトのママリのAPIクライアントをAPIKitからURLSession+Combineにしました。

変更する動機

ママリではHTTPリクエストに、APIKitを使用していましたが、 GETパラメータにArrayを使えるようにしたいなどの理由で本家APIKitをforkしたものを利用していました。

健全性の観点で、folkしているライブラリを使用し続ける将来的な不安と、依存ライブラリを減らす理由からAPIKitを外す決断をしました。

変更方針

  1. 新しくエンドポイントが追加されたときは、リクエストとレスポンスを作成するのみに留めたい
  2. 非同期処理を適切に処理したい

新しくエンドポイントが追加されたときは、リクエストとレスポンスの作成のみに留めたい

新しくエンドポイントを追加するときに、何度もURLRequest処理まわりに変更を加えたくない。例えば以下のような感じ

リクエスト定義

struct SampleRequest {
    struct Suggest {
        typealias Response = SearchQueryResponse

        var method: HTTPMethodType {
            return .get
        }

        var path: String {
            return "path/to.json"
        }

        let query: String

        var queryItems: [URLQueryItem]? {
            return [
                .init(name: "query", value: query)
            ]
        }
    }

レスポンス定義

struct SearchQueryResponse: Decodable {
    let suggestionSearchQueries: [SearchQuery]
}

これを実現するために汎用的なプロトコルを実装していきます。 まず、APIRequestTypeプロトコルを定義して準拠すべき実装を定義していきましょう。

Responseはエンドポイントごとに型を柔軟に変更したいのでassociatedtype Responseとする。 SwiftのDecodableでdecodeしたいのでDecodableに準拠させておく。

さらに、path(エンドポイント)やHTTP リクエストメソッドなど、リクエストごとに変わるプロパティを定義していきます。 そのリクエストはGETなのかPOSTなのか、エンドポイントやクエリストリングなどは各リクエストごとに変わるのでリクエストの定義側で処理していきます。(前述SampleRequestの部分)

protocol APIRequestType {
    associatedtype Response: Decodable

    var path: String { get }

    var queryItems: [URLQueryItem]? { get }

    var method: HTTPMethodType { get }
}

次にAPIRequestTypeにextensionを生やしてどのエンドポイントでも一意な定義を追加していきます。 最終的にbuildRequest()を通してURLRequestを作成します。

extension APIRequestType {

    // ママリで使用しているAPIのバージョンをheaderに追加する必要があるので登録
    var apiVersion: String {
        return "2.0.0"
    }

    // 各エンドポイントがリクエストする基底のURL
    var baseURL: URL {
        guard let url = URL(string: "\(Const.API_URL)/api/") else {
            fatalError()
        }
        return url
    }

    var headerFields: [String: String] {
        let header = [
             // headerフィールドに追加すべき項目
            "Accept-Encoding": "gzip",
        ]
        return header
    }

    func buildRequest() -> URLRequest {

        let pathURL = URL(string: self.path, relativeTo: baseURL)!
        let httpMethod = self.method

        var urlComponents = URLComponents(url: pathURL, resolvingAgainstBaseURL: true)!
        urlComponents.queryItems = self.queryItems
        let characterSet = CharacterSet(charactersIn: "/+:").inverted
        urlComponents.percentEncodedQuery = urlComponents.percentEncodedQuery?
            .addingPercentEncoding(withAllowedCharacters: characterSet)

        var request = URLRequest(url: urlComponents.url!)
        request.httpMethod = httpMethod.rawValue
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        headerFields.forEach { key, value in
            request.addValue(value, forHTTPHeaderField: key)
        }

       // その他Basic認証などHeaderFieldに追加すべき項目をrequest.addValueで追加していますが長くなるので省略します
    }
}

APIRequestTypeが実装を強制させているプロパティであるpath,queryItems,methodについてそれぞれ見ていくと pathbaseURLを基底としてURL型に変換しています。 queryItems[URLQueryItems]として保持しているのでそのままURLComponents.queryItemsに追加することができます。URLQueryItemsの実態は(name: "query", value: query)の引数名付きのタプル。これでURLにクエリストリング?query=fooを追加することができます。 methodrequest.httpMethodrawValueStringをセットしています。

これについてはHTTPリクエストメソッドを網羅するenumを定義しています。

public enum HTTPMethodType: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case head = "HEAD"
    case delete = "DELETE"
    case patch = "PATCH"
    case trace = "TRACE"
    case options = "OPTIONS"
    case connect = "CONNECT"
}

他にハマったこととして、URLComponentsRFC3986に準拠しているため+:などは予約語とみなされエンコードされないので 2021-11-11T16:28:46+0900のようなクエリストリングをエンコードするためにaddingPercentEncodingを使ってエンコードする文字を指定する必要がありました。

ここまでで、エンドポイントの追加に対して、リクエストとレスポンスを作成し、buildRequest()を通してURLRequestを作成することが出来ました。

冒頭の再掲になりますが、エンドポイントの追加時は以下のようなStructを作成するだけです。

struct SampleRequest {
    struct Suggest {
        typealias Response = SearchQueryResponse

        var method: HTTPMethodType {
            return .get
        }

        var path: String {
            return "path/to.json"
        }

        let query: String

        var queryItems: [URLQueryItem]? {
            return [
                .init(name: "query", value: query)
            ]
        }
    }

長くなってしまったのでURLSessionを使って非同期処理していくところは中編でお伝えできればと思います。

インフラエンジニアの業務で部分的にスクラムを取り入れている話

この記事は コネヒト Advent Calendar 2021 18日目の記事です。

コネヒトでインフラエンジニアをしている @laugh_k です。今回は直近のコネヒトインフラチームの業務に部分的にスクラム開発のプラクティスを導入している取り組みを紹介します。

スクラム開発のプラクティス導入の背景

はじめに、なぜインフラエンジニアの業務で部分的なスクラム導入に至ったのかの背景を簡単に紹介します。

元々コネヒトのインフラエンジニアはこれまで基本的に一人、多くとも二人体制で過ごした時間が長く、課題の管理についても個人が把握している課題に取り組むという色が強かったように思えます。

私がコネヒトに入社した1年前からは GitHub Issue にチームの課題を集約する体制を作り、二人体制ではあるもののチームとしてインフラに関する課題を把握できる状況をつくることはできました。一方で日々の業務に取り組むスタイルとしては、初めの10カ月程度の期間は大規模なプロジェクトをメインタスクとして持ち、隙間時間で日々発生するインフラの課題に向き合う形で過ごしました。大きな課題に集中するべくこのスタイルで業務を進めていましたが、結果として日々発生するインフラの課題にほぼ手をつけられないという問題に発展しました。

これはチームのリソースや優先度の都合で仕方がないとみることもできます。ですが、「同じスタイルでの業務を続けると半期のうちに改善する問題が1,2個程度になってしまう」と危機感を覚えました。限られたリソースであっても、課題を解決に向けて少しでも進捗させられないかと考えた結果、「大きなプロジェクトを半期に持つ」のではなく「短い時間でできるところまで集中して取り組み、またタスクの持ち方を見直すループを回す」ほうが割り込み業務が発生しやすいインフラエンジニアの業務的にもよいという結論に至りました。

私の過去の経験上「短い時間でできるところまで集中して取り組み、またタスクの持ち方を見直すループを回す」スタイルにはスクラム開発のプラクティスが大いに生かせると考え、インフラエンジニア業務向けにスクラムを部分的に取り入れることに決めました。

どうやっているのか

ここで紹介するのは 2021-12-17 時点でのやり方です。スプリントごとにどんどん改修を加えていってる段階なので、来月にはやり方を変えていることもあるかもしれません。

インフラスプリントの開催

一般的なスクラム開発のスプリントにアレンジを加えた「インフラスプリント」を開催しています。

期間は約2週間程度とし、開始は木曜日で終了がその2週先の火曜日とします。スプリントの1回目の水曜日がリファインメントで、終了の次の日となる水曜日がレトロスペクティブと次回のプランニングという流れです。言葉だけだと少々わかりずらいので図で示すと以下のような流れです。水曜日にスクラムイベントが発生するようにしています。

f:id:laugh_k:20211218114620p:plain

スクラムイベントが水曜日となっている背景は、インフラスプリントを実践する以前から行っていた定例MTGがたまたま水曜日開催だったためで深い理由はありません。ただ、実際にやってみると水曜日にスクラムイベントがあると準備に余裕を持たせやすいこと、また次の日も営業日であることからイベントで話題に上がったTryに着手しやすく、バランスがよいと感じています。

GitHub Project(beta) の活用

基本的なインフラの課題・タスクの管理には GitHub Issue (以下、単に Issue)で行っています。その関係で今現在は GitHub Project(beta)を活用しています。

f:id:laugh_k:20211218114648p:plain

GitHub Project(beta)で用意しているステータスは以下の通り

ステータス 説明
未分類 原則 project に入れた Issue は最初は必ずこれにする。スプリント計画のタイミングで Backlog にするかスプリントTodoにするか決める
Backlog 直近のスプリントではやらない(or やれない)Issue を積んでおく
ペンディング / 進行したもののとまってしまった 途中まで進行していたものの、様々な要因で進行が止まってしまった Issue。プランニング or リファインメント時に Backlog に戻すか再びスプリントTodoに入れるかを判断する
スプリントTodo 「このスプリントでやる!」という Issue 。今のところ明確な数の制限は行わず、状況を見て判断。プランニングの際にこのTodoを決める
進行中 実際に手を動かして着手している最中の Issue。議論中となっているものもここに含む
返答待ち / レビュー待ち / 確認中 アサイニーは直接手を動かしていないが、別のだれか or 何かのアクション待ちの Issue/PR(PRのレビュー待ち、問い合わせた後の応答待ちなど)
完了 / マージ済み PRはマージしたらここでOK。Issueもクローズしたらここへ。他、開発チームから派生したIssueなどでインフラチームの対応として完了と判断したもの。

GitHub Project(beta) へのIssue/PRの追加ルールとしては以下の通りです。

  • インフラ関連の課題は一つのリポジトリの Issue に集約。そのIssueは原則すべて追加
  • ecschedule や terraform のコードを管理するリポジトリのPRも追加
  • 他のリポジトリでもインフラとしての活動が入ったIssueも追加
  • 最初のステータスは原則「未分類」。ただし、依頼やアラート対応などの急な割り込みの場合は「スプリント予定外」のラベルをつけて「進行中」にいきなり追加するのもあり。

また、ストーリーポイントのような見積もりは現時点では行わないことにしています。インフラ業務においては依頼やアラート起因で発生する割り込みでかつ、すぐに着手する必要があるIssueも多く、作業見積もりをしてから着手をするという流れはあまり現実的ではないという判断です。このあたりについてはチームの規模や、仕組み次第で今後変えていく可能性もあります。

スプリントイベントの進行

今のところインフラエンジニア二人体制でリファインメントは30分、レトロスペクティブ&プランニングでは1時間ほど水曜日に確保しています。二つのパターンでも基本的な進行は同じで、レトロスペクティブ&プランニングの回のみパート4があります。他の細かな違いは以下の通りです。

イベントの種類 議論ポイント Issueのアーカイブ
リファインメント 残り半分の期間で今のタスクは終了させられそうか。終了させるにあたって何か懸念事項はないか。この時点で無理そうなものはないか。 やらない
レトロスペクティブ&プランニング 実際にどれくらいのタスクを消化できたか、タスクが残った場合は何が問題だったのかを振り返る やる(完了数を数え、スクリーンショットをとっておく)

アジェンダ・議事録については以前は Docbase を使って行っていましたが、最近は Notion 上で以下のテンプレートに沿って行っています。進行内容を実際のアジェンダ・議事録の一部とともに紹介します。

f:id:laugh_k:20211218114718p:plain

パート1: Issue / PR を確認

f:id:laugh_k:20211218114739p:plain

最初のパートではGitHub Project(beta)を眺め、前回のスクラムイベントからの間でどういった出来事があったのかを共有しながら、必要に応じて質疑応答・議論を行います。事前に書き出しておける内容は Memo ブロックにあらかじめ記載しておくようにします。

リファインメント回のときは、その時点で完了が難しくなったIssueを「ペンディング / 進行したもののとまってしまった」に移動するなどの調整も行います。レトロスペクティブ&プランニング回のときは「完了 / PRマージ済み」のIssueの内容を確認し、記録のためIssue/PR数を控えてスクリーンショットをとったあとにアーカイブをします。

また、GitHub Project(beta) のみの場合、直近動きのあった Issue を見逃す可能性もあることから、Issue を集約するリポジトリにおける Issue を「最近動きがあったIssue」として Recently update で sort した一覧も確認するようにしています。 

パート2: 今週の出来事

f:id:laugh_k:20211218114758p:plain

方式としてはいわゆる KPT 方式なのですが、あえて「Keep」「Problem」「Try」とせず「よかったこと」「課題に感じたこと」「これやってみては?」という名称にしています。あまり深い意図は無いものの、実際に話しておきたい事をそのまま項目にしています。

パート3: ほか、話したい事

f:id:laugh_k:20211218114820p:plain

出来事の振り返りができたら次はインフラチームとして話しておきたいことがある場合に議論するパートです。このパートでは組織的な方針から、今週の出来事パートで出た話題の深堀り、「今やってるこのタスクぶっちゃけどうですか」のようなものまでさまざまです。

パート4:プランニング

f:id:laugh_k:20211218114837p:plain

レトロスペクティブ&プランニング回の時のみ行います。一通り振り返りの議論が終わったところで、次のスプリントの計画を立てます。やることとしては以下の通りです

  • 「未分類」の Issue がある場合、「Backlog」にするか「スプリントTodo」にするかを決定
  • 「進行中」「返答待ち / レビュー待ち / 確認中」の状況や期限などを話し合いながら「Backlog」から「スプリントTodo」に移動するIssueを決め、担当をアサイン

どのような効果が出ているのか

インフラスプリントはまだ3回目が終わった状況で、まだまだこれからな部分はあります。しかしながら、現時点でも定性的ではありますが改善していると感じられる部分はあります。

一つは常に巨大なプロジェクトを一人で抱え込まなくてもよくなったことにあります。プランニングの時点でそのスプリントで集中すべきことがチームで合意を取れたうえで決まるため、安心してスプリントToDoの内容に取り組めます。

また、やらないことに関する合意も取りやすくなりました。リファインメントのタイミングで「このIssueは割り込みのあの件があるので、来週まではやらないことにしましょう」といったコミュニケーションもよく発生しています。

スクラムイベントのフレームワークも良い形で機能してると感じることが多いです。単純な定例MTGと比べスクラムイベントのタイミングで「このスプリント、次のスプリントの終了 Issue を増やしに行く」という意識も強く働くようになり、中途半端な状態で宙に浮いてしまうIssueもかなりクローズさせられるようになったと思います。

おわりに

インフラエンジニアの業務にスクラム開発のプラクティスを部分的に導入している取り組みを紹介しました。この取り組みはまだまだ手探り段階で、まだまだ改善できる点も多いだろうと考えています。もしもこの記事で紹介している内容を見ていただき、「一緒にコネヒトのインフラチームで働いてみたい!」「こんなのもっとよくできるよ!」と思っていただける方がいましたら是非ご連絡ください。

hrmos.co

Android版ママリアプリのリファクタ事情 ~ ViewModel編 ~

これは コネヒト Advent Calendar 2021 16日目の記事です。

こんにちは。2017年11月にAndroidエンジニアとしてjoinした@katsutomuです。

昨年のエントリーで緑髪にした報告を行いましたが。今は緑髪 -> 赤髪 -> 金髪と定期的にアップデートを続けております。

tech.connehito.com

今回はAndroid版ママリアプリのリファクタリングの事情について紹介しています。

はじめに

ママリのAndroidアプリは2014年から開発を続けています。7年間の技術トレンドの変遷のスピードに完全には追いつけず、少しずつコードのレガシー化が進んでおり、さまざまな課題が顕在化しています。

例えば以下のような課題が、あげられます。

  • パッケージ構成の見通しが悪い
  • 複数種類の実装方法が混在している
    • ViewModel層の実装、リストビューの実装、データ永続化、(etc
  • 全てのレイヤーがRxJavaに依存している
  • UseCaseをうまく活用できていない
  • UIのコンポーネントが古い

などなど、あげ始めるとまだまだキリがありませんが、段階的に改善を続けていく予定ですので、今後は本ブログなどで、折に触れて改善事例を紹介していければと思います。

今回はその第一弾としてViewModel層の実装方法が複数存在する問題に着手したので、リファクタリング事例として紹介します。

なぜやるのか

まずこの課題の背景について紹介します。先述したとおり、ママリのAndroidアプリは7年の歴史があります。 その間でAndroid開発では、DataBindingの登場、ViewModel/LiveDataの登場、JetpackComposeの登場など、技術的な変遷がいくつか行われ、開発の負担を減らすように進化していきました。 その反面、当時最新だったコードのリファクタリングが追いつかず、レガシーな方法で書かれたコードが残されている部分があります。

具体的にはViewModel層でJetpackのViewModelとBaseObservableの2種類書き方が混在しています。同じ層に複数の書き方が混在しているため、見通しが悪くなり、開発のボトルネック要因となっています。

この課題を解決し、今よりも快適かつスピーディに開発を進めることを目指します。

狙い

今回のリファクタリングの狙いは以下の2つです。

  • 設計ルールを一つに絞ることで、開発のボトルネックを減らす
  • Jetpack Composeの導入のための投資を行う

本来の開発のボトルネックを減らす目的に加えて、Jetpack Composeの導入を考慮しました。今後Androidアプリを、長期的に開発継続し続ける際には、UI実装をJetpack Composeに置き換えていくことが、開発効率向上にベターな選択になると感じています。このタイミングで導入を進めやすい設計を実現しておくことで、段階的に改善を進めることを狙っています。

やることは以下の2つです

1.ViewModel コンポーネントの設計を統一する

BaseObservableやObservableFieldのコードを、LiveDataに置き換えます。 UIとViewModelの接続部分をLiveDataで統一し、コーディング時の迷いをなくす目的です。 同様にRxでViewと接続しているコードもLiveDataに置き換えます。

LiveDataをStateFlowに置き換えることがよぎりましたが、Coroutineの知識が必要になるため現段階では選択していません。 馴染みのあるメンバーがいないことや、修正範囲が広いことが理由です。段階的にリファクタリングを進める過程で、取捨選択をしていく予定です。

2. xmlでのbindingをやめる

xmlにViewModelを渡し、レイアウト要素ごとに行うbindingはやめて、ActivityやFragment内でレイアウトを更新する設計に変更を行います。

developer.android.com

コード例

現時点では、以下のようなコードが点在しています。

// ViewModel: Layout要素ごとにObservableFieldを用意している
class EntityViewModel constructor(
    private val entityRepository: EntityRepository
)  {

    val frameVisible = ObservableField<Boolean>()
    val name = ObservableField<String?>()
    val imageUrl = ObservableField<String?>()
}

// xml: ViewModelを受け取り bindingをしている
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/layout">

    <data>
        <variable name="viewModel"
            type="com.connehito.mamariq.viewmodel.ViewModel"/>
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <LinearLayout
            android:id="@+id/frame"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:visibility="@{viewModel.frameVisible ? View.VISIBLE : View.GONE}"
            app:layout_constraintBottom_toBottomOf="parent">

            <ImageView
                android:id="@+id/image"
                original:contentPhotoUrl="@{viewModel.imageUrl}"/>

                <TextView
                    android:id="@+id/name"
                    android:text="@{viewModel.name}" />
            </LinearLayout>
        </LinearLayout>
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

これを以下のようなコードに置き換えていく予定です。

// ViewModel: 変更されるオブジェクトをLiveDataで持つ
class EntityViewModel constructor(
    private val entityRepository: EntityRepository
)  {

    private val _entity = NonNullMutableLiveData(Entity.INVALID)
    val entity = _entity as LiveData<Entity>
}

// Activity: オブジェクトを監視し、変更があればUIに反映する
class EntityActivity : AppCompatActivity() {

    private fun bindViewModel() {
        viewModel.entity.observe(this) {
            if (it == Entity.INVALID) {
                return@observe
            }

            binding.name.text = it.name
            ImageLoader.getInstance().displayImage(it.imageUrl, binding.categoryImage)
        }
    }
}

アーキテクチャー図

図にするとこのような感じになります。

Before After
f:id:katsutomu0124:20211201004129p:plain f:id:katsutomu0124:20211201004157p:plain

より理想を目指して

今回はあくまでもリファクタリングの第一歩で、段階的に改善を続けていき、最終的には以下のようなアーキテクチャにたどり着くことを現時点では、目指しています。

f:id:katsutomu0124:20211201003652p:plain

この状態を目指すために、以下の取り組みを進めていく予定です。

  • [Now] ViewModelをリファクタリングする
  • [Now]ドメイン知識を元にしたコンポーネント再設計
  • Jetpack Composeの導入
    • 宣言的UIの導入を行い、開発スピードを向上する。
    • リストビューの実装方法を一つに統一する
  • Kotlin coroutineの導入
    • 非同期処理をサードパーティのライブラリに依存しないようにする
    • LiveDataをFlowに置き換える
  • Material Component利用の拡大

もちろんここに挙げた以外にも、改善が必要なことは多く、同時に技術の変遷の速度も早いです。 今後も、理想像もアップデートしながら改善をつづけ、こちらのブログで紹介していければと思います。

PR

コネヒトでは、今回挙げた課題を一緒に解決してくれるAndroidエンジニアも募集中です!

hrmos.co

SageMaker Experimentsを使った機械学習モデルの実験管理

皆さん,こんにちは!機械学習エンジニアの柏木(@asteriam)です.
本エントリーはコネヒトアドベントカレンダーの15日目の記事になります.

今回は機械学習モデルの実験管理をする際に使用しているAWSのSageMaker Experimentsの活用例を紹介したいと思います.

アドベントカレンダー1日目でたかぱいさんがSageMaker Processingの使い所を紹介してくれているので,こちらも併せて参考下さい.

tech.connehito.com

はじめに

前回のエントリー*1でML Test Scoreの話をしましたが,その際にMLOpsの大事な要素である再現性(モデル学習など)に触れました.今回はこのモデル学習の再現性のために必要な実験結果(ハイパーパラメータの引数の値,モデル評価指標など)の管理をSageMaker Experimentsでしているというお話です.

※本エントリーは主にSageMaker Experimentsで実験管理しようとしている人向けの内容になります.

今回説明する部分は,こちらのアーキテクチャーの概略図でいうと,AWSで実験的にデータ分析を行う環境の「実験管理: SageMaker Experiments」になります.

データ分析環境のアーキテクチャー概略図

トーマス・エジソンも以下のような名言を残しているので,何回も無駄な実験を繰り返さないため,また振り返った時にわかるように実験管理はきちんととしようねという気持ちです.

私は今までに一度も失敗をしたことがない。電球が光らないという発見を今まで二万回しただけだ。 それは失敗じゃなくて、その方法ではうまくいかないことがわかったんだから成功なんだよ。


目次


SageMaker Experimentsとは?

SageMaker Experimentsとはなんぞや?というと,公式ドキュメントによると以下のような機能になります.

Amazon SageMaker Experiments is a capability of Amazon SageMaker that lets you organize, track, compare, and evaluate your machine learning experiments.

機械学習モデルの再現性を担保するために必要な情報(モデルのバージョン管理や学習の追跡・比較・評価など)を収集して管理することができる機能で,記録をGUI上から確認することができます.(SageMaker ExperimentsはAmazon SageMaker Studioと連携されているので,SageMaker Studio (Jupyter Labのインターフェース)の画面から確認できます)

機械学習はイテレーティブな実験が必要になりますが,何度もモデル学習を行っていると,どのモデルが最も性能が良くてその時のハイパーパラメータや評価指標の値が何で設定はどうだったかなど,きちんと管理しておかないとわからなくなります.これらの実験管理をSageMakerを使った学習時にも実施できるのが,SageMaker Experimentsになります.

なぜSageMaker Experimentsなのか

実験管理のツールは,OSSの製品も多くあり代表的なものでいうとMLflowなどがあると思います.その中でなぜ私たちがSageMaker Experimentsを使うのかというと,大きく2点あります.

  1. AWSの各種サービスを機械学習プロジェクトで使用しており,それらと相性が良いもの
  2. 再現性に必要なメタデータを管理でき,チームで共有しながら簡単に確認できること

①について,いくつかメリットがあります.

  1. データ同期はS3と簡単に行える
  2. SageMakerのリソースを使って実験した際に,AWSのSetting情報も収集することができる
  3. ログはCloudWatch Logsで確認することができる
  4. Step Functionsに組み込んでパイプラインを動かした時にも実験のログが取れる

②については,他のツールでも実現できるところかなと思います.一方でMLflowなどを使う場合,共有するとなるとトラッキング用にサーバーをホスティングする必要性があったり,それを別途管理・運用する必要が出てきます.

これらを踏まえた上でまずはSageMaker Experimentsで色々と試していこうということになりました.

一方で少し物足りない or 使いづらい部分もあります.

  • 実験後の結果に対して,どうゆう実験内容だったかなどのコメントを入れることができない
  • 実装方法がSageMakerのフレームワークに則る必要があり,その理解に時間がかかる

SageMaker Jobについて

Create an Amazon SageMaker Experimentから拝借した下記表ですが,Jobとして主に使うのはTrainingとProcessingの2つになるかなと思います.特に実験管理を行う場合には,TrainingのEstimatorを使うことになります.今回はこのEstimatorに焦点を当てたいと思います.

Job SageMaker Python SDK method Boto3 method
Training Estimator.fit CreateTrainingJob
Processing Processor.run CreateProcessingJob
Transform Transformer.transform CreateTransformJob

Estimatorを実行して,実験管理を行う方法

カスタムコンテナでTraining Jobを実行し,Estimator.fitを利用してトレーニングモデルの実験管理を行います.

カスタムコンテナで実行するための準備

AWS SageMakerのTraining Jobを実行するためには,SageMakerのお作法に則る必要があります.

SageMakerのTraining Jobを実行する際,デフォルトではdocker run {image} trainのコマンドが実行されます.このことから,trainというファイルを用意し,コマンドにパスを通し,実行権限を付与する必要があります.

一方で,DockerfileにSAGEMAKER_PROGRAMの環境変数を設定すれば,ここで指定したプログラムが実行されます.参考までにDockerfileの記述例を載せておきます.

ただし,sagemaker-trainingのpythonライブラリをインストールしていないと,正常に動作しなくなるので注意して下さい!

FROM python:3.8

# Set some environment variables.
# PYTHONUNBUFFERED keeps Python from buffering our standard
# output stream, which means that logs can be delivered to the user quickly.

ENV PYTHONUNBUFFERED=TRUE

# PYTHONDONTWRITEBYTECODE keeps Python from writing the .pyc files which
# are unnecessary in this case.
ENV PYTHONDONTWRITEBYTECODE=TRUE

RUN apt-get -y update && apt-get install -y --no-install-recommends \
    curl \
    sudo \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

# Install 'sagemaker-training' library
COPY requirements.lock /tmp/requirements.lock
RUN python3 -m pip install -U pip && \
    python3 -m pip install -r /tmp/requirements.lock && \
    python3 -m pip install sagemaker-training && \
    rm /tmp/requirements.lock && \
    rm -rf /root/.cache

# Timezone jst
RUN ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime

# Locale Japanese
ENV LC_ALL=ja_JP.UTF-8

# Set up the program in the image
ENV PROGRAM_DIR=/opt/program
COPY src $PROGRAM_DIR
WORKDIR $PROGRAM_DIR
ENV PATH="/opt/program:${PATH}"

# SageMaker Training
RUN chmod +x $PROGRAM_DIR/train.py
ENV SAGEMAKER_PROGRAM $PROGRAM_DIR/train.py

CMD ["python3"]

以下の記事が参考になったので,挙げておきます.

  1. Amazon SageMakerで独自アルゴリズムを使ったトレーニング(学習)の作り方
  2. SageMakerで独自アルゴリズムを使う

Experimentsを使った実験管理

今回はSageMaker Studio (Jupyter Labのインターフェース)を使って実験を行う場合を想定しています.

  • SageMaker Experiments SDKをインストールしていない場合は,インストールします.
    • pip install sagemaker-experiments
# 必要なライブラリのインポート
import time
import boto3
import sagemaker
from sagemaker import get_execution_role
from sagemaker.inputs import TrainingInput
from sagemaker.estimator import Estimator
from smexperiments.experiment import Experiment
from smexperiments.trial import Trial
from smexperiments.trial_component import TrialComponent
from smexperiments.tracker import Tracker
from sagemaker.analytics import ExperimentAnalytics

# ロールやセッションの設定
role = get_execution_role()
sess = sagemaker.Session()
sm = boto3.Session().client('sagemaker')
region = boto3.session.Session().region_name
account = sess.boto_session.client('sts').get_caller_identity()['Account']
print(f'AccountID: {account}, Region: {region}, Role: {role}')

# ECRにあるコンテナを指定
service_name = <service name>
tag = 'mlops'
image = f'{account}.dkr.ecr.{region}.amazonaws.com/{service_name}:{tag}'
print('Image:', image)

SageMaker Experimentsには,Experiment・Trial・Trial Componentsがあり,左側から順番に上位の概念(クラス)になっています.これにプラスして,Trial Componentsに実験の情報などを記録することができるTrackerというものがあります.SageMaker Experiments SDKというSDKが用意されているので,これを用いてコードに組み込んでいきます.

例えば,あるプロジェクトを考えてみると...

  • 1つのExperimentを作成する(これが1プロジェクトに相当)
    • experiment_nameにはこの実験のプロジェクト名を付けるイメージ
    • 一度作成すると同一名称では作成できない
      • UIから削除できないので,注意が必要(コマンド実行で削除できるが,下の階層にあるデータを削除してからでないとExperimentの削除ができない)
# experimentの作成
experiment = Experiment.create(experiment_name="mlops-experiment01", description="Sample Experiments for MLOps.", sagemaker_boto_client=sm)
  • Tracker.createでTrackerを作成し,それを用いてExperimentのメタデータを記録&追跡
    • log_parameters, log_input, log_output, log_artifact, log_metricがあります
      • 予め定義しておくものを必要に応じて追加します
# trackerの作成
with Tracker.create(display_name=f"tracker-{int(time.time())}", sagemaker_boto_client=sm) as tracker:
    tracker.log_input(name="input-dataset-dir", media_type="s3/uri", value='s3://mlops/input/')
    tracker.log_input(name="output-dataset-dir", media_type="s3/uri", value='s3://mlops/output/')
  • Trial.createで実行するTraining JobごとにTrialを作成
    • Experimentに紐づく実験単位
    • Training JobのTrialを作成し,Tracker情報を追加
# trialの作成
trial_name = f"training-job-{int(time.time())}"
experiments_trial = Trial.create(
    trial_name=trial_name,
    experiment_name=experiment.experiment_name,
    sagemaker_boto_client=sm,
)

# trial_componentの付与
sample_trial_component = tracker.trial_component
experiments_trial.add_trial_component(sample_trial_component)

以下の記事は今回の記事のようにExperimentsを使用した記事になっていて,とても参考になりました.

  1. SageMaker Experimentsによる実験管理とQuickSightを使ったその可視化

Estimatorを定義して実行する

モデル作成を行うために,Estimatorを定義します.Estimatorクラスの引数にmetric_definitionshyperparametersを渡すことで学習ログのメトリクスとハイパーパラメータを記録することができます.

  • metric_definitions: 正規表現を用いて学習ログからメトリクスを抽出できる(参考: Define Metrics
  • hyperparameters: trainスクリプト内でArgumentParserを用いてパラメータを渡せるようにすることで,セットしたハイパーパラメータを使って実験を行える
    • sagemaker.estimator.Estimator().set_hyperparameters()でハイパーパラメータをセットできる

例えば,メトリクスとしてRMSEを使って学習する場合,metric_definitionsには以下のような正規表現を入れておくとログを取得することができます.(ただし,この辺りはtrain.pyの中でどのようにログを出力しているかにも依るので,適宜自身のコードに合わせて修正が必要になります)

# S3に保存されているデータのパス
s3_train_data = sagemaker.inputs.TrainingInput(
   s3_data=<S3のデータセットパス>,
)

# モデル作成
estimator = Estimator(
    image_uri=image,
    role=role,
    instance_count=1,
    environment={"PYTHON_ENV": "dev"},
    instance_type="ml.m5.large",
    sagemaker_session=sess,
    output_path=<モデルのアウトプットパス>,
    base_job_name="training-job",
    metric_definitions=[
        {'Name': 'Train Loss', 'Regex': 'train_loss: (.*?);'},
        {'Name': 'Validation Loss', 'Regex': 'val_loss: (.*?);'},
        {'Name': 'Train Metrics', 'Regex': 'train_root_mean_squared_error: (.*?);'},
        {'Name': 'Validation Metrics', 'Regex': 'val_root_mean_squared_error: (.*?);'},
    ],
)

# ハイパーパラメータのセット
estimator.set_hyperparameters(
    epochs=15,
    batch_size=1024,
    learning_rate=0.1,
    momentum=0.9,
    embedding_factor=20
)

training_job_name = f"estimator-training-job-{int(time.time())}"
estimator.fit(
    {'train': s3_train_data},
    job_name=training_job_name,
    # trialの情報を指定
    experiment_config={
        "ExperimentName": experiment.experiment_name,
        "TrialName": experiments_trial.trial_name,
        "TrialComponentDisplayName": estimator_trial_component.display_name,
    },
    wait=True,
)

こんな感じのログが出ると学習が始まっています.(wait=Trueを設定した場合のみ)

学習プロセス

上手く学習が回って終了すると下図のように(Experiments and trialsから該当のTrial Componentsを見る),MetricsやParametersに指定した値が取れていることを確認することができます.

Experiments and trialsの結果画面 - Metrics

Experiments and trialsの結果画面 - Parameters

Experiment Analyticsで結果をDataFrameで確認

Experimentsに記録されているメタデータをDataFrameで確認することができるのがExperiment Analyticsになります.

  • experiment_nameを引数に指定することで,実験結果を取得しDataFrame表示することが可能
  • デフォルトだと全件取得されるので,欲しい実験だけフィルターして取得することも可能
trial_component_analytics = ExperimentAnalytics(
    experiment_name="mlops-experiment01",
    search_expression={
        "Filters":[{
            "Name": "DisplayName",
            "Operator": "Equals",
            "Value": "hogehoge"
        }]
    },
)
analytic_table = trial_component_analytics.dataframe()
display(analytic_table)

Experiment Analyticsの結果画面

(おまけ)Trainコード(train.py)のTips

最後にEstimatorで実行されるtrain.pyのコードを書く際のTipsを載せておこうと思います.

Estimatorの機能の1つに,「Estimator.fit()のinputs引数で指定したデータはdocker上の'/opt/ml/input/data'に同期される」というものがあります.

例えば,Estimator().fit(inputs={'train': s3_train_data})とすると,データは'/opt/ml/input/data/train'配下にs3_train_dataで指定したデータセットが全て配置されるという形です.(ファイル指定した場合はそのファイルが,ディレクトリ指定した場合はディレクトリ以下のファイルが全て配置されます)

このことを知っていると,コード中にS3からデータをダウンロードする処理を書いている場合,そういったダウンロード処理が不要になるので便利です!

s3_train_data = sagemaker.inputs.TrainingInput(s3_data='s3://mlops/input/train.csv')
Estimator().fit(inputs={'train': s3_train_data})
→ S3にある'train.csv''/opt/ml/input/data/(inputsに指定した辞書のkeyが入る)/train.csv'に配置される

dataset = '/opt/ml/input/data/train/train.csv'
train = pd.read_csv(dataset)

参考

  1. aws/amazon-sagemaker-examples - train

今後について

学習部分に関して実施していきたいことが大きく2つあります.

  1. Step FunctionsにSageMaker CreateTrainingJobを組み込む
    • 現在,毎日レコメンドエンジンの学習が回っており,それをStep Functionsのパイプラインで実行しています.その際にSageMaker CreateProcessingJobを使っているのですが,これだと実験管理が十分にできないため,それをTraining Jobに置き換えて実行するというものです.これによりExperimentsに情報を蓄積できるため学習のログを簡単に可視化したり,オンラインのビジネス指標と比較したりすることもできます.
  2. SageMaker Pipelinesの活用
    • ProcessingJobやTraining Jobを組み合わせることで,パイプラインを構築することができます.これを用いるとDAGによるフローの可視化をすることができたり,構築したパイプラインをそのままデプロイすることもできます.将来的にSageMakerでServingするところまで考えるとこの辺りの使用感を理解しておきたいところです.

おわりに

今回はSageMaker Experimentsを活用して,機械学習モデルの実験管理をしているということを紹介しました.自分自身がカスタムコンテナを使って学習実行して完了するまで少し苦労したので,この記事が参考になればと思います.

前回紹介したML Test Scoreの改善に向けてAWSのマネージドサービスを上手く活用しながら,今後も引き続きMLOpsを推進していきたいと思います.

最後に,私たちのチームではデータを活用したサービス開発を一緒に推進してくれるデータエンジニアを募集しています. もっと話を聞いてみたい方や、少しでも興味を持たれた方は,ぜひ一度カジュアルにお話させてもらえると嬉しいです.(僕宛@asteriamにTwitterDM経由でご連絡いただいてもOKです!)

hrmos.co

参考(再掲)

ママリの WebView を JavaScript + Flow から TypeScript に移行しました

これは コネヒト Advent Calendar 2021 11日目の記事です。

こんにちは! フロントエンドエンジニアのもりやです。 今回はママリのアプリ内で使われている WebView を JavaScript + Flow から TypeScript に移行した事例を紹介します。

WebView の課題

今までママリ内で使われている WebView は JavaScript + Flow で実装されていました。

しかし State of JS 2020 の結果からも分かるように現在は TypeScript の人気が高く、実際コネヒトでも新規プロジェクトでは TypeScript が使われています。 開発体験としても TypeScript の方がよく、ツールチェインやライブラリの型定義の充実度も圧倒的です。現在、新規で何かを作るなら Flow を選ぶ積極的な理由はないと私は思います。

また Flow の問題ではないですが、以前から // @flow の漏れなどで Flow のチェックが上手く機能してなさそうという課題もありました。 ここだけ直すこともできますが、全体を直すなら合わせて TypeScript にしたいとなりました。

これらの理由により、WebView を JavaScript + Flow から TypeScript 化する流れになりました。

TypeScript 化の概要

2021年4月〜12月にかけて実施したプロジェクトです。 ただし @ts-ignoreany などでエラーを抑制している部分もあり、完全に移行が終わったわけではありません。 まだ Storybook など一部 JavaScript ファイルが残っている状況です。

TypeScript 移行中でも、開発は並行して行っていました。 対応する開発者も、メインとなる開発業務とは別に、業務時間の 10% 程度の時間をとって進めていました。

全部で4名の開発者が関わっています。 私はその中でも中心的な役割で、作戦を立てたり初期設定を主導して進め、TypeScript 化の作業も半分以上をやっていました。

TypeScript 化の規模

tokei で計測してます。

TypeScript 化前

言語 ファイル数 行数
JavaScript 387ファイル 20,546行
TypeScript 0ファイル 0行

TypeScript 化後

言語 ファイル数 行数
TypeScript 448ファイル 30,140行
JavaScript 31ファイル 1,175行
  • ファイル数が増えているのは、TypeScript 化の途中でも機能開発していたためです。

TypeScript 化の作戦

一括置換(失敗)

まず最初に、拡張子と変換が必要な特定の型定義パターン(例: ?stringstring | null | undefined に変更するなど)を機械的に変換する方法を試しました。

結論から言うと、これは失敗に終わりました。 原因としては、Flow がきちんと機能していない、という課題に起因しています。

一見定義されて動きそうに見えても @flow の定義漏れなどで any のように扱われてしまっている箇所がいくつもありました。 そのため TypeScript 化し、きちんとチェックが走ることによってエラーが多発してしまうという状況でした。 ある程度機械的に置き換えられるものを置き換えた後でも数百件のエラーがありました。

また型を外すということも考えましたが、Flow の定義が役に立つ場面もあり、これまでの資産がなくなってしまうのもやめたい、という事情もありました。 (Flow を導入していない、純粋な JavaScript であればもっと簡単だったと思います)

そういった事情から、一度に全部を変換することもそれをレビューすることも難しいですし、リリースしても何かが起きれば一気にふりだしに戻ってしまうので、この作戦は諦めました。

一つずつ手動変換(採用)

最終的に開発者が一つずつ JavaScript を TypeScript に変換していく作戦にしました。 数も多いので大変なことは予想していましたが、これが現状取れる手の中で最善と判断しました。

TypeScript 化の対応

ファイルを一つずつ TypeScript 化していく方針を立てたので、次は TypeScript と JavaScript + Flow を共存していくための対応をしました。

WebView はアプリ内で変更が多い画面などによく使われており、長期間開発を止めるのは難しいので、混在した状態でもビルドができるようにしなければならないためです。

Webpack の設定

まずビルドで使っている Webpack の設定を更新します。

依存パッケージに typescriptts-loader を追加し、webpack.config.js に以下の設定を追加します。

+ {
+   test: /\\.tsx?$/,
+   include: path.resolve(__dirname, 'assets'),
+   use: ['babel-loader', 'ts-loader']
+ },

これで .ts .tsx ファイルをビルドできるようになりました。

import 文の対応

JavaScript + Flow ↔ TypeScript 間でファイルを import しようとするとそれぞれ型エラーが出ます。 それぞれ以下の方法でエラーを抑制しました。 (抑制しただけで、それぞれの間で型情報を引き継げるわけではありません)

JavaScript から TypeScript のファイルを参照する場合

この場合は Flow がエラーを出します。

対応としては、まず TSFlowStub.js.flow というスタブ用のファイルを配置して、中身を以下のようにします。

export default {};

そして .flowconfig に以下の指定を追加します。

+ module.name_mapper.extension='ts' -> '<PROJECT_ROOT>/TSFlowStub.js.flow'
+ module.name_mapper.extension='tsx' -> '<PROJECT_ROOT>/TSFlowStub.js.flow'

最後に JavaScript ファイルを読み込む時に、拡張子 .ts または .tsx を参照します。 すると Flow は自動的に TSFlowStub.js.flow の型を参照してくれるので、エラーが出なくなります。

import foo from './foo.ts'

TypeScript から JavaScript のファイルを参照する場合

このパターンは @ts-ignore で抑制しました。 (これで Webpack はエラー無くビルドしてくれました)

// @ts-ignore
import foo from './foo'

自動テストの対応

一旦テスト自体を止める、という判断をしました。

共存する設定も試してみたのですが、大掛かりになって時間がかなり掛かりそうでした。

もともと10ファイル程度しかなく、あまり変更が入らないものが多かったので、共存する設定をするよりも止めておくほうがよいと判断しました。

スクリーンショット比較テストの導入

先に書いたとおり、ママリの WebView のフロントエンドのテストはほとんど無く、開発者による手動テストに頼っていました。 なので、TypeScript 化による意図しない変更に気付けるようなテストを入れておきたいと考えました。

ただテストの導入にコストをかけすぎると TypeScript 化が進まないので、なるべくコスパ的に良いテストを探していました。 検討した結果、主要な画面のスクリーンショットをとって、変更がないかをチェックするものであれば導入のコストが低く、効果も高いと考えました。

具体的には cypresscypress-image-diff-js というライブラリを使って、Chrome で主要な画面をスクリーンショットでの比較をするテストを、Pull Request ごとに GitHub Actions 上で実行するようにしました。 (キャンペーン用ページなど一時的に使って、今は使わない画面などはチェックの時間が増えるだけなので除外しました)

これにより、主要な画面が表示できて変更がないことを自動でチェックできるようになり、一定の安心感ができました。 また、テストが失敗した場合でもスクショと差分をアーティファクトとしてアップロードするようにしたので、どこが失敗したのかも見れるようにしています。

ちなみに WebView なので、当初は TestCafe を使って iOS の環境に近い Safari 上でのテストもしたかったのですが、時々テストが止まってしまうという不具合が起きていたので、一旦諦めました。 毎回ではなく時々止まる、という症状で原因の特定が難しく、あまり時間もかけたくなかったので、詳しい原因までは探れていないです。

ESLint のアップデート

弊社では @connehito/eslint-config という ESLint の設定を OSS として公開していて、Flow 用の v1 系から、TypeScript に対応した v2 系にアップデートしました。

Flow と TypeScript が共存していて書き方が違うものなので、そのあたりを考えてやりました。 1つの Pull Request でやってしまい、かなり Diff が大きくなってしまったのは反省ポイントです・・・。(ほんとすみません。レビューありがとうございました)

https://mryhryki.com/file/bWB6Ftu3-y5cGc2BBpwbLxRvAnhEl-7.png

↓ Pull Request に大まかな変更のポイントを書いていましたので、ついでに載せておきます。

https://mryhryki.com/file/bWB6G3hu0ChMHuS5xdkMMeDTBvhpmf7.png

ちなみに、今までは CI で ESLint によるチェックが行われていなかったので、CI でチェックする対応もしました。

TypeScript 化の作業方針

最初の設定が終わってしまえば、あとは JavaScript + Flow のファイルを TypeScript に変更していくだけです。 ここでは実際に作業する中でできた方針について書いていきます。

依存関係が少ないものから順にやる

他のファイルへの依存がない・少ないファイルの方がやりやすいので、分担してそういったファイルから進めてていきました。

具体例としては、API リクエストやユーティリティ関数などを最初に進めました。

型定義をしながら進める

当初は anyobject などで曖昧になっていた部分を、なるべく型定義を追加しつつ TypeScript 化していきました。

特に API レスポンスは、今まで Markdown でドキュメント化はしていたものの、コード上では何の型定義もできていませんでした。

ここの定義を追加することで、アプリ内で使われるデータのチェックや IDE による補完も効くようになり、かなり開発体験が向上しました。

方針転換:とにかく進める

最初は型定義を拡充していましたが、本丸である UI 系のコードに入ってくると型定義的に不整合が起きる場面が増えてきました。Flow がきちんとチェックされていない影響は、特にこの辺りで大きかったです。

具体例として、例えば配列が渡ってくる場合 JavaScript のプリミティブな配列なのか Immutable.js のリストなのかが合致していない、などがありました。 極端な場合、どちらが来ても動くようになってて、動かしてログに出さないとどちらを期待しているのかがわからないという状況などもありました。

UI のコードは一番ファイル数的に大きいので、あまりコストを掛けてやると終わらない見通しになってきました。 また影響範囲もその画面、その部分でとどまる場合が多いので、一旦は動いているものを正として、 eslint-disable@ts-ignore でのチェック抑制をして進めました。

きちんとした対応をしようとすると、おそらく今年度中には終わっていなかったと思います。

おまけ:ネイティブとの連携箇所でのエラー

WebView から FireBase の機能を呼び出す際は、ネイティブ側と連携して動作しています。 その呼び出し関数を、一度変数への代入を使うとエラーが発生するという不具合がありました。

// iOS の例
const { postMessage } = window.webkit?.messageHandlers?.firebase ?? {}
if (postMessage != null) {
  postMessage(...)
}

推測ですが window.webkit.messageHandlers.firebase.postMessage という関数が呼ばれた時にネイティブ側がハンドリングしているものと思われます。

しかし ?. を使うと、一度変数に入れるようなコードに変換されてしまいます。

https://mryhryki.com/file/bWB6GRucQB0EEZasOO9yMXnP3aCtbYR.png

そのため window.webkit.messageHandlers.firebase.postMessage が呼ばれたとネイティブ側で判断できず、エラーになってしまうのだと考えられます。

解決策としては、以下のように順にチェックをしておくと特にエラーにならず正常に動作するようになりました。

if (
  window.webkit &&
  window.webkit.messageHandlers &&
  window.webkit.messageHandlers.firebase &&
  window.webkit.messageHandlers.firebase.postMessage
) {
  window.webkit.messageHandlers.firebase.postMessage(/* ... */)
}

アプリ内で使う WebView ならではの不具合でした・・・。

ちなみに以下のようにすれば大丈夫そうな気もするんですが、試してはいません。

if (window.?webkit.?messageHandlers.?firebase.?postMessage) {
  window.webkit.messageHandlers.firebase.postMessage(/* ... */)
}

後始末

Flow の除去

Flow 関連のパッケージの除去や設定ファイル、スタブ用ファイル ( TSFlowStub.js.flow ) を削除しました。

これで完全に Flow への依存がなくなりました。

不要な抑制コメントの除去

TypeScript 化が終わったことで不要になった eslint-disable@ts-ignore を除去していきました。

やった人はすぐ分かりますが、あとから見る人は何のためのものなのか判断に困るので、こういうお掃除はなるべく早く対応しておきたいですね。

テストコードの TypeScript 化

一時的に止めていたテストを TypeScript 化し、動かせるようにしました。

また WebView ではテストフレームワークとして ava を使っていたのですが、コネヒトでは基本的に jest を使っていたので、統一して jest に移行しました。

State of JS 2020 でみても Jest の人気が高いというのもあります)

https://mryhryki.com/file/bWB6GgZmC8t7mer1yGoRu5A42Xk9vDJ.png

感想など

長い戦いでした(まだ終わってないけど)が、ようやく終わりが見えてホッとしています。 ちゃんとやりたい部分もありつつ、時間との兼ね合いがあるので、どこまでやってどこはやらないかを判断するのが大変でした。

TypeScript に統一できたことによって、開発体験としてはかなり良くなりました。Flow でも一定サポートはしてくれますが、やはり TypeScript のツールチェインは充実しています。

まだ型定義がちゃんとできていない部分なども残っていますが、今後を開発を進めながら地道に健全な状態にしていきたいと思います。

PR

コネヒトでは、フロントエンド開発のモダン化に挑戦したいエンジニアも募集中です!

hrmos.co