コネヒト開発者ブログ

コネヒト開発者ブログ

Tableauのパラメーターを横並びのラジオボタンにする拡張機能をつくった

こんにちは!コネヒトのエンジニアあぼです。この記事はコネヒト Advent Calendar 2021の24日目です。

今回は、コネヒトでデータ分析や新規プロダクトに使われているTableauの拡張機能をつくったので紹介します。

つくったもの

github.com

この拡張機能は、ダッシュボード上から参照できるパラメーターを1つ、横並びのラジオボタンとして表示するシンプルな拡張です。

「構成」からダイアログを開くと、ダッシュボードから参照できるパラメーターのうち許容値がリストであるものが表示されるので、この中から横並びのラジオボタンにしたいパラメーターを選択します。すると、パラメーターを画像のように横並びで表示することができます。

f:id:aboy_perry:20211223171641p:plain

ちなみにダッシュボード上に拡張機能オブジェクトを複数設置すれば、複数のパラメーターにも対応できます。.trexファイルをダウンロードすれば誰でも使えるので、ぜひ使ってみてください! 💪

つくったきっかけ

業務で必要になったのがきっかけです。Tableauでは許容値がリストであるパラメーターはラジオボタンのようなUI(単一の値のリスト)で表示することができますが、横並びにするような設定がありません。

f:id:aboy_perry:20211223171813p:plain
許容値がリストであるパラメーター。この場合Order ID, Order Date, Customer Nameの3種類の文字列を取りうる。

ダッシュボードのスペースやUI設計の都合上、横並びにしたかったため方法を探していました。Tableauコミュニティでも同様の相談が複数見受けられました。できないからといってクリティカルではないものの、どうにかできないかな〜と考えていました。

こういった公式がまだ機能として提供していないものについては、Tableauのコミュニティでtipsが共有されたりしています。今回のラジオボタンの横並びについてもコミュニティでtipsを見つけることができました。具体的にはこちらのYouTubeの動画や、こちらのナレッジベースのように、ワークシートやダッシュボードアクションを駆使することで横並びのラジオボタンをつくることができるというもので、これはこれで素晴らしいのですが、

  • 多少手間がかかる
  • リストの値を取るデータフィールドがないようなデータ構造だとできない

という問題がありました。前者はしょうがないとしても、今回のきっかけとなったデータ構造は後者に該当するためこの方法ではできませんでした。後者は例えば動画内で出てきた計算フィールドSTR([Region Parameter] = [Region]) + [Region]において、RegionRegion Parameterで定義したリスト内の値をとるデータでなければいけません。Region ParamerterEast, West, Central, Southの4つの文字列を許容値とするリストならば、その4つの文字列を取りうるRegionというデータフィールド(列)が必要ということです。

f:id:aboy_perry:20211223172038p:plain
Tableau付属のデータセット「Superstore」のRegionのようなデータ構造なら可能

そこで、Tableauが提供しているもう一つの選択肢、拡張機能を使うことにしました。ユーザーはTableauのギャラリー(Tableau公認*1)や、ユーザーコミュニティポータルで公開されている拡張機能を探してダッシュボードに組み込めるほか、Tableau Extension APIを活用して自作することもできます。

今回はニーズに合う拡張機能が見つからなかったので自作しました。拡張機能をつくるには、Tableauの知識、Tableau Extension APIの知識、多少のWebアプリケーション開発の知識が必要になります。今回のようなシンプルな拡張機能であれば比較的簡単につくることができました。

以降では、今回の拡張機能をつくるうえで出てきた実装をいくつか紹介します。

実装

Tableau拡張のつくり方の詳細は公式のドキュメントにまとまっていますのでご覧になってみてください。今回JavaScriptのライブラリは、公式サンプルにも登場するjQueryを利用しました。

ダッシュボード内のパラメーターを取得する

ダッシュボード内のパラメーターはdashboardContent.dashboard.getParametersAsync()で取得できます。その中から、許容値がリストであるパラメーターに絞り、inputやlabelを作り反映させています。

tableau.extensions.dashboardContent.dashboard.getParametersAsync().then((params) => {
    params
        .filter((p) => p.allowableValues.type === tableau.ParameterValueType.List)
        .forEach((p) => {
            const hElement = $('<h3>')

            $('<input />', {
                type: 'radio',
                id: p.name,
                name: 'HorizontalRadioButton',
                value: p.name,
            }).appendTo(hElement)

            $('<label>', {
                for: p.name,
                text: p.name,
            }).appendTo(hElement)

            $('#parameters').append(hElement)  
        )      
})

パラメーターとラジオボタンを同期させる

拡張機能側のラジオボタンを押したらパラメーターの値も変わるようにする部分です。また、Tableauはダッシュボードアクションなどでパラメーターに作用できるので、ダッシュボード側の操作によってパラメーターが変わったことを拡張機能側で検知して自身のラジオボタンに反映させられるように、つまり双方向に同期されるようにします。

拡張機能からパラメーターへの反映はparameter.changeValueAsync()で行い、パラメーターから拡張機能への反映はパラメーターへイベントリスナーを登録して行います。

tableau.extensions.dashboardContent.dashboard.getParametersAsync().then((parameters) => {
    const selectedParameter = parameters.find((p) => p.name === savedValue)
    // パラメーターの変更検知
    selectedParameter.addEventListener(tableau.TableauEventType.ParameterChanged, onParameterChange)

        const parameterValuesElement = $('<div id="parameter">')
        selectedParameter.allowableValues.allowableValues.forEach((dataValue) => {
        const eachValueElement = $('<div style="display: inline-block">')

        $('<input />', {
            type: 'radio',
            id: dataValue.value,
            name: selectedParameter.name,
            value: dataValue.formattedValue,
            checked: dataValue.value === selectedParameter.currentValue.value,
                        // 拡張機能 => パラメーター へ同期
            click: () => selectedParameter.changeValueAsync(dataValue.value),
        }).appendTo(eachValueElement)

        $('<label>', {
            for: dataValue.value,
            text: dataValue.formattedValue,
        }).appendTo(eachValueElement)

        parameterValuesElement.append(eachValueElement)
    })
    $('#parameter').replaceWith(parameterValuesElement)
})

// パラメーター => 拡張機能 へ同期
const onParameterChange = (parameterChangeEvent) => {
    parameterChangeEvent.getParameterAsync().then((p) => {
        $(`input:radio[value="${p.currentValue.formattedValue}"]`)
            .prop('checked', true)
    })
}

設定をワークブックに保存し永続化する

拡張機能の実態はホスティングされたWebサイトなので、ワークブックの再読み込みで初期化されます。Tableauの拡張機能は基本的に構成ダイアログから設定を変更できるように作りますが、その設定を保存するためのコードを書く必要があります。

設定はkey-value形式で保存でき、ワークブック単位で保持されます。構成ダイアログをひらく場合はtableau.extensions.ui.displayDialogAsync()、とじる場合は tableau.extensions.ui.closeDialog() も呼んであげます。どちらも引数にはpayloadを渡せますが、今回は設定の読み込みと書き込みは全てtableau.extensions.settings経由で行いたかったので、payloadは空文字でも問題ありません。*2

// 構成ダイアログをひらく
tableau.extensions.ui.displayDialogAsync('config.html', payload)

// 設定をセット
tableau.extensions.settings.set('key', value)
// 構成をワークブックに保存
tableau.extensions.settings.saveAsync().then(() => {
    // 構成ダイアログをとじる
    tableau.extensions.ui.closeDialog(value)
})

keyを指定してsettings.get()で保存された値を取得できるほか、イベントリスナーの登録によって設定が変更されたことを検知できます。拡張機能や構成ダイアログの初回読み込み時は settings.get()、以降はイベントリスナー経由で値を取得するという使い分けになります。

tableau.extensions.initializeAsync({'configure': configure}).then(() => {
    // 初回読み込み時はここで保存された値を取得
    savedValue = tableau.extensions.settings.get('key')
    // イベントリスナーを登録しておいて
    tableau.extensions.settings.addEventListener(tableau.TableauEventType.SettingsChanged, onSettingsChange)
})

const onSettingsChange =  (settingsEvent) => {
    // 最新の値を取得
    savedValue = settingsEvent.newSettings.key
    // UIの更新など
}

checkedのときにinputが表示されない

inputがcheckedのときに消えてしまう不具合がありました。下の画像では「Order Date」がcheckedなのですが、ラジオボタンの丸ポチが消えてしまっています。

f:id:aboy_perry:20211223172324p:plain

原因は特定できなかったため、結局CSSを別途当てることにしました。input[type=radio]に対してdisplay:noneを当てて、labelのbefore/afterに対してスタイルを当てて擬似的にラジオボタンのように見せて対応しました。

おわりに

拡張機能を上手く活用すれば、Tableau Extension APIとWebでできることはだいたいできるので、Tableauの可能性が広がりますね。拡張機能は開発するうえでTableauの理解も深まりますし、コミュニティにも貢献できるのでオススメです!

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」になります.

f:id:connehito-mkashiwagi:20211210183455p:plain
データ分析環境のアーキテクチャー概略図

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

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


目次


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を設定した場合のみ)

f:id:connehito-mkashiwagi:20211211190419p:plain
学習プロセス

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

f:id:connehito-mkashiwagi:20211211190551p:plain
Experiments and trialsの結果画面 - Metrics
f:id:connehito-mkashiwagi:20211211190650p:plain
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)

f:id:connehito-mkashiwagi:20211211191147p:plain
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

参考(再掲)