NearMe Tech Blog NearMeの技術ブログです 2024-03-22T12:18:00+09:00 nearme-jp Hatena::Blog hatenablog://blog/26006613720905581 「シェア乗り時刻表」について hatenablog://entry/6801883189092435160 2024-03-22T12:18:00+09:00 2024-03-22T12:53:45+09:00 はじめに 先日、空港送迎のシェア乗りサービスの空席を埋めるための時刻表機能をリリースしました。 構想は昔からあったのですが、ようやく開発を進めて世に出すことができました。 ここではその機能概要やシステムについて説明します。 時刻表について NearMeではユーザー同士がタクシー/シャトルの車両をシェアして乗車するサービスを構築してきました(※同じシェアですが、昨今、ドライバー不足で議論されている"ライドシェア"は一般ドライバーと乗客のマッチングによるもので、それとは異なります)。 特に、空港送迎のような事前に予約するシナリオにおいて運行効率を高める機能を作ってきました。 ユーザー同士のマッチン… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20240321/20240321102628.png" width="1200" height="630" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="はじめに">はじめに</h2> <p>先日、<a href="https://app.nearme.jp/airport-shuttle/">空港送迎のシェア乗りサービス</a>の空席を埋めるための<a href="https://app.nearme.jp/airport-shuttle/timetable/">時刻表機能</a>をリリースしました。 構想は昔からあったのですが、ようやく開発を進めて世に出すことができました。 ここではその機能概要やシステムについて説明します。</p> <h2 id="時刻表について">時刻表について</h2> <p>NearMeではユーザー同士がタクシー/シャトルの車両をシェアして乗車するサービスを構築してきました(※同じシェアですが、昨今、ドライバー不足で議論されている"ライドシェア"は一般ドライバーと乗客のマッチングによるもので、それとは異なります)。</p> <p>特に、空港送迎のような事前に予約するシナリオにおいて運行効率を高める機能を作ってきました。 ユーザー同士のマッチングを最適化したり(<a href="https://tech.nearme.jp/entry/2021/07/02/112508">参考</a>、<a href="https://tech.nearme.jp/entry/2021/06/07/114010">参考</a>)、 車両と運行のマッチングも最適化して密な運行セットを作ったりしました(<a href="https://tech.nearme.jp/entry/2023/11/02/092607">参考</a>)。</p> <p>また、既にある運行の空席を埋める施策として、時間をずらしたら乗れるというレコメンド機能も実装しました(下図)。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20240321/20240321173834.png" width="1082" height="540" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> <span style="font-size: 80%">レコメンド表示の図。上が希望の運行。下がマッチした既存の運行。下は希望とずれる分、安くなっている。</span></p> <p>今回はその空席をさらに埋めるためのアプローチとして時刻表機能を開発しました。 既にある運行予定をの一覧を表示して、ユーザーは、多少希望の時間・位置がずれたとしても、その中からマッチする運行を選択できるようにしています。</p> <p>希望の地点を入力してマッチするものに絞り込りこんだり、</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20240321/20240321180517.png" width="1200" height="937" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><span style="font-size: 80%">時刻表にて地点を入力して運行を絞り込もうとするところ。</span></p> <p>運行を選択したら、マッチ可能なエリアが表示されたりします(※エリア内でも、諸々の事情で配車できないことがあります)。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20240321/20240321181723.png" width="776" height="722" loading="lazy" title="" class="hatena-fotolife" style="width:400px" itemprop="image"></span></p> <p><span style="font-size: 80%">ある運行に対してマッチング可能なエリアを表示。</span></p> <p>使い方としては例えば、空港で少し時間をつぶした後、途中の駅までシャトルで移動して、あとはタクシーで移動する、といったことが挙げられます。</p> <p>また、特に直前に予約する場合は時刻表が効果的です。 一般に、直前になるほど車両を一から手配するのは難しくなり、 既に運行予定がある車両に追加で乗る方が手配しやすく、コストも安く済むからです。</p> <h2 id="システムについて">システムについて</h2> <p>この機能のアナロジーはバスの時刻表からきてますが、 それがタクシー/シャトルのような、よりダイナミックなものに対して適用しているところが新しい点です。</p> <p>時刻表の一覧表示自体は非常に単純です。 運行のリストを時間で絞り込んで、位置情報をマスクした上で表示するだけです。</p> <p>難しいのは、希望の地点を入力した時に、そこでマッチする可能性のある運行のリストに絞り込むところです。 また、希望の地点を入力しないで一覧から運行を選択したときに、 どこまでのエリアがマッチングできるのかを位置をぼかして表示するところも一工夫必要です。</p> <p>マッチング可能なエリアは、現実装においては、簡単な数式でポリゴンを算出し、複数経由地があったらポリゴンを合成するなどしています(※概算でエリアを求めているので改善の余地はあります)。</p> <p>また、位置ぼかしのため<a href="https://h3geo.org/">h3</a>というライブラリでポリゴンの<a href="https://h3geo.org/docs/core-library/restable">解像度</a>を落としたりしています。</p> <p>悩んだのはその結果を、データに保存するか、オンデマンドで計算するかです。 データに保存する場合は、検索はしやすくなるものの、状態の整合性を保つための処理が大変です。 一方で、オンデマンド処理は、検索の度にシステム負荷が高くなる可能性があります。</p> <p>今回は結果的にオンデマンド処理にしました。フィルタリングとキャッシュも利用して、処理速度は許容範囲と判断しました。</p> <p>とはいえ、JavaScriptでアルゴリズムを作ると、運行1件あたり数十〜百ミリ秒くらいかかることが分かりました。検索時に一気に数十件を処理することを考えると、最悪数秒かかることになります。これを既存のNode.js上で動かすとなると(シングルスレッドなこともあり)既存処理への影響が大きいです。</p> <p>なので、別サービスに切り出してRustで実装することにしました。結果として、Rustで実装することにより10倍くらい速くなることを確認しました(副次的ですが、イメージサイズ、メモリフットプリントもかなり小さく、サービスを増やすハードルは低かったです)。</p> <p>なお、正式な実装では単体テストも書きつつ、<a href="https://streamlit.io/">Streamlit</a>を用いてインタラクティブにAPIを検証するツールも作りました(下図)。このツールにより、単体テストでは見つけづらい、いくつかの不具合を発見しています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20240321/20240321210730.png" width="1200" height="923" loading="lazy" title="" class="hatena-fotolife" style="width:450px" itemprop="image"></span></p> <h2 id="今後の展望">今後の展望</h2> <p>今は空港送迎サイトにおける表示だけですが、今後は様々な利用シーンで使えるようにしていきたいと考えています。 例えば、登録した地点においてマッチする運行リストを表示できるので、ホテルのカウンターなどにQRを置いて空席を探すといったことをしたいです。 また、ユーザー数が比較的少ない地域のシャトルなどでも、マッチングの可能性を高める手段として期待しています。</p> <p>最後になりますが、NearMeではエンジニアを募集しています!まだまだ多くの可能性が潜んでいる領域です。興味を持った方はぜひ以下から応募いただければと思います。</p> <ul> <li><a href="https://corp.nearme.jp/jobs/">採用情報</a></li> </ul> <p><span style="font-size: 80%"><span style="color: #666666">Author: Kenji Hosoda</span></span></p> <p><a href="https://b.hatena.ne.jp/entry/" class="hatena-bookmark-button" data-hatena-bookmark-layout="basic-label" data-hatena-bookmark-lang="ja" title="このエントリーをはてなブックマークに追加"><img src="https://b.st-hatena.com/images/v4/public/entry-button/button-only@2x.png" alt="このエントリーをはてなブックマークに追加" width="20" height="20" style="border: none;" /></a><script type="text/javascript" src="https://b.st-hatena.com/js/bookmark_button.js" charset="utf-8" async="async"></script></p> nearme-jp 配車組作業(6時間 / 日)を自動化した話 hatenablog://entry/6801883189054943371 2023-11-02T09:26:07+09:00 2023-11-02T09:58:18+09:00 はじめに NearMeエンジニアの柿野上 拓真です。私は今年4月に新卒としてNearMeに入社いたしました。担当領域としては、主にデータサイエンスやMLOps、新規機能のPOCを担当しています。本記事では、私が設計・実装している自動配車システムの概要および今後の展望として深層強化学習の導入について解説します。 NearMeでは事前予約で注文を集めて、時間的・距離的に近い注文同士をAIによって自動的にマッチング(相乗り)させて乗車人数 / 移動距離を最大化しています。マッチングした注文の集合、あるいは、マッチングしなかった単独の注文をトリップと呼んでいます。車両へのアサイン(トリップに対して運行… <p><figure class="figure-image figure-image-fotolife" title="サムネ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kakky-takuma/20231031/20231031170545.png" width="1200" height="589" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption></figcaption></figure></p> <h2 id="はじめに">はじめに</h2> <p>NearMeエンジニアの柿野上 拓真です。私は今年4月に新卒としてNearMeに入社いたしました。担当領域としては、主にデータサイエンスやMLOps、新規機能のPOCを担当しています。本記事では、私が設計・実装している自動配車システムの概要および今後の展望として深層強化学習の導入について解説します。</p> <p>NearMeでは事前予約で注文を集めて、時間的・距離的に近い注文同士をAIによって自動的にマッチング(相乗り)させて乗車人数 / 移動距離を最大化しています。マッチングした注文の集合、あるいは、マッチングしなかった単独の注文をトリップと呼んでいます。車両へのアサイン(トリップに対して運行する車両を紐づけること)はトリップ単位で行います。自動配車システムでは、トリップを最適な車両にアサインし、効率的な運行計画を作成することを目標にしています。</p> <p>本記事では、自動配車システムを理解して頂くためのイントロとして、システムの概要および配車組ロジック、監視手法、そして、深層強化学習の導入計画という順で述べていきます。</p> <h2 id="自動配車システム構築の背景">自動配車システム構築の背景</h2> <h3 id="従来のオペレーションと課題">従来のオペレーションと課題</h3> <p>配車組とは、トリップを車両にアサインすることを繰り返し、運行計画を作成することを指します。配車組をする際、車両のキャパシティや運行可能時間、既にアサインされている時間的に近いトリップとの移動時間などを考慮する必要があります。従来は、配車組を人手によって行っていました。例えば<a href="https://app.nearme.jp/airport-shuttle/">羽田空港送迎</a>では1日3回(1回当たり約2時間)の配車組を行っていました。人手で配車組を行う際、現在の運行計画および新規トリップを表示する下記の様な画面上で作業します。</p> <p>従来のオペレーションには以下に挙げる課題が存在します。</p> <ul> <li>人手なので工数がかかる(1日約6時間費やす計算)</li> <li>担当者の技量によって作成する運行計画の良し悪しに差がある(上手な人はパズルの様にトリップを車両間で組み替えて1日でより多くのトリップを周れる「効率的な運行計画」を作る)</li> </ul> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kakky-takuma/20231101/20231101170213.png" width="1200" height="651" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> <span style="font-size: 80%">横軸は時間(0時〜23時)を表しており、各行が各車両の1日の運行計画に該当します。未アサイン欄にある赤色のトリップが新規トリップ(処理対象のトリップ)となります。 </span></p> <h3 id="自動配車による課題解決">自動配車による課題解決</h3> <p>上記の課題を解決するために自動配車システムを構築しました。自動配車システムによって、配車組の工数を削減できるだけでなく、人手だと発生していた配車ミスも防ぐことができます。加えて、注文確定までの応答時間を短くすることができるので、結果的にUXの改善も期待できます。</p> <p>また、「効率的な運行計画」を作成するために、「トリップの乗車人数」や「相乗りしやすさの予測値」などをもとにトリップをスコアリングし、スコアの高い順にアサインするといったルールベースのアルゴリズムも実装しました。現在は、深層強化学習を用いて、より最適な配車組を行うアルゴリズムの導入も進めています。</p> <p>配車組をシステムで自動化した場合、人手のオペレーションでは発生していた配車組の属人性の高さを排除できる上に、配車組画面のUI設計・構築の工数も削減できます。そして、「効率的な運行計画」を作成することによって収益向上も期待できます。</p> <h2 id="自動配車システムの概要">自動配車システムの概要</h2> <h3 id="自動配車システムの概略図">自動配車システムの概略図</h3> <p>自動配車システムは<a href="https://kubernetes.io/ja/">Kubernetes</a>上にデプロイされています。また、自動配車システムは、現行のNearMeのメインシステムとは独立に開発しています。自動配車システムは以下の一連のバッチ処理を定期的に実行します。</p> <ol> <li>NearMeのデータベースにアクセスして、車両データおよびトリップデータを取得します</li> <li>取得したデータに対して、次の章で解説する「配車組バリデーション」および「トリップスコアリング」を行い、アサインを実行するトリップIDと車両IDの組を求めます</li> <li>NearMeの配車情報を管理するAPIにアクセスし、アサイン処理を実行します(APIとのやり取りには<a href="https://graphql.org/">GraphQL</a>を用いています)</li> </ol> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kakky-takuma/20231102/20231102095805.png" width="1200" height="407" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="配車組バリデーションロジック">配車組バリデーションロジック</h3> <p>現在の自動配車システムでは、入ってきたトリップを車両のキャパシティや運行可能時間、前後のトリップとの間隔を考慮して自動でアサイン可能な車両を割り出し、配車管理APIを叩くことで車両アサインおよび承認処理を実行します。細かい考慮事項を挙げればキリがないですが、例えば以下に示す内容を考慮して配車組バリデーションを行っています。</p> <ul> <li>車両のキャパシティ(乗車定員と荷物積載容量)</li> <li>車両の運行可能時間</li> <li>同じ時間帯に既にトリップがアサインされていないか</li> <li>既にアサインされている前後の運行との移動時間</li> </ul> <p>また、各車両に対してどのサービスに属すトリップをアサインの対象にするかも設定可能です。具体的には、車両IDとサービスIDの紐付けを指定します。</p> <p>※サービスIDとは、例えば「羽田空港送迎」や「那覇空港送迎」のようなサービス単位で一意のIDのこと</p> <h3 id="トリップスコアリングロジック">トリップスコアリングロジック</h3> <p>効率の良い運行計画を作成するためにルールベースなトリップのスコアリングも行っています。以下の要素を複合的に考慮した上で、新規トリップをスコアリングし、高いスコアのトリップから順にアサインしています。「相乗りの発生しやすさの予測値」は既にクラウド上のエンドポイントにデプロイされている機械学習の予測モデルから結果を取得しています。</p> <ul> <li>人数</li> <li>荷物数</li> <li>前後の運行との間隔(間隔が中途半端に長いとスカスカな運行計画になってしまう)</li> <li>相乗りの発生しやすさの予測値</li> </ul> <h3 id="自動配車に関するパラメータ">自動配車に関するパラメータ</h3> <p>配車組を管理するオペレータは、管理画面のUI上からパラメータを設定・変更することによって自動配車システムの挙動を操作することが可能です。以下に設定可能なパラメータの例を挙げます。サービスによって自動配車システムに求められる要件が異なるので、将来的には<a href="https://jsonlogic.com/">JsonLogic</a>なども取り入れて柔軟なシステムにすることを目標にしています。</p> <ul> <li>自動配車を適用するかしないかのトグル</li> <li>自動配車を行う間隔(分)</li> <li>何日後のトリップまで自動配車の対象にするか</li> <li>自動配車の対象にする車両ID</li> <li>移動時間に対する補正係数</li> </ul> <h2 id="自動配車システムの監視方法">自動配車システムの監視方法</h2> <h3 id="自動配車システムの監視対象メトリクス">自動配車システムの監視対象メトリクス</h3> <p>自動配車システムが正常に稼働しているかの指標として、いくつかのメトリクスを定義しています。メトリクスは、日付とサービスID、車両IDの組ごとにラベリングして監視しています。メトリクスの定義に関しては、実際に自動配車システムを使いながら必要な値を洗い出していくアプローチを取っています。以下はメトリクスの例です。</p> <ul> <li>最後に処理を実行した時刻</li> <li>処理の実行時間</li> <li>処理中の内部エラーの回数</li> <li>アサインに成功したトリップ数</li> <li>アサインを試みたが配車管理APIからエラーが返されたトリップ数</li> <li>データ自体に問題があり処理できなかったトリップ数</li> </ul> <h3 id="Prometheus--Grafanaによる監視システム">Prometheus + Grafanaによる監視システム</h3> <p>上記のメトリクスを監視するために自動配車システムでは、<a href="https://prometheus.io/">Prometheus</a>および<a href="https://grafana.com/">Grafana</a>という監視プラットフォームを用いています。PrometheusはPull型の監視システムです。Grafanaはメトリクスをグラフなどでビジュアライズするために使っています。余談ですが、PrometheusおよびGrafanaについては社内勉強会でハンズオンを行ったので、その際の<a href="https://speakerdeck.com/nearme_tech/grafana-hanzuon">スライド</a>も是非ご覧ください。メトリクスは、以下の図の様にグラフ化されます。ダッシュボードを事前に作成しておけば、ブラウザからGrafanaにアクセスすれば直ぐに最新のメトリクスを見ることができます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kakky-takuma/20231101/20231101120453.png" width="1200" height="356" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="今後の展望">今後の展望</h2> <h3 id="深層強化学習の導入">深層強化学習の導入</h3> <p>深層強化学習を使って配車組を最適化するロジックを研究中です。深層強化学習は、巡回セールスマン問題(TSP)や配送計画問題(VRP)において、既存のアプローチよりも良い性能を発揮できる可能性があることが示されています(<a href="https://www.sciencedirect.com/science/article/abs/pii/S092523122200978X">論文1</a>、<a href="https://arxiv.org/pdf/2107.07076.pdf">論文2</a>)。現在、私は問題設定やアルゴリズムを自動配車に応用可能な形へと拡張し、学習実験を行っております。</p> <h2 id="おわりに">おわりに</h2> <p>本記事では、自動配車システムと効率的な運行計画作成に向けての機械学習の導入について述べてきました。個人的な感想ですが、自動配車システムの開発を通じてKubernetesやPrometheusなどのクラウドネイティブ時代の礎となる技術についても学べており、日々成長を実感できています!NearMeでは、週1で勉強会(<a href="https://speakerdeck.com/nearme_tech">資料</a>)を開催しており、自身が学んだ技術について他のエンジニアに説明したり議論することを通して、互いに切磋琢磨しています!最後になりますが、NearMeではエンジニアを募集しています!ご興味のある方はぜひ以下をご覧ください。</p> <ul> <li><a href="https://corp.nearme.jp/jobs/">採用サイト</a></li> </ul> <p><span style="font-size: 80%">Author: Takuma Kakinoue</span></p> nearme-jp ChatGPT + JsonLogicの可能性 hatenablog://entry/4207112889979053967 2023-04-10T12:07:10+09:00 2023-04-10T12:07:10+09:00 はじめに 技術ブログのネタとして、JsonLogicを利用した柔軟なシステムカスタマイズについて考えてはいたのですが、もう一捻り欲しいなというところで、最近話題のChatGPTと組み合わせたらどうだろうと思い試してみたところ、思いのほかいい感じの結果が得られたので、タイトルを改めて書いてみました。 JsonLogicについて JsonLogicは汎用的なルールエンジンで、現在、NearMeのサービスにおいて複雑な料金計算等で利用しています。例えば、ユーザーに提示する料金は、需給のバランスを整えるために、時間帯や配車までの時間等で金額を調整することがあります。また、配車コストを算出するために、タ… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20230410/20230410014729.png" width="1170" height="528" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="はじめに">はじめに</h2> <p>技術ブログのネタとして、<a href="https://jsonlogic.com/">JsonLogic</a>を利用した柔軟なシステムカスタマイズについて考えてはいたのですが、もう一捻り欲しいなというところで、最近話題の<a href="https://openai.com/blog/chatgpt">ChatGPT</a>と組み合わせたらどうだろうと思い試してみたところ、思いのほかいい感じの結果が得られたので、タイトルを改めて書いてみました。</p> <h2 id="JsonLogicについて">JsonLogicについて</h2> <p>JsonLogicは汎用的なルールエンジンで、現在、<a href="https://app.nearme.jp/airport-shuttle/">NearMe</a>のサービスにおいて複雑な料金計算等で利用しています。例えば、ユーザーに提示する料金は、需給のバランスを整えるために、時間帯や配車までの時間等で金額を調整することがあります。また、配車コストを算出するために、タクシー会社と契約する料金表があるのですが、これも、エリアや人数(大人、子供)、荷物の数、時間などの変数が複雑に絡んだものがあります。これらを計算するために、一つはハードコーディングして解決するという手もありますが、更新しにくく、スケールもしにくいです。また、DB上に設定パラメタを持たせて計算するという手もありますが、パラメタ数が多すぎたり、ロジックも拡張性を持たせるのが難しくなったりします。JsonLogicを用いるとそれらをうまく解決できます。</p> <p>さて、JsonLogicについてChatGPTに聞いてみます。</p> <ul> <li>JsonLogicとは</li> </ul> <pre class="code" data-lang="" data-unlink>JsonLogicは、JSON形式で表現されたルールエンジンであり、データを扱うための簡単な文法を提供します。JsonLogicを使用すると、単純な条件分岐や数式計算、データフィルタリングなどのタスクを簡単に実行できます。 JsonLogicは、JavaScriptオブジェクトのような簡単な構造を使用してルールを定義します。ルールは、if/elseステートメントのような基本的な条件分岐、算術演算、文字列操作、配列の操作、そしてネストされたルールの組み合わせをサポートします。 JsonLogicは、特にビジネスルールやデータ処理の自動化などの分野でよく使われます。JsonLogicのシンプルな構文と柔軟性により、データ処理やビジネスロジックの自動化を効率的かつスケーラブルに実行することができます。</pre> <p>うまく簡潔に説明されていますね。補足として、JsonLogicは、ルール内において、変数の代入や、ループ、関数定義、GOTO文を許しておらず、副作用がなく、計算時間も発散しないように作られています。ただし、明示的に宣言すれば、独自に定義した関数を呼び出すことができるので、やりたいことは大体できるようになっています。</p> <p>実際にどのようなものか例を示したいと思います。</p> <pre class="code" data-lang="" data-unlink>&gt; jsonLogic.apply({ &#34;+&#34; : [1, 1] }) 2</pre> <p>ここで、applyの引数の部分は"1 + 1"というルールを表していて、applyによって計算が行われて2を出力しています。</p> <p>ルールをネストすることも可能です。</p> <pre class="code" data-lang="" data-unlink>&gt; jsonLogic.apply({ &#34;*&#34;: [{ &#34;+&#34; : [1, 1] }, 3] }) 6</pre> <p>これは、"(1 + 1) * 3"を表しています。</p> <p>変数は次のようにして定義します。</p> <pre class="code" data-lang="" data-unlink>&gt; jsonLogic.apply({ &#34;+&#34; : [{ &#34;var&#34; : &#34;x&#34; }, { &#34;var&#34; : &#34;y&#34; }] }, { x: 1, y: 2 }) 3</pre> <p>ここで、ルールは"x + y"を表していて、xに1、yに2を代入して、3を出力しています。</p> <p>サポートされている演算子は、四則演算のみならず、If文や比較演算、論理演算、配列操作などがあります(<a href="https://jsonlogic.com/operations.html">参考</a>)。</p> <p>さらに、独自に定義した関数も次のようにして利用することができます(<a href="https://jsonlogic.com/add_operation.html">参考</a>)。</p> <pre class="code" data-lang="" data-unlink>&gt; jsonLogic.add_operation(&#34;sqrt&#34;, Math.sqrt); &gt; jsonLogic.apply({ &#34;sqrt&#34;:2 }) 1.4142135623730951</pre> <h2 id="ChatGPTでJsonLogicを生成">ChatGPTでJsonLogicを生成</h2> <p>それではChatGPTがJsonLogicを扱えるか問い合わせてみます。</p> <p><span style="font-size: 80%"><span style="color: #666666">試行時のChatGPTのバージョンは"ChatGPT Mar 23 Version" (ChatGPT Plus未加入)です。</span></span></p> <ul> <li>1+1をJsonLogicで表現してください</li> </ul> <pre class="code" data-lang="" data-unlink>以下は1+1をJsonLogicで表現した例です。 { &#34;+&#34; : [1, 1] } この式は、+演算子を使用して2つの数値1を加算することを意味します。JsonLogicは、JavaScriptの構文に似ていますが、JavaScriptのように括弧を使用する必要がないため、式が簡潔で直感的になります。</pre> <p>おぉ!ちゃんとJsonLogicの式を返してくれました。JsonLogicというネット上で比較的ニッチな文法を理解してるのが驚きです。</p> <p>ではもっと現実的な課題を提示してみます。</p> <ul> <li>以下をJsonLogicで表現してください。時間に応じて、通常料金3000円、深夜料金4000円 を出力します。深夜料金は朝5時までまたは夜22時以降に適用されます。時間はhourの変数を用いてください。</li> </ul> <pre class="code" data-lang="" data-unlink>以下は、JsonLogicを使用して時間に応じて通常料金と深夜料金を出力するロジックを表現した例です。 { &#34;if&#34;: [ { &#34;&gt;=&#34;: [ { &#34;var&#34;: &#34;hour&#34; }, 22 ] }, 4000, { &#34;if&#34;: [ { &#34;&lt;&#34;: [ { &#34;var&#34;: &#34;hour&#34; }, 5 ] }, 4000, 3000 ] } ] } このロジックでは、まず現在の時間(hour)が22時以上であれば、深夜料金(4000円)が適用されます。そうでなければ、時間が5時未満であれば深夜料金(4000円)が適用され、それ以外の場合は通常料金(3000円)が適用されます。</pre> <p>すごいですね。"if"文も"var"の変数も理解していました。しかも、ちゃんと「朝5時まで」を"hourの5以下"ではなく"hourの5未満"として処理してくれました。</p> <p>ただし実はこの結果を得る前にいくつか試行錯誤はありました。 確率的に構文を間違えることもあり、説明を丁寧にしないとその確率が増える感じでした。 また、説明の仕方によっては深夜料金を増加分として解釈し3000 + 4000を返すこともありました。</p> <p>続いて、もう少し出力を改良してみます。</p> <ul> <li>"if"のネストを、"or"演算子を用いて解消してください。</li> </ul> <pre class="code" data-lang="" data-unlink>以下は、&#34;if&#34;のネストを&#34;or&#34;演算子を使用して解消した例です。 { &#34;if&#34;: [ { &#34;or&#34;: [ { &#34;&gt;=&#34;: [ { &#34;var&#34;: &#34;hour&#34; }, 22 ] }, { &#34;&lt;&#34;: [ { &#34;var&#34;: &#34;hour&#34; }, 5 ] } ] }, 4000, 3000 ] } このロジックでは、現在の時間が22時以上であるか、5時未満であるかどうかを&#34;or&#34;演算子で判定しています。もし、そのいずれかであれば深夜料金(4000円)が適用され、そうでなければ通常料金(3000円)が適用されます。 </pre> <p>望んだ結果が得られました。ネストした"if"の関係性から、"推論"的な感じで、"or"条件に還元することができました。</p> <p>テストコードも依頼してみます。</p> <ul> <li>JavaScriptでテストコードをテストケースとともに生成してください。JsonLogicの式はテストケースから外出しして共通化してください。</li> </ul> <pre class="code" data-lang="" data-unlink>以下は、JavaScriptでJsonLogic式を共通化し、テストコードとテストケースを生成する例です。 const JsonLogic = require(&#39;json-logic-js&#39;); // 共通化したJsonLogic式 const pricingRule = { &#34;if&#34;: [ { &#34;or&#34;: [ { &#34;&gt;=&#34;: [ { &#34;var&#34;: &#34;hour&#34; }, 22 ] }, { &#34;&lt;&#34;: [ { &#34;var&#34;: &#34;hour&#34; }, 5 ] } ] }, 4000, 3000 ] }; // テストケースを生成する関数 function generateTestCase(hour, expectedPrice) { return { input: { hour }, expectedOutput: expectedPrice }; } // テストコードを生成する関数 function generateTestCode(pricingRule) { const testCases = [ generateTestCase(0, 4000), generateTestCase(3, 4000), generateTestCase(5, 3000), generateTestCase(10, 3000), generateTestCase(20, 3000), generateTestCase(22, 4000), generateTestCase(23, 4000), ]; testCases.forEach(({ input, expectedOutput }) =&gt; { const actualOutput = JsonLogic.apply(pricingRule, input); console.log(`Input: ${JSON.stringify(input)}, Expected Output: ${expectedOutput}, Actual Output: ${actualOutput}`); console.assert(actualOutput === expectedOutput, `Test Failed: ${JSON.stringify(input)}`); }); } // テストコードを実行する generateTestCode(pricingRule);</pre> <p>想像以上にいい感じのテストコードが返ってきました。境界値周りも考慮されています。</p> <h2 id="ChatGPTでJsonLogicを翻訳">ChatGPTでJsonLogicを翻訳</h2> <p>今度は、先ほどのJsonLogic式を逆に変換して日本語に直してみます。</p> <ul> <li>次のJsonLogic式を日本語で表現してください。 {"if":[{"or":[{">=":[{"var":"hour"},22]},{"&lt;":[{"var":"hour"},5]}]},4000,3000]} ただし、出力は料金を表しています。</li> </ul> <pre class="code" data-lang="" data-unlink>このJsonLogic式は、次のように日本語で表現できます。 「もし現在の時間が22時以上であるか、5時未満である場合は、料金を4000円に設定し、それ以外の場合は料金を3000円に設定する。」 ここで、「var」で参照されている「hour」は、式が実行される瞬間の時間を表す変数として想定されます。また、「4000」と「3000」は、それぞれの場合に適用される料金を表しています。</pre> <p>正しく変換されました。</p> <p>もっと複雑な例を試してみます。</p> <ul> <li>次のJsonLogic式を日本語で表現してください。{"if":[{"and":[{"in":[{"var":"from_area"},["area1","area2","area3"]]},{"in":[{"var":"to_area"},["itami"]]}]},3500,{"and":[{"in":[{"var":"from_area"},["area1","area2","area3"]]},{"in":[{"var":"to_area"},["kanku"]]}]},4500,{"and":[{"in":[{"var":"from_area"},["area4","area5","area6"]]},{"in":[{"var":"to_area"},["itami"]]}]},4000,{"and":[{"in":[{"var":"from_area"},["area4","area5","area6"]]},{"in":[{"var":"to_area"},["kanku"]]}]},5000,3000]} ただし、出力は料金を表しています。</li> </ul> <pre class="code" data-lang="" data-unlink>このJsonLogic式は、次のように日本語で表現できます。 「もし出発地がarea1、area2、またはarea3であり、到着地がitamiである場合は、料金を3500円に設定する。出発地がarea1、area2、またはarea3であり、到着地がkankuである場合は、料金を4500円に設定する。出発地がarea4、area5、またはarea6であり、到着地がitamiである場合は、料金を4000円に設定する。出発地がarea4、area5、またはarea6であり、到着地がkankuである場合は、料金を5000円に設定する。上記の条件がどれにも当てはまらない場合は、料金を3000円に設定する。」 ここで、「var」で参照されている「from_area」と「to_area」は、それぞれ出発地と到着地を表す変数として想定されます。また、各エリアに対する料金は、条件分岐によって異なります。</pre> <p>読みやすくなりました。変換も正しいです。</p> <h2 id="おわりに">おわりに</h2> <p>ChatGPTがJsonLogicを正しく理解して扱えてることを示してきました。現状、説明が雑だったり曖昧だったりすると意図した結果が得られないことがありますが、十分活用できるレベルだと思います。今後さらに精度が高くなることも期待できます。</p> <p>JsonLogicはシステムに高い柔軟性をもたらしますが、それでいて比較的安全でシステム側としては扱いやすいです。難点としては、文法を覚えないといけず、書きづらい読みづらいというところはあったのですが、ChatGPTで翻訳すればそれも解消しそうです。将来的には、例えば、特定の条件の時に何らかのアクションを実行するというタスクを、ChatGPTに音声で指示して実行させたいといった場合、ChatGPTがJsonLogicを介してシステムを制御するということもあり得そうです。</p> <p>最後になりますが、NearMeではエンジニアを募集しています!ご興味のある方はぜひ以下をご覧ください。</p> <ul> <li><a href="https://corp.nearme.jp/jobs/">採用情報</a></li> </ul> <p><span style="font-size: 80%"><span style="color: #666666">Author: Kenji Hosoda</span></span></p> <p><a href="https://b.hatena.ne.jp/entry/" class="hatena-bookmark-button" data-hatena-bookmark-layout="basic-label" data-hatena-bookmark-lang="ja" title="このエントリーをはてなブックマークに追加"><img src="https://b.st-hatena.com/images/v4/public/entry-button/button-only@2x.png" alt="このエントリーをはてなブックマークに追加" width="20" height="20" style="border: none;" /></a><script type="text/javascript" src="https://b.st-hatena.com/js/bookmark_button.js" charset="utf-8" async="async"></script></p> nearme-jp 外部向けAPIプラットフォームの設計について hatenablog://entry/4207112889949178125 2022-12-30T15:41:40+09:00 2023-01-06T10:59:28+09:00 はじめに NearMeでは最近、相乗り配車サービスのための外部向けAPIプラットフォームを構築しました。 これにより、他アプリからシームレスに注文したり、Lineミニアプリのような新しいチャネルのUIを独自に構築することを可能にしました。 その設計においては様々な考慮が必要でしたので、ここにまとめたいと思います。 提供方法 APIを利用するにはまず、外部連携先の"組織"を作成し、登録した"組織"で「〇〇 地域シャトル」「〇〇スクール送迎」などの"サービス"を作成します。これにより、ユーザー管理、車両管理、注文管理などが管理画面から利用できるようになります。マルチテナント方式なので専用の"サービ… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20221229/20221229180606.png" width="1200" height="493" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="はじめに">はじめに</h2> <p>NearMeでは最近、<a href="https://nearme.jp/partners/">相乗り配車サービス</a>のための外部向けAPIプラットフォームを構築しました。 これにより、他アプリからシームレスに注文したり、Lineミニアプリのような新しいチャネルのUIを独自に構築することを可能にしました。 その設計においては様々な考慮が必要でしたので、ここにまとめたいと思います。</p> <h2 id="提供方法">提供方法</h2> <p>APIを利用するにはまず、外部連携先の"組織"を作成し、登録した"組織"で「〇〇 地域シャトル」「〇〇スクール送迎」などの"サービス"を作成します。これにより、ユーザー管理、車両管理、注文管理などが管理画面から利用できるようになります。<a href="https://tech.nearme.jp/entry/2021/05/08/224059">マルチテナント方式</a>なので専用の"サービス"が構築されます。</p> <p>次に、API連携に関する基本情報を格納する"アプリケーション"という項目を作成します。 認証情報やWebhookのURLなどもここで設定します。 この"アプリケーション"のIDがキーになって、例えば、この注文はこの外部連携から作成されたもの、というのが分かるようになります。</p> <p>最後にAPIを試用します。 <a href="https://jupyter.org/">Jupyter Notebook</a> で主要なAPIをすぐに叩けるようにしつつ、 APIを網羅的に整理するために、 <a href="https://www.postman.com/">Postman</a>というAPI確認用ツールも利用しています。 Postmanのいいところとして、 変数を保存しておいて環境ごとに切り替えられる点、 API実行の前と後にスクリプトを走らせて変数を操作できる点、 API毎にドキュメントを書けたり、レスポンスを保存して表示できる点、 一覧性が高く、見やすい点、 などが挙げられます。 次図は実際のPostmanの使用例です。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20221229/20221229160611.png" width="1200" height="542" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="認証">認証</h2> <p>リソースにアクセスするための認証は、"組織"に関するものと、各ユーザーに関するものに分かれます。</p> <p>"組織"に関するものは <code>server_token</code> を利用します。 信頼のあるサーバーから、このトークンをリクエストヘッダに付与してリクエストします。</p> <p>各ユーザーに関するものは、<code>access_token</code> をリクエストヘッダに付与してリクエストします。外部ユーザー連携を行う場合は、 <code>server_token</code> を利用して "アプリケーション"に紐づいた形でNearMeのユーザーを作成した上で、<code>server_token</code> を利用して、そのユーザーの<code>access_token</code> を取得します。そうでない場合は、OAuth形式に沿って、<code>client_id</code> と <code>client_secret</code> を利用します。権限タイプに応じた"ユーザーの認証情報"とともに、信頼のあるサーバーを経由して付与された<code>client_id</code> と <code>client_secret</code> をログイン用のAPIにリクエスして <code>access_token</code>取得します(<a href="https://cloud.google.com/apigee/docs/api-platform/security/oauth/oauth-introduction?hl=ja">参考</a>、※ パスワードタイプはテスト用途でのみ利用しています)。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20221230/20221230235508.png" width="1200" height="509" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="Webhook">Webhook</h2> <p>通知の連携や、システム間の状態の連携を行うときにWebhookを利用します。 例えば、"運行が完了した"というようなイベントを、 NearMeのシステムから外部のシステムに送信します。 プッシュ通知などは外部のユーザーに対してNearMeのシステムから直接送ることはできないので、 このWebhookにより、外部のシステムを介して通知を送ったりします。</p> <p>WebhookのURLや認証情報なども"アプリケーション"に保存します。 Webhookの認証は外部システム依存になるので、ここは柔軟に設定できるようにしています。 ただし、デフォルトで、 <code>client_secret</code> をキーにして、SHA256でリクエストボディをハッシュ化した値をシグネチャとして用いて、 送信元の検証が行えるようにもしています(<a href="https://developer.uber.com/docs/riders/guides/webhooks#security">参考</a>)。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20221230/20221230150600.png" width="1200" height="605" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="中継サービス">中継サービス</h2> <p>元々、内部向けAPIは既に用意はされているので、 それを権限だけ修正してそのまま公開というのもできなくはないですが、 外部向けAPIは安定性や認知性をより重視するので、 リクエストを中継するサービスを新たに設けて、その特性の違いを吸収させました。</p> <h3 id="GraphQLかRestAPIか">GraphQLかRestAPIか</h3> <p>特に、内部向けAPIはGraphQLで組んでいたのですが、外部向けAPIはRestAPIで組むようにしました。</p> <p>GraphQLのいいところは、 型に厳密なので間違いに気づきやすく、 柔軟性が高いので効率良くデータを取得できるといった点があります。 逆に、少しのスキーマ変化でエラーを起こしたり、 どんなクエリを作成すればいいか分かりづらかったりします。 GraphQLそのものへのキャッチアップも少し時間がかかります。</p> <p>一方、RestAPIはその点、少しのスキーマ変化でもレスポンス自体はエラーにならず、 どのAPIをどのように叩けばいいのかはより明快です。 逆に、スキーマ変化のためにエラーが混入しやすく、 不要なデータを取ってきたり、何回もAPIを叩いたりする必要があったりします。</p> <p>内部で開発する分にはGraphQLの不利点はそこまで問題にならないのですが、 外部の人がAPIを利用する際はRestAPIの不利点よりもGraphQLの不利点の方が大きくなるのではと思います。</p> <h3 id="名前の変換">名前の変換</h3> <p>外部向けAPIはRestAPIで組むとしたことで、 中継サーバーにおいてGraphQLからRestAPIへの変換が必要になってくるのですが、 名前をリファクタリングできる機会も生まれたので、結果よかったと思います。</p> <p>外部向けAPIは一度公開したらなかなか変えられないので、 なるべく熟考して名前を決めたいところです。 内部向けAPIでもそれは要求されますが、 やり直しのチャンスはもう少し残っています。 割り切る時もあるかもしれませんし、開発が進んでからより良い名前を思いつくこともあります。</p> <p>例えば、<a href="https://tech.nearme.jp/entry/2022/03/23/000000">配車のステータス</a>といったフィールドの値は、 内部向けAPIでは数値で管理していたのですが、 分かりやすい文字列に変換するようにしました。 数値だとデータ節約の側面もありますが、名前を深く考えなくていい(コード上で名前は設定しますがこれは後から容易に変更できます)、というのも開発の上で進めやすかったのですが、 外部向けには余計な不便をかけてしまいます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20221230/20221230151526.png" width="1136" height="334" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="おわりに">おわりに</h2> <p>NearMeの外部向けAPIプラットフォームにおける、基本的な構築方法から、設計思想に関わる中継サービスについて説明しました。 構築にあたっては他サービスの事例を色々と参考にしてきました。この記事もまたその参考の一つになれば幸いです。</p> <p>最後になりますが、NearMeではエンジニアを募集しています!ご興味のある方はぜひ以下をご覧ください。</p> <ul> <li><a href="https://corp.nearme.jp/jobs/">採用情報</a></li> </ul> <p><span style="font-size: 80%"><span style="color: #666666">Author: Kenji Hosoda</span></span></p> <p><a href="https://b.hatena.ne.jp/entry/" class="hatena-bookmark-button" data-hatena-bookmark-layout="basic-label" data-hatena-bookmark-lang="ja" title="このエントリーをはてなブックマークに追加"><img src="https://b.st-hatena.com/images/v4/public/entry-button/button-only@2x.png" alt="このエントリーをはてなブックマークに追加" width="20" height="20" style="border: none;" /></a><script type="text/javascript" src="https://b.st-hatena.com/js/bookmark_button.js" charset="utf-8" async="async"></script></p> nearme-jp ArgoによるCI構築 hatenablog://entry/4207112889902208677 2022-07-28T13:54:34+09:00 2022-07-28T15:46:59+09:00 はじめに 今回は、NearMeにおけるCIの仕組みについて説明します。 CIとは、Continuous Integration(継続的インテグレーション)の略で、 コード変更の度にビルドとテストを自動で実行するプラクティスを指します(参考)。 NearMeではCIを実現する方法として、Kubernetes(k8s)上に動作するArgoを利用しました。 k8sはコンテナ化されたアプリケーションを管理するためのオープンソースのシステムです。 Argoはk8s上でCIやCD(Continuous delivery)を実現するツール群です。 一般に、CI構築では外部サービスを利用することも多いですが、… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20220726/20220726203748.png" width="1200" height="685" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="はじめに">はじめに</h2> <p>今回は、NearMeにおけるCIの仕組みについて説明します。 CIとは、Continuous Integration(継続的インテグレーション)の略で、 コード変更の度にビルドとテストを自動で実行するプラクティスを指します(<a href="https://cloud.google.com/architecture/devops/devops-tech-continuous-integration?hl=ja">参考</a>)。</p> <p>NearMeではCIを実現する方法として、<a href="https://kubernetes.io/ja/">Kubernetes(k8s)</a>上に動作する<a href="https://argoproj.github.io/">Argo</a>を利用しました。 k8sはコンテナ化されたアプリケーションを管理するためのオープンソースのシステムです。 Argoはk8s上でCIやCD(Continuous delivery)を実現するツール群です。 一般に、CI構築では外部サービスを利用することも多いですが、 他システムの障害や料金に依存せずCIを管理したかったのと、 既にk8sを中心にシステムを構築していたので、 多少の煩雑さはありつつも自前で構築しました。</p> <h2 id="システム構成">システム構成</h2> <p>CIシステムはイベントの処理を行う<a href="https://argoproj.github.io/argo-events/">Argo Events</a>とジョブを実行する<a href="https://argoproj.github.io/argo-workflows/">Argo Workflows</a>からなります。全体としては下図のような構成になります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20220726/20220726205735.png" width="1200" height="540" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Argo EventsやArgo Workflowsにはそれぞれ、k8sの<a href="https://kubernetes.io/ja/docs/concepts/extend-kubernetes/api-extension/custom-resources/">カスタムリソース</a>が用意されていて、k8s上でのリソースを容易に構築できるようになっています。ただし、NearMeでは<a href="https://ja.wikipedia.org/wiki/%E3%83%9E%E3%82%A4%E3%82%AF%E3%83%AD%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9">マイクロサービス</a>で複数のリポジトリを扱うため、リソース定義を共通化したい部分も多いので、<a href="https://cdk8s.io/">cdk8s</a>を用いてそれらをさらにプログラム的に管理するようにしています。</p> <h3 id="Argo-Events">Argo Events</h3> <p>Argo Eventsは、様々なイベントを汎用的に扱うことを想定して作られています。</p> <p>NearMeのCIでは<a href="https://docs.github.com/ja/get-started/customizing-your-github-workflow/exploring-integrations/about-webhooks">Githubのwebhook</a>のイベント扱います。これはカスタムリソースの<a href="https://argoproj.github.io/argo-events/concepts/event_source/">EventSource</a>で実現できます。以下はその定義の例です。</p> <pre class="code" data-lang="" data-unlink>kind: EventSource metadata: name: github-event-source spec: github: rideServicePullRequest: events: - pull_request repositories: - names: - ride-service owner: nearme-jp webhook: endpoint: /ride-service/pull_request ...</pre> <p>このリソースは、<code>ride-service</code>というリポジトリのプルリクエストのイベントを<code>/ride-service/pull_request</code>というエンドポイントで検知します。</p> <p>ただし、外部からのリクエストを受け付けるために、このエンドポイントのホストはk8sの<a href="https://kubernetes.io/ja/docs/concepts/services-networking/ingress/">Ingress</a>において定義します。それにより、GithubからのリクエストはIngressを介してEventSourceに送られます。</p> <p>このエンドポイントのURLを、GithubのリポジトリにおけるSettings > Webhooksにて設定します。このとき、プルリクエストやプッシュといった送信するイベントの種類も指定します。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20220726/20220726211918.png" width="1200" height="926" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>EventSourceで受信したイベントは、<a href="https://argoproj.github.io/argo-events/concepts/sensor/">Sensor</a>に伝えられます。Sensorは特定のイベントをサブスクライブして、特定の処理を起動します。この間のやり取りは、<a href="https://argoproj.github.io/argo-events/concepts/eventbus/">EventBus</a>を介して、<a href="https://ja.wikipedia.org/wiki/%E5%87%BA%E7%89%88-%E8%B3%BC%E8%AA%AD%E5%9E%8B%E3%83%A2%E3%83%87%E3%83%AB">Pub/Subのメッセージングモデル</a>で行われます。</p> <p>Sensorは次のようなカスタムリソースで定義します。</p> <pre class="code" data-lang="" data-unlink>kind: Sensor spec: dependencies: - eventName: rideServicePullRequest eventSourceName: github-event-source name: ride-service-merged-dep filters: name: data-filter data: - path: body.action type: string value: - closed - path: body.pull_request.merged type: bool value: - &#34;true&#34; - path: body.pull_request.base.ref type: string value: - main triggers: - template: conditions: ride-service-merged-dep k8s: resource: workflows parameters: - dest: spec.arguments.parameters.0.value src: dataKey: body.repository.name dependencyName: ride-service-merged-dep source: resource: kind: Workflow arguments: parameters: - name: repository_name ...</pre> <p>このリソースは、<code>ride-service</code>リポジトリの<code>main</code>ブランチにプルリクエストがマージされたイベントから、後述するWorkflowを起動します。</p> <p>このとき、Githubのイベントのペイロードにおいて<code>body.repository.name</code>のパスに格納されたリポジトリ名をWorkflowに渡します。このような形で、プルリクエストのリポジトリ名やブランチ名、コメントやラベルなどの情報(詳細は<a href="https://docs.github.com/ja/developers/webhooks-and-events/webhooks/webhook-events-and-payloads">こちら</a>)をWorkflowで利用することが可能になります。</p> <p>なお、ペイロードの中身は、Githubのwebhookの設定画面の"Recent Deliveries"タブで確認することができます。また、ここで"Redeliver"ボタンを押すことで、同じイベントを送信することができるのでデバッグに便利です<span style="font-size: 80%">(ただし、Workflowの一つのイベントに対するジョブは、同じイベントでは再度起動できないようになってるので、デバッグするときはジョブの実行履歴を消す必要があります)</span>。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20220727/20220727155511.png" width="1200" height="967" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="Argo-Workflows">Argo Workflows</h3> <p>NearMeのCIでは、Argo Eventsで処理されたイベントからArgo Workflowsにおけるジョブのフロー(Workflow)を起動します。</p> <p>このWorkflowでビルドとテストを行い、問題なければ、ビルドしたイメージを<a href="https://www.redhat.com/ja/topics/cloud-native-apps/what-is-a-container-registry">コンテナレジストリ</a>にプッシュします。ここではさらに、チャットサービスの<a href="https://slack.com/intl/ja-jp/">Slack</a>に通知したり、タスク管理サービスの<a href="https://asana.com/ja">Asana</a>のタスクのステータスを変更したりもしています。</p> <p>次図は当Workflowの内部処理を示したものです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20220727/20220727222125.png" width="1200" height="1036" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Workflow内部ではビルド用のタスクと、そのタスクの終了時に<a href="https://argoproj.github.io/argo-workflows/lifecyclehook/">フック</a>されて起動する通知用のタスクを用意しています。</p> <p>各タスクの処理はk8sのコンテナのコマンドで書きます。コマンドで全処理を書けなくもないですが、それなりの量になったので、<a href="https://ja.wikipedia.org/wiki/%E3%82%B7%E3%82%A7%E3%83%AB%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88">シェルスクリプト</a>にファイル化してk8sの<a href="https://kubernetes.io/ja/docs/concepts/configuration/configmap/">ConfigMap</a>に保存して実行するようにしています(もっと複雑になれば、その他の言語で書き直すかもしれません)。外部サービスだとこの辺りより簡潔に書ける可能性はありますが、この方式でも少しの努力で同じことはできると考えています。</p> <p>なお、Workflowは単体でも実行することが可能です。デバッグに用いたり、イベント系で障害があった時は手動で実行したりすることもできます。</p> <h4 id="ビルドタスク">ビルドタスク</h4> <p>ビルドタスクではまず、Workflowの引数に渡されたリポジトリ名やブランチ名からをリポジトリをチェックアウトします。githubへアクセスするためにk8sの<a href="https://kubernetes.io/ja/docs/concepts/configuration/secret/">Secrets</a>からプライベートキーを取得しています。</p> <p>そこからコンテナのイメージをビルドします。ここで<a href="https://docs.docker.jp/engine/reference/commandline/index.html">dockerコマンド</a>を利用するために、<a href="https://hub.docker.com/_/docker">Docker In Docker(dind)</a>というコンテナをk8sの<a href="https://www.containiq.com/post/kubernetes-sidecar-container">サイドカー</a>で実行しています。</p> <p>テストも基本的にはdockerコマンドを利用します。このとき。例えば、MySQLなどテスト実行で依存するサービスも一緒に立ち上げます。また、NearMeでは<a href="https://tech.nearme.jp/entry/2021/06/07/114010">Ride ServiceからRouting Serviceを利用</a>しており、Ride Serviceのテスト時にRouting Serviceを立ち上げたりもしています。</p> <p>最後に、コンテナレジストリ(AWSの<a href="https://aws.amazon.com/jp/ecr/">ECR</a>)にビルドしたイメージをプッシュします。</p> <p>その他、<a href="https://docs.docker.jp/engine/reference/commandline/save.html">docker save</a>と<a href="https://docs.docker.jp/engine/reference/commandline/load.html">docker load</a>コマンドを利用して、dockerのイメージをキャッシュして、毎回ダウンロードしないようにもしています。このキャッシュはk8sの<a href="https://kubernetes.io/ja/docs/concepts/storage/volumes/">Volume</a>に保存しています。</p> <p>また、リポジトリによってはビルドやテスト時間が長いので適宜手動で省略できるようにもしています。具体的には、プルリクエストに付与されたラベルに基づいて、ビルドをスキップしたり、ブランチ派生前からの差分のあるファイルを検出して、そのファイルに直接関連のあるテストだけ実行するようにしています。</p> <p>さらに、テストのエラーログなどを通知で表示するため、シェルスクリプトの実行ログをファイルに保存してその後の通知タスクで利用できるようにもしています。これはArgo Workflowsの<a href="https://argoproj.github.io/argo-workflows/walk-through/artifacts/">Artifacts</a>という機能で実現しています。ここではArtifactsのバックエンドとして<a href="https://aws.amazon.com/jp/s3/">S3</a>ライクな<a href="https://min.io/">Minio</a>を利用しています。</p> <h4 id="通知タスク">通知タスク</h4> <p>通知タスクでは、前回のビルドタスクの成否を受け取り、プルリクエストのタイトルやコミットのリンク、ビルド時間などをSlackに通知します。また、ビルドエラーがあった場合は前述のArtifactsからログを取得して(文字のエスケープもして)Slackのメッセージに載せています。</p> <p>さらに、プルリクエストのコメントに貼り付けられたAsanaのタスクのURLを抽出し、<a href="https://developers.asana.com/docs">AsanaのAPI</a>を利用して 、そのタスクのステータスを変更しています。また、そのリンクもSlackのメッセージに載せています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20220727/20220727174148.png" width="548" height="258" loading="lazy" title="" class="hatena-fotolife hatena-fotolife-height-only" style="height:120px" itemprop="image"></span></p> <h2 id="おわりに">おわりに</h2> <p>k8s上で動作するArgoを利用したNearMeのCI構成を紹介しました。 GithubのwebhookのイベントからArgo Eventsを介してArgo WorkflowsのWorkflowを起動する仕組みや、 Workflow内のビルドタスクや通知タスクでの工夫などを示しました。 ある程度k8sの知識が要求されるのでハードルはありますが、 逆にそこを越えればCIとしてやりたいことはできると思います。 Argo自体は非常に汎用的で、まだ利用できてない部分もあるので、これからさらに開発プロセスを進化させていけたらと思います。</p> <p>最後になりますが、NearMeではエンジニアを募集しています!ご興味のある方はぜひ以下をご覧ください。</p> <ul> <li><a href="https://corp.nearme.jp/jobs/">採用情報</a></li> </ul> <p><span style="font-size: 80%"><span style="color: #666666">Author: Kenji Hosoda</span></span></p> <p><a href="https://b.hatena.ne.jp/entry/" class="hatena-bookmark-button" data-hatena-bookmark-layout="basic-label" data-hatena-bookmark-lang="ja" title="このエントリーをはてなブックマークに追加"><img src="https://b.st-hatena.com/images/v4/public/entry-button/button-only@2x.png" alt="このエントリーをはてなブックマークに追加" width="20" height="20" style="border: none;" /></a><script type="text/javascript" src="https://b.st-hatena.com/js/bookmark_button.js" charset="utf-8" async="async"></script></p> nearme-jp 相乗り配車の注文処理について hatenablog://entry/13574176438070800955 2022-03-23T00:00:00+09:00 2022-03-23T13:09:03+09:00 はじめに 配車サービスの注文処理はECサイトのそれと似ています。 ECサイトでは商品を検索し、カートに入れて、注文します。 このとき、在庫が確保できていれば注文できます。 もしくは、後から在庫確保または在庫切れの通知が届く場合もあります。 その後、配送手配が行われ、お届け日などが通知されます。 配車サービスでは、商品に対応するのが、出発地と目的地を結ぶ移動手段になります。 在庫確保の流れはドライバーの予定を押さえる形になります。 このとき配車確定まで時間がかかる場合もあります。 その後、配車手配が行われ、ピックアップ時刻などが通知されます。 今回は、NearMeの相乗りサービスにおける注文処理… <h2>はじめに</h2> <p>配車サービスの注文処理はECサイトのそれと似ています。 ECサイトでは商品を検索し、カートに入れて、注文します。 このとき、在庫が確保できていれば注文できます。 もしくは、後から在庫確保または在庫切れの通知が届く場合もあります。 その後、配送手配が行われ、お届け日などが通知されます。</p> <p>配車サービスでは、商品に対応するのが、出発地と目的地を結ぶ移動手段になります。 在庫確保の流れはドライバーの予定を押さえる形になります。 このとき配車確定まで時間がかかる場合もあります。 その後、配車手配が行われ、ピックアップ時刻などが通知されます。</p> <p>今回は、<a href="https://app.nearme.jp/airport-shuttle/">NearMeの相乗りサービス</a>における注文処理について説明します。 当サービスでは事前予約で注文を貯めつつ、注文が入る度に最適な相乗りの組み合わせを計算して配車確定を逐次的に行えるようにしています。 このような相乗りにおいてどのようなパタンが発生するか見ていきます。</p> <h2>基本的な注文処理</h2> <p>配車における主要な注文処理は、注文申込(Apply) → 配車可否確認(Accept/Reject) → 配車完了(Complete) という流れです。 ただし、配車に至るまでに注文がキャンセル(Cancel)される可能性もあります。 図で示すと以下のようになります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20220322/20220322232136.png" alt="f:id:nearme-jp:20220322232136p:plain" width="1200" height="422" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>ここで配車可否確認においては、いくつかの方法があります。 一つは、運行管理者が全体の運行スケジュールを見てドライバーの予定を押さえる方法です。 この場合、注文申込から配車可否確認まで時間がかかります。例えば、24時間以内に回答します、といった形になります。 もう一つは、ドライバーが直接配車可否を決める方法です。 特にリアルタイムの場合は、注文が入ると近くの車両のドライバーアプリに通知し、そこで配車可否が決定されます。 その他、様々な情報を駆使して自動的に配車可否を決めたり、配車率が高ければ投機的に配車可能を伝えることも考えられます。</p> <p>ここまではフローとしてはシンプルかと思います。</p> <h2>相乗りを考慮した状態遷移</h2> <p>上記の基本的な注文処理において、複数の注文が入ってきたときに、各注文と複数の注文を束ねる運行(Trip)の状態遷移について説明します。</p> <p>まずは複数の注文が相乗りしながら配車確定していく流れを次図に示します。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20220323/20220323104236.png" alt="f:id:nearme-jp:20220323104236p:plain:h460" width="848" height="966" loading="lazy" title="" class="hatena-fotolife hatena-fotolife-height-only" style="height:460px" itemprop="image"></span></p> <p>注文処理のステップは以下のようになります。</p> <ol> <li>何もない状態からスタートします</li> <li>注文Aが入ると(Apply A)、仮の運行1(Trip1)が生成されます。</li> <li>注文Bが入ると(Apply B)、仮の運行1の下で注文Aと相乗りになります。</li> <li>注文Aを配車確定します(Accept A)。運行1は注文Bがまだ配車確定されていないのでまだ仮の状態です。</li> <li>注文Bを配車確定します(Accept B)。運行1の配下の注文は全て配車確定されたので、運行1はある種"確定"の状態になります。</li> <li>一方、3の状態から運行単位で配車確定して(Accept Trip1)、注文Aと注文Bを同時に配車確定することも可能です。</li> <li>注文Cが入り(Apply C)、3つの注文がまとまった相乗りが成立します。注文Cはまだ配車確定していないので、運行1はまた仮の状態になります。</li> </ol> <p>配車確定前に注文が貯まりながら、相乗り処理が逐次的に行われていることが分かります。</p> <p>今度は、注文にアサインされた運行が切り替わるパタンについて見てみます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20220322/20220322234138.png" alt="f:id:nearme-jp:20220322234138p:plain:h260" width="786" height="590" loading="lazy" title="" class="hatena-fotolife hatena-fotolife-height-only" style="height:260px" itemprop="image"></span></p> <ol> <li>注文Aと注文Bが、それぞれ配車確定前に相乗りになった状態です。</li> <li>注文Dが入ると運行が2つになり、注文Bは注文Dとくっつきます。このとき、注文Aと注文Bの対よりも、注文Bと注文Dの対の方が効率の良い運行で、かつ、注文Aと注文Bと注文Dの3つでは相乗りが成立しない状況になっています。</li> <li>注文Dを配車不可(Reject)すると、再び注文Bは注文Aとくっついて、運行2は消滅します。</li> </ol> <p>配車確定前に貯まった注文同士で、ダイナミックに相互作用しているのが見てとれます。</p> <p>一方、素朴に運行管理者が予定を押さえる場合は、運用の煩雑さから"配車確定後の注文は別の運行に切り替わらない"という縛りを加えることが可能です。この場合は次のようになります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20220322/20220322234402.png" alt="f:id:nearme-jp:20220322234402p:plain:h220" width="872" height="496" loading="lazy" title="" class="hatena-fotolife hatena-fotolife-height-only" style="height:220px" itemprop="image"></span></p> <ol> <li>注文Aが単独、注文B、Dが相乗りで配車確定になった状態です。</li> <li>"配車確定後に別の運行に切り替わらない"場合、注文Dがキャンセルされると、注文Aと注文Bが別々の運行のままになります。</li> </ol> <p>先ほどは注文が却下/キャンセルされると運行が1つになったところが、今回は運行が2つのままになっているのが分かります。なお、この縛りは設定によって変えることができ、ここは運用の煩雑さと配車効率のトレードオフになります。</p> <h2>時間確定処理</h2> <p>事前予約型の相乗り配車では、先の注文申込(Apply) → 配車可否確認(Accept/Reject) → 配車完了(Complete) の処理フローにおいて、配車確定後にさらに時間確定(Finalized)が追加されています。ピックアップ時刻はある程度時間幅を持たせることで相乗りが成立しやすくなりますが(<a href="https://tech.nearme.jp/entry/2021/07/02/112508#ride_buffer_time">参考</a>)、逆に、ユーザーにとっては予定が立てづらくなって不便です。そこで、あるタイミング、例えば、乗車の1日前のところで、ピックアップ時刻を確定、もしくはその時間幅を短縮するという時間確定処理を行います。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20220323/20220323000345.png" alt="f:id:nearme-jp:20220323000345p:plain" width="1200" height="360" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>時間確定処理が入った処理フローも相乗りを考慮して見てみます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20220323/20220323001126.png" alt="f:id:nearme-jp:20220323001126p:plain:h400" width="720" height="902" loading="lazy" title="" class="hatena-fotolife hatena-fotolife-height-only" style="height:400px" itemprop="image"></span></p> <ol> <li>注文Aと注文Bが、それぞれ配車確定前に相乗りになった状態です。</li> <li>運行1を時間確定します(Finalize Trip1)。時間確定処理は注文単位ではなく運行単位で行います。このとき、各注文のピックアップ時刻の時間幅が短縮されます。完全に時刻を確定させると追加の相乗りがほとんど発生しなくなるので(前後にくっつくか、全く同じルートの注文にくっつくかのみになります)、実用的には少し時間幅を残します。そして、ユーザーには確定したピックアップ時刻を通知します。なおここで、追加の相乗りがあってもピックアップ時刻が早まることはないようにしています。</li> <li>注文Cが入り、注文Aと注文Bと相乗りします。</li> <li>再度、運行1を時間確定します(Finalize Trip1)。このとき、注文Cにピックアップ時刻を通知するとともに、注文Aと注文Bにおいてピックアップ時刻が変更した場合は、それぞれそのユーザーにその時刻を通知します。</li> <li>注文Bがキャンセルされると、少し非効率になりますがピックアップ時刻は変わらずに注文Aと注文Cが運行1に残ります。</li> </ol> <p>時間確定処理後も、よりきつい時間制約の下で追加の相乗りが発生しているのが分かります。</p> <h2>別の運行に渡す処理</h2> <p>最後に、ある注文を今の運行から剥がして別の運行に渡す処理(Skip)についても見てみます。 この処理は、ある注文に対して特定の運行にアサインさせないという制約を加えることで実現します(<a href="https://tech.nearme.jp/entry/2021/12/15/113718#affinity">参考</a>)。 追加の相乗りがあったけど、諸々のスケジュールの関係でこの運行では行けないので、別の運行に注文を渡してとってもらうことを想定しています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20220323/20220323001444.png" alt="f:id:nearme-jp:20220323001444p:plain:h300" width="784" height="674" loading="lazy" title="" class="hatena-fotolife hatena-fotolife-height-only" style="height:300px" itemprop="image"></span></p> <ol> <li>注文Aと注文Bが異なる運行でそれぞれ配車確定していて、追加の注文Dが入って注文Bと相乗りになった状況です。</li> <li>注文Dを今アサインされている運行2から剥がすことにより(Skip D)、注文Aと相乗りになります。</li> <li>さらに注文Dを今アサインされている運行1から剥がすと(Skip D)、既に剥がした運行2とはくっつけないため、今度は独立した運行になります。</li> </ol> <p>より複雑な制約条件の下、逐次的に相乗り処理が行われているのが分かります。</p> <h2>おわりに</h2> <p>事前予約型の相乗り配車サービスの注文処理における様々な状態遷移を見てきました。 注文申込、 配車可否確認、キャンセル、時間確定、別の運行に渡す処理などの操作によって、 単体の注文の状態だけでなく、別の注文と相互作用して様々なパタンが発生することを示しました。 また、処理過程において、ピックアップの時間幅を短縮したり、特定の運行にアサインさせないなど、制約条件が細かく変わっていくことも示しました。 このようなパタンを緻密に管理することでサービスが実現されていることが伝わればと思います。</p> <p>最後になりますが、NearMeではエンジニアを募集しています!まだまだ多くの可能性が潜んでいる領域です。興味を持った方はぜひ以下から応募いただければと思います。</p> <ul> <li><a href="https://corp.nearme.jp/jobs/">採用サイトはこちら</a></li> </ul> <p><span style="font-size: 80%"><span style="color: #666666">Author: Kenji Hosoda</span></span></p> <p><a href="https://b.hatena.ne.jp/entry/" class="hatena-bookmark-button" data-hatena-bookmark-layout="basic-label" data-hatena-bookmark-lang="ja" title="このエントリーをはてなブックマークに追加"><img src="https://b.st-hatena.com/images/v4/public/entry-button/button-only@2x.png" alt="このエントリーをはてなブックマークに追加" width="20" height="20" style="border: none;" /></a><script type="text/javascript" src="https://b.st-hatena.com/js/bookmark_button.js" charset="utf-8" async="async"></script></p> nearme-jp 配車オーケストレーション hatenablog://entry/13574176438033096929 2021-12-15T11:37:18+09:00 2022-03-23T12:27:57+09:00 はじめに NearMeでは相乗りシャトルのサービスを展開していますが、その配車ルーティングを実装してきて気づいた点があります。 クラウドの基盤技術である"コンテナオーケストレーション"と似た構造がある点です。 コンテナオーケストレーションは、コンピューティング環境で動くアプリケーションをコンテナと呼ばれる単位で管理し、コンピューティングリソースを適切に割り当て、協調的に動作させる技術です。 コンテナオーケストレーションを実現する代表的なツールとしてKubernetes(クーバネティス、以降k8sと略します)があり、ここでは特にk8sと配車ルーティングとのアナロジーについて考えてみます。 構成要… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20211214/20211214190037.png" alt="f:id:nearme-jp:20211214190037p:plain" width="720" height="405" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2>はじめに</h2> <p><a href="https://nearme.jp/">NearMe</a>では<a href="https://app.nearme.jp/airport-shuttle/">相乗りシャトル</a>のサービスを展開していますが、その配車ルーティングを実装してきて気づいた点があります。 クラウドの基盤技術である"コンテナオーケストレーション"と似た構造がある点です。</p> <p>コンテナオーケストレーションは、コンピューティング環境で動くアプリケーションをコンテナと呼ばれる単位で管理し、コンピューティングリソースを適切に割り当て、協調的に動作させる技術です。</p> <p>コンテナオーケストレーションを実現する代表的なツールとして<a href="https://kubernetes.io/">Kubernetes</a>(クーバネティス、以降k8sと略します)があり、ここでは特にk8sと配車ルーティングとのアナロジーについて考えてみます。</p> <h2>構成要素</h2> <p>k8sでは、コンテナ・ポッド・ノードという階層的な構成要素があります。コンテナは単独のアプリケーションを動かす実行環境です。ポッドは、複数の密接に関連したコンテナをまとめたものです。コンテナはその共有リソースとともにポッド単位で管理されます。ノードは一つの物理サーバー、または、仮想マシンを表していて、複数のポッドがその上に配置され実行されます。</p> <p>一方、配車ルーティングでは、乗客/荷物、注文、車両という階層的な構成要素があります。乗客/荷物は最小単位の積載物です。注文は、複数の乗客/荷物に対する運送を要求します。車両は、特に相乗りを許すと、複数の注文を束ねて運行します。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20211214/20211214205351.png" alt="f:id:nearme-jp:20211214205351p:plain" width="720" height="405" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2>スケジューリング</h2> <p>k8sでは、各ポッドを様々な制約や評価を考慮して最適なノードに割り当てるスケジューラがあります。例えば、ポッド作成時に、リソースに空きのあるノードを探し、ノード間でリソース使用率に偏りがないように割り当てます。また、スケジューラはプラグイン的に拡張でき、<a href="https://github.com/kubernetes-sigs/descheduler">ポッドを削除して再スケジュールするもの</a>もあります。</p> <p>一方、配車ルーティングでは、各注文を様々な考慮のもと車両に割り当てるスケジューラがあります。例えば、注文が入ると、迂回時間や待ち時間、車両のキャパなどから配車可能な車両を探し、全体としてルートが短くなるように割り当てます(<a href="https://tech.nearme.jp/entry/2021/07/02/112508">参考</a>)。このとき、既存の注文も車両を跨いで再配置することもあります(<a href="https://tech.nearme.jp/entry/2021/06/07/114010">参考</a>)。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20211214/20211214225227.png" alt="f:id:nearme-jp:20211214225227p:plain" width="720" height="405" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2>オートスケール</h2> <p>k8sでは、リソース不足でポッドがスケジューリングできない時に自動的にノードを追加することが可能です。逆に、負荷が低い時にノードを自動的に削除することも可能です。</p> <p>一方、配車ルーティングでは、注文が増えていくと必要な車両も増えていきますが、特に事前予約型の注文の場合、"仮想的な車両"を自動的に増やして注文を割り当てることが可能です。後からその仮想的な車両を実際の車両に置き換えます。注文のキャンセルがあって車両が必要なくなった場合はその車両を減らします。</p> <p>ただし、k8sではノードのみならずポッドもスケールしますが、配車ルーティングでは一般に車両のみがスケールします。一応、注文のスケールは保険的に複数の車両を確保するような状況として捉えられるかもしれません。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20211214/20211214225702.png" alt="f:id:nearme-jp:20211214225702p:plain" width="720" height="405" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2>リソース管理</h2> <p>k8sでは、CPUやメモリなどコンテナが必要とする各リソースの量を指定することができます。そしてポッド作成時に、ポッドに紐づくコンテナのリソースの量を集計し、リソースが確保できなければポッドはそのノードに割り当てられなくなります。</p> <p>一方、配車ルーティングでは、乗客や荷物が占有する座席数や容量があります。小さい子供や大きな荷物など単体で占有量が異なるものもあります。車両には、座席やトランクの最大数/量があり、注文が要求する数/量を確保できなければその車両に割り当てることはできません。</p> <p>ただし、k8sのリソースの量には最小と最大を指定できますが、配車ルーティングの占有量の指定は一般に最小=最大となります。</p> <h2 id="affinity">アフィニティ</h2> <p>k8sでは、ノード上のポッドの配置の条件を、特に<a href="https://kubernetes.io/ja/docs/concepts/scheduling-eviction/assign-pod-node/">アフィニティ</a>と呼ばれる方法で細かく指定することができます。例えば、ノードとポッドそれぞれに任意のラベルをつけることができ、ポッドを特定のラベルがついたノードに強制的/優先的に割り当てたり(ノードアフィニティ)、特定のラベルがついてないノードに割り当てたりすることができます(ノードアンチアフィニティ)。また、ポッドを特定のラベルがついたポッドを含むノード<span style="font-size: 80%">(やゾーンなど)</span>に共存させたり(ポッドアフィニティ)、避けたりすることができます(ポッドアンチアフィニティ)。さらに、ノードアフィニティとはある種逆に、特定のノードに特定のポッドのみを割り当てる機構もあります(ノードのテイントとポッドの容認)。</p> <p>一方、配車ルーティングでは、例えば、注文に、チャイルドシート、ペット、車椅子などの要求があれば、特定の車両に割り当てた方がいいことがあります(車両アフィニティ)。また、営業区域の事情で、ある注文を特定の車両には割り当てないようにしたいこともありそうです(車両アンチアフィニティ)。さらに、なるべく親しい人同士で相乗りしたい(注文アフィニティ)、もしくはしたくない(注文アンチアフィニティ)といったことも考えられます。特定の注文しか受け付けない車両もあるかもしれません(車両のテイントと注文の容認)。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20211214/20211214210949.png" alt="f:id:nearme-jp:20211214210949p:plain" width="720" height="405" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2>おわりに</h2> <p>コンテナオーケストレーション、特にk8sの構成要素やスケジューリング機構をはじめとする幾つかの要素技術において、相乗りシャトルの配車ルーティングとのアナロジーを見出してきました。</p> <p>このアナロジーから、別分野ですが洗練されたk8sからの洞察を得て、配車ルーティングの実装の参考にしたり、概念を整理できればと思っています。実際、アフィニティの概念などはk8sから輸入して実装したところもあります。また、ここでは紹介してないですが、k8sの宣言的な作りも参考にしてきました。今後、k8sの自律分散的な挙動も参考にできるのではと考えています。</p> <p>最後になりますが、NearMeではエンジニアを募集しています!カジュアルな面談も可能ですので、お気軽にご連絡ください。</p> <ul> <li><a href="https://corp.nearme.jp/jobs/">採用サイトはこちら</a></li> </ul> <p><span style="font-size: 80%"><span style="color: #666666">Author: Kenji Hosoda</span></span></p> <p><a href="https://b.hatena.ne.jp/entry/" class="hatena-bookmark-button" data-hatena-bookmark-layout="basic-label" data-hatena-bookmark-lang="ja" title="このエントリーをはてなブックマークに追加"><img src="https://b.st-hatena.com/images/v4/public/entry-button/button-only@2x.png" alt="このエントリーをはてなブックマークに追加" width="20" height="20" style="border: none;" /></a><script type="text/javascript" src="https://b.st-hatena.com/js/bookmark_button.js" charset="utf-8" async="async"></script></p> nearme-jp Dartsで事前予約を加味した時系列の需要予測をしてみる hatenablog://entry/13574176438011044480 2021-09-21T13:56:57+09:00 2022-03-23T12:28:24+09:00 はじめに 今回は、事前予約型の乗車サービスにおける乗車需要を時系列解析を用いて予測します。ポイントとなるのが、注文日時と乗車日時の間に数日間のラグがあることです。典型的には、過去の乗車実績の時系列の変動から未来のそれを予測します。事前予約型ではさらに、事前に注文された乗車予定のデータを加味することが効果的であると考えられます。ここでは、シミュレーションによって事前予約型の注文のトイデータを作成し、事前予約を加味した時系列モデルを構築し検証します。また、同じモデルで実際のデータでの検証結果の概要も示します。 時系列解析を行うにあたっては、DartsというPythonのライブラリを利用しました。統… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210920/20210920182434.png" alt="f:id:nearme-jp:20210920182434p:plain" width="720" height="405" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2>はじめに</h2> <p>今回は、事前予約型の乗車サービスにおける乗車需要を時系列解析を用いて予測します。ポイントとなるのが、注文日時と乗車日時の間に数日間のラグがあることです。典型的には、過去の乗車実績の時系列の変動から未来のそれを予測します。事前予約型ではさらに、事前に注文された乗車予定のデータを加味することが効果的であると考えられます。ここでは、シミュレーションによって事前予約型の注文のトイデータを作成し、事前予約を加味した時系列モデルを構築し検証します。また、同じモデルで実際のデータでの検証結果の概要も示します。</p> <p>時系列解析を行うにあたっては、<a href="https://unit8co.github.io/darts/">Darts</a>というPythonのライブラリを利用しました。統計的手法や機械学習含め多数の時系列解析の予測モデルが備え付きで利用できます。そして、時系列データに対する様々な操作、モデルの構築・検証などが、統一的なAPIを通して利用できます(<a href="https://blog.ikedaosushi.com/entry/2020/08/25/003557">参考1</a>、<a href="https://qiita.com/DS27/items/0f5604e1c3b3cb4ed3ca">参考2</a>)。コード断片を通してこちらも紹介できればと思います。なお、今回利用した一連のコードは<a href="https://gist.github.com/kenji4569/46391a6b115e37ebf96b6fea50a623e7">こちら</a>に公開しています。</p> <h2>乗車実績のトイデータ作成</h2> <p>まず、乗車日時の確率分布が、週毎、月毎、四半期毎に周期的になるようにして、乗車日時のリストをランダムに生成します。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">from</span> darts.utils.timeseries_generation <span class="synPreProc">import</span> sine_timeseries, constant_timeseries time_length = <span class="synConstant">365</span> sample_size = <span class="synConstant">10000</span> distribution = <span class="synIdentifier">sum</span>([ sine_timeseries(length=time_length, value_frequency=(<span class="synConstant">4</span>/<span class="synConstant">365</span>), value_y_offset=<span class="synConstant">1</span>, freq=<span class="synConstant">'D'</span>), sine_timeseries(length=time_length, value_frequency=(<span class="synConstant">1</span>/<span class="synConstant">30</span>), value_y_offset=<span class="synConstant">1</span>, freq=<span class="synConstant">'D'</span>), sine_timeseries(length=time_length, value_frequency=(<span class="synConstant">1</span>/<span class="synConstant">7</span>), value_y_offset=<span class="synConstant">1</span>, freq=<span class="synConstant">'D'</span>), constant_timeseries(length=time_length, value=<span class="synConstant">1</span>, freq=<span class="synConstant">'D'</span>)]) p_values = (distribution / distribution.sum()[<span class="synConstant">0</span>]).values()[:,<span class="synConstant">0</span>] times = distribution.time_index.values ride_start_dates = np.random.choice(times, size=sample_size, replace=<span class="synIdentifier">True</span>, p=p_values) </pre> <p>そして、それを日毎に集計します。この時系列のデータは<a href="https://unit8co.github.io/darts/generated_api/darts.timeseries.html">TimeSeries</a>として格納します。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">from</span> darts <span class="synPreProc">import</span> TimeSeries time_counts = <span class="synIdentifier">dict</span>(<span class="synIdentifier">zip</span>(times, np.zeros(times.shape))) uniuqe, counts = np.unique(ride_start_dates, return_counts=<span class="synIdentifier">True</span>) <span class="synStatement">for</span> time, value <span class="synStatement">in</span> <span class="synIdentifier">zip</span>(uniuqe, counts): time_counts[time] = value target_series_df = pd.DataFrame(data=time_counts.items(), columns=[<span class="synConstant">'time'</span>, <span class="synConstant">'count'</span>]) target_series = TimeSeries.from_dataframe(target_series_df, freq=<span class="synConstant">'D'</span>, time_col=<span class="synConstant">'time'</span>, value_cols=<span class="synConstant">'count'</span>) </pre> <p>図にプロットします。</p> <pre class="code lang-python" data-lang="python" data-unlink>target_series.plot() </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210920/20210920123329.png" alt="f:id:nearme-jp:20210920123329p:plain" width="368" height="263" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>ノイズがありながらも指定した周期性があるのが分かります。</p> <h2>シンプルな予測</h2> <ul> <li>モデルの構築</li> </ul> <p>ここでは、素朴な時系列モデルとして<a href="https://unit8co.github.io/darts/generated_api/darts.models.exponential_smoothing.html">指数平滑化法</a>(ExponentialSmoothing)を利用します。Dartsの内部的には、ホルト-ウィンターズ法という"時系列の変動にトレンドと季節変動を追加し、それぞれの指数平滑の重ね合わせを期待値として算出する方法"を利用しています(<a href="https://hazm.at/mox/math/analysis/holt-winters-forecast.html">参考</a>)。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">from</span> darts.models <span class="synPreProc">import</span> ExponentialSmoothing model = ExponentialSmoothing() </pre> <ul> <li>学習と予測</li> </ul> <p>ある時点でトレーニング用とテスト用のデータに分割し、トレーニングデータからモデルを学習し、テストデータの予測をします。</p> <pre class="code lang-python" data-lang="python" data-unlink>split_ts = pd.Timestamp(<span class="synConstant">'2000-11-01'</span>) train, val = target_series.split_after(split_ts) model.fit(train) prediction = model.predict(<span class="synIdentifier">len</span>(val)) </pre> <p>次の図はその結果で、実測値(target)と予測値(forecast)をプロットしたものです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210920/20210920124626.png" alt="f:id:nearme-jp:20210920124626p:plain" width="366" height="270" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>テスト期間の初期は比較的予測が合ってますが、時間とともにズレが大きくなっているのが見てとれます。</p> <ul> <li>バックテスト</li> </ul> <p>時系列解析における予測精度を定量的に評価するため、<a href="https://unit8co.github.io/darts/generated_api/darts.models.forecasting_model.html#darts.models.forecasting_model.GlobalForecastingModel.backtest">バックテスト</a>という方法で検証します。 これは、時系列に沿った各時刻ステップにおいて、その時点までのデータを用いて学習したモデルを用いて、特定時刻ステップ先の値を予測していきます。</p> <pre class="code lang-python" data-lang="python" data-unlink>backtest = model.historical_forecasts( series=target_series, forecast_horizon=forecast_horizon, start=split_ts - Timedelta(timedelta(days=forecast_horizon))) </pre> <p>ここで、"forecast_horizon"は何時刻ステップ先(今回の場合は何日先)を予測するかという予測期間を指定します。"start"は予測を始めるタイミングを指します(ここではバックテストで予測された期間を揃えるため、"forecast_horizon"毎に"start"をずらしています)。</p> <p>次の図は、予測期間を1日先と10日先にして予測したものです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210920/20210920130905.png" alt="f:id:nearme-jp:20210920130905p:plain" width="366" height="270" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>"1 day forecast horizon"はバックテストにおける1日先の予測、"10 days forecast horizon" は10日先の予測のプロットです。1日先の方が実測データによりフィットしているのが分かります。</p> <p>バックテストで得た予測データと実測データとの誤差を表す指標として<a href="https://ja.wikipedia.org/wiki/%E5%B9%B3%E5%9D%87%E4%BA%8C%E4%B9%97%E5%81%8F%E5%B7%AE">平均二乗偏差</a>(RMSE)を用います。この値が小さいほど予測の精度が高いことを意味します。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">from</span> darts.metrics <span class="synPreProc">import</span> rmse <span class="synIdentifier">print</span>(<span class="synConstant">'Backtest RMSE = {}'</span>.format(rmse(target_series, backtest))) </pre> <p>平均二乗偏差は、予測期間が1日と10日の場合それぞれ、<code>5.88</code>と<code>10.75</code>となり、1日先の予測の方が精度が高いことが伺えます。</p> <ul> <li>ベースラインモデルとの比較</li> </ul> <p>先ほどのモデルが自明でない時系列の構造を捉えていることを示すため、もっとプリミティブな<a href="https://unit8co.github.io/darts/generated_api/darts.models.baselines.html">ベースラインモデル</a>と比較します。ここでは単に学習データの最後の値をそのまま以降の予測値として使用するものを用います。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">from</span> darts.models <span class="synPreProc">import</span> NaiveSeasonal model = NaiveSeasonal(<span class="synConstant">1</span>) </pre> <p>次の図は、先の指数平滑化法とベースラインのそれぞれのモデルにおいて、バックテストで算出した平均二乗偏差を予測期間に対してプロットしたものです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210920/20210920132414.png" alt="f:id:nearme-jp:20210920132414p:plain" width="380" height="261" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>どの予測期間でも指数平滑化法がベースラインモデルよりも平均二乗偏差が小さい=予測精度が高くなっているのが分かります。なお、週単位の周期性があるため、予測期間が7日辺りでベースラインモデルの精度も高くなっています。</p> <h2>乗車予定のトイデータ作成</h2> <p>予測期間毎に、予測期間先の乗車予定のデータを作成します。</p> <p>そのためにまず、先ほど生成した乗車日時のリストの各要素に対して、乗車日時と注文日時の差を生成します。差の分布は指数関数に従うようにします。</p> <pre class="code lang-python" data-lang="python" data-unlink>advanced_diffs = np.random.exponential(<span class="synConstant">5</span>, size=<span class="synIdentifier">len</span>(ride_start_dates)) </pre> <p>こちらがそのヒストグラムです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210920/20210920135619.png" alt="f:id:nearme-jp:20210920135619p:plain" width="377" height="247" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>そして、注文日時と乗車日時の差が予測期間(+集計タイミング)以上のもので乗車日時のリストをフィルタし、乗車時刻に関して日別にカウントします。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synStatement">def</span> <span class="synIdentifier">get_advanced_series</span>(forecast_horizon): advanced_start_dates = ride_start_dates[np.where(advanced_diffs &gt;= forecast_horizon + <span class="synConstant">1</span>)] time_counts = <span class="synIdentifier">dict</span>(<span class="synIdentifier">zip</span>(times, np.zeros(times.shape))) uniuqe, counts = np.unique(advanced_start_dates, return_counts=<span class="synIdentifier">True</span>) <span class="synStatement">for</span> time, value <span class="synStatement">in</span> <span class="synIdentifier">zip</span>(uniuqe, counts): time_counts[time] = value advanced_series_df = pd.DataFrame(data=time_counts.items(), columns=[<span class="synConstant">'time'</span>, <span class="synConstant">'count'</span>]) advanced_series = TimeSeries.from_dataframe(advanced_series_df, freq=<span class="synConstant">'D'</span>, time_col=<span class="synConstant">'time'</span>, value_cols=<span class="synConstant">'count'</span>) <span class="synStatement">return</span> advanced_series </pre> <p>次の図は、1日先の乗車予定("1 day advanced")と10日先の乗車予定("10 days advanced")のデータを実績データとともにプロットしたものです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210920/20210920152152.png" alt="f:id:nearme-jp:20210920152152p:plain" width="359" height="259" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>1日先の乗車予定の方が10日先の乗車予定より、実績データに近づいていることが分かります。とはいえ、10日先の乗車予定も兆候のようなものは見られます。</p> <h2>事前予約を加味した予測</h2> <ul> <li>モデルの構築</li> </ul> <p>今回は、事前予約を加味した時系列モデルとして、<a href="https://ja.wikipedia.org/wiki/%E7%B7%9A%E5%BD%A2%E5%9B%9E%E5%B8%B0">線形回帰</a>(Linear Regression)を用いました。実績データと予定データからなる複数の変数から予測したい時点の実績値を回帰します。モデルの概要は次のようになります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210920/20210920152920.png" alt="f:id:nearme-jp:20210920152920p:plain" width="1124" height="508" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>"target"は実績データ、"N advanced"はN日先の乗車予定データです。N日先の実績値を予測するため、未来のN点の乗車予定データと、予測点("predict")からM点前から現在("now")までの実績データを用います。これらを時系列に沿って取得しモデルを学習させます。ただし、N毎に別々のモデルを作成します。</p> <p>コードとしてはこのような形になります。</p> <pre class="code" data-lang="" data-unlink>from darts.models import RegressionModel model_N = RegressionModel(lags=list(range(-M, 1 - N)), lags_future_covariates=list(range(1 - N, 1)) )</pre> <p>"lags"は予測点に対して実績データを学習に利用する際の期間、"lags_future_covariates"は予定データを学習に利用する際の期間です。"future_covariates"は未来に関する情報の変数で、例えば、天気予報の雨量などを設定します(<a href="https://medium.com/unit8-machine-learning-publication/time-series-forecasting-using-past-and-future-external-data-with-darts-1f0539585993">参考</a>)。</p> <ul> <li>モデルの評価</li> </ul> <p>こちらもバックテストにより評価します。</p> <pre class="code" data-lang="" data-unlink>backtest = model_N.historical_forecasts( series=target_series, future_covariates=get_advanced_series(N), forecast_horizon=1, start=split_ts - Timedelta(timedelta(days=1))</pre> <p>今回は、"future_covariates"としてN日先の乗車予定データを指定します。また、学習は予測点を起点とした時系列のインデックスになっているので"forecast_horizon"は1としています。</p> <p>次に、平均二乗偏差を予測期間に対してプロットします。比較のため他のモデルによるものも加えました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210920/20210920154733.png" alt="f:id:nearme-jp:20210920154733p:plain" width="380" height="261" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>"Linear Regression with advanced"は事前予約を加味した線形回帰のモデル、"Linear Regression"は"future_covariates"を考慮しないで線形回帰を行ったものです。"Exponential Smoothing"と"Baseline"はそれぞれ、先ほどの指数平滑化法とベースラインモデルによるものです。事前予約を加味した線形回帰のモデルがどの予測期間に対しても最も低い平均二乗偏差=最も高い予測精度になっていることが分かります。</p> <ul> <li>実際のデータでの評価</li> </ul> <p>最後に、ある条件で抽出した実際の注文データに対して同様の解析を行なったものを示します。具体的には示しませんが、実際のデータ分布は上記のトイデータとある程度似たものとなっています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210920/20210920160123.png" alt="f:id:nearme-jp:20210920160123p:plain" width="380" height="261" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>図の通り、トイデータと同様に、実際のデータに対しても事前予約を加味した線形回帰のモデルが最も高い予測精度になりました。</p> <h2>その他のモデル</h2> <p><a href="https://gist.github.com/kenji4569/46391a6b115e37ebf96b6fea50a623e7">公開したコード</a>では、線形回帰のモデルの他に、非線形な回帰手法として<a href="https://qiita.com/kuroitu/items/57425380546f7b9ed91c">勾配ブースティング</a>の一つである<a href="https://ja.wikipedia.org/wiki/LightGBM">LightGBM</a>による方法も検証しました。結果として、今回のデータでは線形回帰の方がわずかながら予測精度が高かったです。時間的に局所的な部分の線形性が強いためと推察されます。</p> <p>また、曜日の情報を(sin/cosで符号化して)加味したモデルも構築して検証しました。結果は、曜日を考慮することでわずかながら予測精度が高まりました。特に、予測期間が1週間以降の方が曜日による効果が大きかったです。明示的な周期性の情報が予測に効いていると思われます。</p> <p>他、Dartsにはニューラルネットワーク系のモデルも多数用意されていますが、今回はスコープ外としました。計算時間がかかるのとパラメタ調整が難しいので手軽には試せないのと、こちらはより大量で複雑なデータに対して威力を発揮するものと考えられるからです。</p> <h2>おわりに</h2> <p>事前予約型の乗車注文データに対して時系列解析を行い、事前予約の情報を加味したモデルの方が乗車需要を高い精度で予測できることを示しました。また、Dartsを用いてこのような解析が手軽に行えることを示しました。サービス開発において様々な場面で過去データから未来を予測することは重要です。今回の解析がその足掛かりなればと思います。</p> <p>最後になりますが、NearMeではエンジニアを募集しています!まだまだ多くの可能性が潜んでいる領域です。興味を持った方はぜひ以下から応募いただければと思います。</p> <ul> <li><a href="https://corp.nearme.jp/jobs/">採用サイトはこちら</a></li> </ul> <p><span style="font-size: 80%"><span style="color: #666666">Author: Kenji Hosoda</span></span></p> <p><a href="https://b.hatena.ne.jp/entry/" class="hatena-bookmark-button" data-hatena-bookmark-layout="basic-label" data-hatena-bookmark-lang="ja" title="このエントリーをはてなブックマークに追加"><img src="https://b.st-hatena.com/images/v4/public/entry-button/button-only@2x.png" alt="このエントリーをはてなブックマークに追加" width="20" height="20" style="border: none;" /></a><script type="text/javascript" src="https://b.st-hatena.com/js/bookmark_button.js" charset="utf-8" async="async"></script></p> nearme-jp RustでOpenStreetMapを触ってみる hatenablog://entry/26006613795443014 2021-08-11T11:43:47+09:00 2022-04-07T18:57:34+09:00 今回はRustという注目のプログラミング言語を用いて、OpenStreetMapというオープンな地理情報データを触ってみます。 背景 Rustに注目した背景としては、大規模かつ複雑な交通データに対して解像度をもっと上げた課題解決が必要になってきたからです。NearMeのシステムは現状、メインはTypeScriptで書いていて、最適化アルゴリズムに関連する部分をPythonで書いています。スタートアップとして、初動としてはこの構成でよかったと思いますが、更なるチューニングを求めて検討したのがRustです。 Rustの特徴としては、C/C++並の高速な処理速度、安全で効率的なメモリ管理、生産性を高… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210809/20210809224836.png" alt="f:id:nearme-jp:20210809224836p:plain" width="720" height="405" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>今回は<a href="https://www.rust-lang.org/ja">Rust</a>という注目のプログラミング言語を用いて、<a href="https://openstreetmap.jp/">OpenStreetMap</a>というオープンな地理情報データを触ってみます。</p> <h2>背景</h2> <p>Rustに注目した背景としては、大規模かつ複雑な交通データに対して解像度をもっと上げた課題解決が必要になってきたからです。NearMeのシステムは現状、メインはTypeScriptで書いていて、最適化アルゴリズムに関連する部分をPythonで書いています。スタートアップとして、初動としてはこの構成でよかったと思いますが、更なるチューニングを求めて検討したのがRustです。</p> <p>Rustの特徴としては、C/C++並の高速な処理速度、安全で効率的なメモリ管理、生産性を高める様々な機能があげられます。Pythonでも、定型の低レイヤーの処理をC/C++で行わせて、高レイヤーの処理をPythonで書くという形で、パフォーマンスと生産性を両立させることができますが、課題によっては限界があります。逆に、全部をC/C++で書くのも大変です。Rustは、流石にPythonよりは簡潔には書けないものの、C++よりは少ない努力で速さと安全性を手に入れることができると思います。別途、Goなどの他の言語との比較をすると込み入りますが、端的に言うと、今回はアルゴリズム周りでよく使われているC++のポジションにより近いということでRustを検討しました。</p> <p>なお、NearMeのシステムは<a href="https://ja.wikipedia.org/wiki/%E3%83%9E%E3%82%A4%E3%82%AF%E3%83%AD%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9">マイクロサービス</a>で構築しているので、Rustで低レイヤーを書いてバインディングするという方法の他、Rustで一つマイクロサービスを構築するといことも可能です。もちろん、言語の種類が増える弊害もあるので慎重に検討する必要がありますが、その中でRustはユニークで有望な候補になっています。</p> <p>ここでは、Rustを始めるとっかかりとして、OpenStreetMapのデータを読み込んで可視化したいと思います。OpenStreetMapは、"自由に利用でき、なおかつ編集機能のある世界地図を作る共同作業プロジェクト"です。世界地図は巨大なので、関東などエリアを絞ってデータをダウンロードすることができます。それでも数百Mくらいのサイズで、素のPythonで扱うにはしんどい大きさです。NearMeのシステムでもOpenStreetMapを一部利用しているところがあるのですが、コアな部分はC++で書かれたライブラリを利用しているのでカスタマイズしづらい状況です。OpenStreetMapをRustで扱えるようになれば可能性が拡がると考えています。</p> <h2>Jupyter NotebookでRustを扱う</h2> <p>インタラクティブにプログラミングを試せる環境として、<a href="https://jupyter.org/">Jupyter Notebook</a>は最適です。利用できる言語はPythonがデフォルトですが、他の言語も<a href="https://github.com/jupyter/jupyter/wiki/Jupyter-kernels">Jupyter kernel</a>をインストールして利用することができます。</p> <p>ここでは<a href="https://www.docker.com/">Docker</a>/<a href="https://docs.docker.jp/compose/toc.html">Docker Compose</a>を用いて、RustのkernelがインストールされたJupyter Notebookの環境を用意します。次のように、<a href="https://github.com/kenji4569/jupyter-rust">こちら</a>のリポジトリをダウンロードしてDockerでコンテナを建ち上げてください。</p> <pre class="code" data-lang="" data-unlink>git clone git@github.com:kenji4569/jupyter-rust.git cd jupyter-rust docker-compose up</pre> <p>建ち上げたコンテナのログの最後の方に、</p> <pre class="code" data-lang="" data-unlink>http://127.0.0.1:8888/?token=xxx</pre> <p>という記載があるので、このURLをブラウザで開いてください。</p> <p>そして、ブラウザ上で、<a href="https://github.com/kenji4569/jupyter-rust/blob/main/notebooks/evcxr_jupyter_tour.ipynb">notebooks/evcxr_jupyter_tour.ipynb</a> というノートブックを開いて、Rustのプログラムを実行してみてください。</p> <h2>OpenStreetMapのデータを扱う</h2> <h3>データをダウンロード</h3> <p>まずは、<a href="https://download.geofabrik.de/asia/japan.html">https://download.geofabrik.de/asia/japan.html</a> から、kanto-latest.osm.pbf というファイルをダウンロードしてください。これは関東エリアの交通データになります。データは<a href="https://developers.google.com/protocol-buffers">Protocol Buffers</a>で符号化されています。</p> <p>このファイルを<code>jupyter-rust/notobooks</code>ディレクトリに置き、同ディレクトリにて新たなNotebookファイルをkenelとしてRustを選択して作成してください。</p> <h3>データの読み込み</h3> <p><a href="https://docs.rs/osmpbfreader/0.15.0/osmpbfreader/">osmpbfreader</a>というライブラリを用いて、先ほどダウンロードしたデータを読み込んでみます。 以下は、そのコードです。</p> <p><script src="https://gist.github.com/4c53b4013e67c007e2ff2588e7a1208b.js"> </script></p> <p>1行目の、</p> <pre class="code" data-lang="" data-unlink>extern crate osmpbfreader;</pre> <p>にて、osmpbfreaderライブラリがダウンロードされ、コンパイルされます。</p> <blockquote><p>kernelのセッションごとに、/tmp以下にディレクトリが作られて、そこでファイルが展開されています。kernelをリスタートすると、別のディレクトリになるので、ライブラリを再度ダウンロードしてコンパイルすることになります。ここは遅いのですが、<a href="https://github.com/mozilla/sccache">sccache</a>を利用して多少改善することができます。<code>cargo install sccache</code>をDockerfileに書いてビルドし直して、<code>:sccache 1</code>をNotebook上で実行すると適用されます。</p></blockquote> <p>レコードの1行1行読み込みは、</p> <pre class="code" data-lang="" data-unlink>for obj in pbf.par_iter().map(Result::unwrap) {</pre> <p>にて行われます。今回は全部で約4000万レコードを走査しています。</p> <p>手元のMacBook(仮想マシン上でCPU: 2.9G x 2, Memory: 16G)では、この走査は8秒程度でした(正確には<a href="https://docs.rs/timeit">timeit</a>で測りますが、<code>:timing</code>をNotebook上で実行するとセル単位の実行時間を測ることができます)。<code>.par_iter()</code>を<code>.iter()</code>にすると、CPUの並列処理が行われず16秒程度でした。なお、C++をバインドしたPythonのライブラリ<a href="https://github.com/osmcode/pyosmium">pyosmium</a>で読み込むと50秒程度、素のPythonで書かれたライブラリ<a href="https://github.com/dezhin/osmread">osmread</a>だと10分くらいかかりました(ただしこちらは素のPythonのprotobufの実装が遅いという要因もあるようです)。ちなみに、Goのライブラリ<a href="https://github.com/qedus/osmpbf">osmpbf</a>はRustと同程度の速度でした。</p> <p>各レコードは、Node、Way、Relationのいずれかの型になっていて、以下のように場合分けして処理しています。</p> <pre class="code" data-lang="" data-unlink>match obj { osmpbfreader::OsmObj::Node(node) =&gt; { ... } osmpbfreader::OsmObj::Way(way) =&gt; { ... } osmpbfreader::OsmObj::Relation(rel) =&gt; { ... } }</pre> <p>今回は、それぞれ初回に読み取ったレコードを出力しています。例えば、初回のNodeレコードは、</p> <pre class="code" data-lang="" data-unlink>Node { id: NodeId(31236558), tags: Tags({}), decimicro_lat: 356350730, decimicro_lon: 1397681010 }</pre> <p>となっています。</p> <p>Nodeは一つの地点を表していて、緯度経度の情報を保持しています。</p> <p>Wayは複数のNodeからなり境界線を表現しています。NodeとWayからグラフ構造が導かれます。</p> <p>Relationは、NodeやWayや他のRelationからなり、一括りの有名な道路といった少し大きな構造を表します。</p> <h3>タグの表示</h3> <p>Node、Way、Relationそれぞれの各オブジェクトに対して、複数のTagが付与されています。Tagはkey-valueの形式で、例えば、道の特徴として、 <a href="https://labs.mapbox.com/mapping/mapping-for-navigation/road-features-mapping-guide/">Road Features Mapping Guide | Mapbox</a> にあるようなTagが付与されています。以下のコードでタグを集計して表示します。</p> <p><script src="https://gist.github.com/7e11c73894ba925f2d6d43727f442d3e.js"> </script></p> <p>一番目のセルでは、Node、Way、Relationそれぞれにおいて、Tagのkey別の集計をとり、回数が多いものを上位から表示しています。まだ一部ですが、様々なタグが存在するのが見て取れます。</p> <p>二番目のセルでは、"highway"をkeyとするTagにおいてvalue別の集計を行い、回数が多いものを上位から表示しています。特に、Wayにおいて、"primary"や"secondary"といった値があるのに着目してください。これらは道の種類を表していて、経路探索において重要なマーカーになります(<a href="https://github.com/valhalla/valhalla/blob/master/docs/thor/path-algorithm.md">参考1</a>、<a href="https://github.com/Project-OSRM/osrm-backend/blob/master/profiles/car.lua#L141">参考2</a>)。</p> <p><h3 id="visualization">グラフ構造の可視化</h2></p> <p>最後に、NodeとWayからなるグラフ構造を可視化します。以下のコードでは、東京駅付近にあるNodeとそれに紐づくWayを選択して描画しています。</p> <p><script src="https://gist.github.com/bf62af09072af879831119596a6ae491.js"> </script></p> <p>こちらがその出力です。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210810/20210810205351.png" alt="f:id:nearme-jp:20210810205351p:plain" width="1200" height="1063" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>ここではNodeを緯度経度を座標とする点、Wayを道の種類で色付けした線として描画しています。 赤の線は大通り、青の線は一般的な車道、緑の線は歩行者用の道、灰色の線はその他の境界線となっています。</p> <h2>おわりに</h2> <p>RustをJupyter Notebookで動かせるように環境構築し、OpenStreetMapのデータを読み込んで、そのデータ構造を眺めてきました。Rustは高速に動作する一方、様々なデータ操作を比較的簡潔に書くことができたのではないかと思います。多少独特な部分はありますが、結果的に怪しい書き方が矯正される感覚になります。ここから先もっと色々なアルゴリズムを試せると思いますので、当記事がそのきっかけになれば幸いです(OpenStreetMapに関する有用なRustのライブラリは<a href="https://github.com/pka/awesome-georust">こちら</a>が参考になりました)。</p> <p>最後になりますが、NearMeではエンジニアを募集しています!まだまだ多くの可能性が潜んでいる領域です。興味を持った方はぜひ以下から応募いただければと思います。</p> <ul> <li><a href="https://corp.nearme.jp/jobs/">採用サイトはこちら</a></li> </ul> <p><span style="font-size: 80%"><span style="color: #666666">Author: Kenji Hosoda</span></span></p> <p><a href="https://b.hatena.ne.jp/entry/" class="hatena-bookmark-button" data-hatena-bookmark-layout="basic-label" data-hatena-bookmark-lang="ja" title="このエントリーをはてなブックマークに追加"><img src="https://b.st-hatena.com/images/v4/public/entry-button/button-only@2x.png" alt="このエントリーをはてなブックマークに追加" width="20" height="20" style="border: none;" /></a><script type="text/javascript" src="https://b.st-hatena.com/js/bookmark_button.js" charset="utf-8" async="async"></script></p> nearme-jp タクシーとバスのなめらかな関係について hatenablog://entry/26006613782151925 2021-07-02T11:25:08+09:00 2022-03-23T12:24:05+09:00 NearMeの相乗りシャトルは、タクシーとバスの中間となる第三の選択肢としての交通手段を提供します。 タクシーはユーザーの利便性は高いですが、少人数しか運べないので配車効率は低く、料金は高いです。 一方、バスはその逆です。 今回は、相乗りシャトルで用いる主なパラメタについて説明し、一方の極限ではタクシーに、もう一方ではバスに還元できること、そして、それらを"なめらかに"繋げることができることを示したいと思います。 許容迂回係数 許容迂回係数は、単独の場合の乗車時間の何倍まで、相乗りによって迂回して乗車時間が伸びることを許せるかという値です。 例えば、東京駅→羽田空港の注文があり、単独で行く場合… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210702/20210702000303.png" alt="f:id:nearme-jp:20210702000303p:plain" width="720" height="405" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>NearMeの<a href="https://app.nearme.jp/airport-shuttle/">相乗りシャトル</a>は、タクシーとバスの中間となる第三の選択肢としての交通手段を提供します。 タクシーはユーザーの利便性は高いですが、少人数しか運べないので配車効率は低く、料金は高いです。 一方、バスはその逆です。 今回は、相乗りシャトルで用いる主なパラメタについて説明し、一方の極限ではタクシーに、もう一方ではバスに還元できること、そして、それらを"なめらかに"繋げることができることを示したいと思います。</p> <h2>許容迂回係数</h2> <p>許容迂回係数は、単独の場合の乗車時間の何倍まで、相乗りによって迂回して乗車時間が伸びることを許せるかという値です。 例えば、東京駅→羽田空港の注文があり、単独で行く場合30分かかるとします。 ここで、許容迂回係数が1.5とすると、30分x1.5で45分まで迂回が許されます。 このとき、六本木→羽田空港の注文と相乗りすると、東京駅→六本木→羽田空港というルートになり、トータル40分かかるようになったとします。その場合、40分 &lt; 45分なので、この相乗りは成立します。 次に、新宿→羽田空港の注文と相乗りすると、東京駅→新宿→羽田空港というルートになり、トータル50分かかるようになったとします。今度は、50分 > 45分なので、この相乗りは不成立になります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210702/20210702105617.png" alt="f:id:nearme-jp:20210702105617p:plain" width="1200" height="789" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>タクシーの場合は最短で着くので、許容迂回係数は1になります。 バスの場合は非常に遠回りすることもあり、迂回許容係数は大きな値になります。 相乗りシャトルでは、この値は、空港送迎、街中、通勤などのサービスタイプごとに異なります。 ユーザーがどの程度迂回を許容できるかと、車両をどこまで用意できるかのバランスによって調整され、 より"タクシー的な"使い方、より"バス的な"使い方、も可能です。</p> <h2 id="ride_buffer_time">許容待ち時間</h2> <p>NearMeの相乗りシャトルでは、出発時刻指定か到着時刻指定かを選べるようになっています。 このとき、許容待ち時間というのが裏では設定されていて、最終的な出発/到着時刻は希望した時刻よりズレる可能性があります。 例えば、10:00時出発を希望した場合、許容待ち時間が20分だとすると、出発時刻は10:00 ~ 10:20の範囲の値になります。 11:00時到着を希望した場合、許容待ち時間が20分だとすると、到着時刻は10:40 ~ 11:00の間の範囲の値になります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210702/20210702011737.png" alt="f:id:nearme-jp:20210702011737p:plain" width="908" height="774" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>相乗りがなければ、出発時刻または到着時刻は、指定した時刻通りになります。 相乗りが発生すると、お互いの時間制約を守れるように、指定した時刻からズレることがあります。 このズレをどこまで許容できるかが、許容待ち時間になり、この時間を増やすほどマッチ率は高まりまが、 ユーザーには不便をかけることになります。</p> <p>ここで、タクシーの場合は、相乗りが発生しないので許容待ち時間は0になります。 バスの場合のそれは、前のバスと次のバスの運行間隔であり、この値は大きな値になります。 相乗りシャトルでは、この値もサービスタイプごとに調整されます。実際、NearMeの相乗りシャトルでは、街中利用では短く、空港送迎のような長距離では長くしています。 また、人によってはとりあえず午前中につければいいなど、個別のユーザーごとに変更することも可能です。 さらに、時間帯によっても変えることもできます。</p> <h2>最大乗車人数</h2> <p>配車の効率性に影響するもう一つのパラメタは、各車両の最大乗車人数です。 タクシーの場合は3人程度、バスだと数十人乗れたりします。 相乗りシャトルの場合は、一般には、6~9人乗りが多いです。</p> <p>なお、相乗りでは、下図のように乗車と降車が入り混じるので、乗車する人全体ではなく、流れの中で最大になる人数に対しての制約になります。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210702/20210702011139.png" alt="f:id:nearme-jp:20210702011139p:plain" width="1090" height="528" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>ユーザーの利便性という意味では、タクシーのような少ない乗車人数の方が快適ですが、 配車の効率性を考えるとなるべく多く乗せられた方がいいです。</p> <p>他方、バスの場合は、数十人乗りなのに数人しか乗ってない、みたいなこともあり、それもまた非効率です。 適切なサイズの車両を選択できれば、より効率化できます。 実際、NearMeの相乗りシャトルでは、後から適切なサイズの車両を割り当てたり、サービスタイプによっては、最大乗車人数を大きくして、ミニバスのようなもう少し大きめの車両を利用することもあります。</p> <h2>乗車位置の補正距離</h2> <p>乗車位置はタクシーの場合は任意で、バスの場合は固定されています。 相乗りシャトルでは、乗車位置は基本的には任意です。 ただし、他に乗車する人もいて、よりスムーズな乗車が求められるので、 車両が停めやすい場所まで少し歩いてもらったり、 近くの人達を一緒に乗車させたりするといった乗車位置の補正をする場合があります。 しかしながらこの補正は、バスの時ほどには求められません。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210702/20210702013623.png" alt="f:id:nearme-jp:20210702013623p:plain" width="926" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2>おわりに</h2> <p>許容迂回係数、許容待ち時間、最大乗車人数、 乗車位置の補正距離という、相乗りシャトルの主要なパラメタについて説明しました。 そして、それぞれ配車効率とユーザーの利便性においてトレードオフがあり、連続的に調整できるものであることを示しました。 このとき、配車のサービスタイプなどに応じてパラメタを柔軟に調整でき、その極限においてタクシーとバスがあること示しました。 このような柔軟性により、ユーザーのニーズに合わせたより幅広い選択肢を提供できるようになると期待できます。</p> <p>最後になりますが、NearMeではエンジニアを募集しています!まだまだ多くの可能性が潜んでいる領域です。興味を持った方はぜひ以下から応募いただければと思います。</p> <ul> <li><a href="https://corp.nearme.jp/jobs/">採用サイトはこちら</a></li> </ul> <p><span style="font-size: 80%"><span style="color: #666666">Author: Kenji Hosoda</span></span></p> <p><a href="https://b.hatena.ne.jp/entry/" class="hatena-bookmark-button" data-hatena-bookmark-layout="basic-label" data-hatena-bookmark-lang="ja" title="このエントリーをはてなブックマークに追加"><img src="https://b.st-hatena.com/images/v4/public/entry-button/button-only@2x.png" alt="このエントリーをはてなブックマークに追加" width="20" height="20" style="border: none;" /></a><script type="text/javascript" src="https://b.st-hatena.com/js/bookmark_button.js" charset="utf-8" async="async"></script></p> nearme-jp 配車ルーティングの状態管理について hatenablog://entry/26006613772201071 2021-06-07T11:40:10+09:00 2022-03-23T12:23:57+09:00 配車のルーティングにおいて、最適化のアルゴリズムはコアとなる機能ですが、 これに相対して開発する必要があるのが、データベースを絡めた永続的な状態管理です。 事前予約型の空港送迎の相乗りサービスでは、注文が入る度に逐次的にルーティングを行なっていて、注文全体で時系列に変化する"ルーティングの状態"があります。 ここではその状態管理についてアーキテクチャを交えて解説します。 サービス構成 NearMeで構築している配車ルーティングは次の二つのサービスに分かれて処理されています。 一つは、ルーティングサービスで、最適化のアルゴリズムを提供します。データベースとは直接はやり取りせず、永続的な状態を持た… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210605/20210605131705.png" alt="f:id:nearme-jp:20210605131705p:plain" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>配車のルーティングにおいて、最適化のアルゴリズムはコアとなる機能ですが、 これに相対して開発する必要があるのが、データベースを絡めた永続的な状態管理です。 事前予約型の<a href="https://app.nearme.jp/airport-shuttle/">空港送迎の相乗りサービス</a>では、注文が入る度に逐次的にルーティングを行なっていて、注文全体で時系列に変化する"ルーティングの状態"があります。 ここではその状態管理についてアーキテクチャを交えて解説します。</p> <h2>サービス構成</h2> <p>NearMeで構築している配車ルーティングは次の二つのサービスに分かれて処理されています。</p> <p>一つは、ルーティングサービスで、最適化のアルゴリズムを提供します。データベースとは直接はやり取りせず、永続的な状態を持たずに、入力を受け取って出力を返します。ただし、地点間の最短経路の移動時間を取得するために地図サービスとは連携しています。</p> <p>もう一つは乗車サービスです。これはデータベースとやり取りして、乗車の諸々の状態を管理します。ユーザー、ドライバー、オペレータからの様々な操作を受けます。そして、ルーティングサービスを介して、現在の"ルーティングの状態"から次の"ルーティングの状態"を算出し、データベースを更新します。</p> <p>サービスを分けた理由として、まず、それぞれで得意な言語を利用したかったからというのがあります。NearMeでは基本的にTypeScriptで書いていますが、ルーティングサービスは数理最適化のライブラリのためPythonを利用しています。次に、ルーティングサービス単体でCPUに最適化されたマシンでスケールさせたかったというのもあります。乗車サービスではボトルネックはデータベースになる一方、ルーティングサービスではボトルネックはCPUになるからです。また、ステートレス、ステートフルで境界を分けた方が整理しやすいというのもあります。結果的に、コードベースはそれぞれ大きくなり、この分割によって見通しはよくなっています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210605/20210605224959.png" alt="f:id:nearme-jp:20210605224959p:plain" width="1200" height="540" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2>状態遷移</h2> <p>配車のルーティングでは、どの注文とどの注文をマッチングさせるかを決めます。各運行において、どの順番で乗車させて、どの順番で降車させるかも決め、複数の経由地からなるルートも算出します。あまりにルートが長すぎるとマッチングは非成立となります。また、車両に乗れる人数以上を乗せることになったり、あらかじめユーザーが決めた希望出発/到着時間から離れてもマッチングは非成立となります。</p> <p>一般にこれは組合せ最適化問題を解くことになり、扱う要素数が増えると爆発的に計算量が増えます。そのため、組合せ最適化問題に帰着する前に、まず、適切な数の注文数に絞ります。ただし、絞り込んだ注文とそれ以外の注文はなるべく互いマッチしないようにします。例えば、空港送迎の相乗りサービスでは、初めに大きく時間と空間で絞り、時間として日付を、空間として<a href="https://tech.nearme.jp/entry/2021/05/08/224059">配車サービスのマルチテナント化について</a>で説明した「羽田空港送迎」のようなサービスのエリアを用いています。この絞り込みはデータベースのインデックスにより高速に行われます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210605/20210605160723.png" alt="f:id:nearme-jp:20210605160723p:plain" width="1200" height="626" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>絞り込みの後は、注文が入る度に、また注文がキャンセルされる度に、配車のルーティングを行います。このとき、前回のルーティング結果を保存しておいて、それを基に最適化アルゴリズムを用いて次のルーティングを算出します。なお、アルゴリズムの中では、出入りする注文に対してさらに"近い"注文に絞って、<a href="https://ja.wikipedia.org/wiki/%E5%B1%B1%E7%99%BB%E3%82%8A%E6%B3%95">山登り法</a>的に組合せ最適化問題を解いています。</p> <p>ここで各注文の配車可否という状態が加わると、また性質の異なる状態を考慮する必要があります。注文が入ると、運行管理者またはドライバーに通知され、そこでルートや在庫状況などを考慮して配車可否を判断します。特に事前予約の場合、配車可否までに多少時間がかかることもあります。したがって、その間に注文が入ったり、キャンセルされたりする可能性があります。このとき、配車可能な注文だけからなる"ルーティングの状態"と、配車可能な注文と配車未決定の注文からなる"ルーティングの状態"に枝分かれします。前者を"正規の状態"、後者を"ドラフトの状態"と定義します。ユーザーやドライバーには"正規の状態"が提示されますが、承認者には"ドラフトの状態"が提示されます。"ドラフトの状態"は、正規の状態から派生し、いくつかのステップを経て、正規の状態にマージされます。各運行においてそれに紐づく注文全てが承認になった時、それらの注文がマージされます。逆に、ここで枝分かれできないと、配車可否の間に追加の注文を受けられず機会損失になります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210605/20210605160742.png" alt="f:id:nearme-jp:20210605160742p:plain" width="1200" height="436" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>ルーティングの各ステップにおいては、より良い最適化のため、注文同士はくっついたり、離れたりします。一旦くっついてても、他の注文とくっついた方がよい場合は離れることもあります。更新の影響範囲を考えると、入力した注文とそれにマッチした注文だけでなく、絞り込んだ注文全てにおいて、どの注文も変化する可能性があります。ルーティング結果の保存の際は、この変化した部分を抽出して差分更新しています。この幅広い影響範囲は後述するロック処理にも関わってきます。ただし、この運行跨ぎの注文の再配置はオペレーションが煩雑になる懸念もあり、配車未決定の注文に限定できるようにもしています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210605/20210605160759.png" alt="f:id:nearme-jp:20210605160759p:plain" width="1200" height="513" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>補足として、このような逐次的な方法ではなく、あるタイミングで一気にルーティングを行うバッチ型の方法もあります。バッチ型の方が状態管理としてはシンプルです。また、逐次的でも現実的に妥当な解は得られていますが、バッチ型で一括で時間をかけて問題を解いた方がより良い解は見つかる可能性はあります。デメリットとしては、配車可否の判断がバッチのタイミングになってしまうので、ユーザーは配車可否が判明するまでより待たされることになります。また、バッチの後に入ってきた注文を処理するには、また別の仕組みが必要になります。</p> <h2>ロック処理</h2> <p>データベースの処理において注意しなければならないのが、データの整合性を保つためのロック処理(排他制御処理)です。 注文やキャンセル、配車可否といった非同期のリクエストを、 "ルーティングの状態"という共有したリソースに対して行う際にロックが必要になってきます。</p> <p>簡単な例として、同じ注文に対して、ユーザーのキャンセルと運行管理者の注文承認を同じタイミングで行うとどうなるでしょうか。 本来、注文承認はキャンセルが行われたら処理できないというバリデーションで弾かれます。 ロックがないと、キャンセルと承認が同時にリクエストされたとき、それぞれまず同じ状態の注文を参照する可能性があります。この場合、未承認の注文です。その後、キャンセルリクエストが処理され、注文の状態がキャンセルになります。追って承認のリクエストが処理されると、このリクエストは未承認の注文として元の注文を参照しているので、先のバリデーションが効かず、注文の状態を承認にしてしまいます。ユーザーはキャンセルしたと思ってるのに、注文は承認されて処理されてしまうことになります。ロックがあるとこのようは不整合を防ぐことができます。</p> <p>ロックの方法には、悲観的ロックと楽観的ロックがあります。悲観的ロックは、共有するリソースをロック対象として指示し、他リクエストの読み取りを防ぎます。先の例だと、キャンセルと承認が同時にリクエストされたとき、どちらか一方が先に処理されて状態を更新した後、もう片方が処理されます。キャンセルが先に処理された場合、非承認のリクエストは、キャンセルの状態を読み出すことになるので、バリデーションで弾かれます。楽観的ロックは、一旦、投機的にそれぞれのリクエストを実行しておいて、状態更新時に読み出した状態から変化があれば、その処理を破棄します。</p> <p>悲観的ロックか楽観的ロックかという話は込み入るのですが、基本的には、悲観的ロックの方が単純で扱いやすいと考えています。楽観的ロックでは、変化を検出するためにバージョンを各レコードに追加するとともに(状況によってはタイムスタンプでも代替可能です)、データが競合し更新に失敗した時のリカバリ処理を考える必要があります。特に、リカバリ処理を自動で行うにはキューのシステムが必要で、こちらも注意深くケアする必要あります。競合が稀にしか起きず自動リカバリが必要ないとき、どうしてもスループットを上げたいとき(投機的実行が増えるのでシステム負荷は上がります)、また、データベースの特性上悲観的ロックが使えないときなどに利用されると思います。後から楽観的ロックを実装するというのも選択肢としてありだと思います。</p> <p>前置きが長くなりましたが、注文全体で時系列に変化する"ルーティングの状態"の場合も、注文が入る度、キャンセルされる度にロックが必要です。前述したように、粒度の大きな絞り込み以降、どの注文が変化するかは基本的に解いてみないと分からないので、悲観的ロックの場合は、その絞り込みのところでロックをかける必要があります。実際には、ロック用のテーブルを用意し、日付xエリアをキーとするレコードを作成してロックをかけます。なおこの時点で、最適化アルゴリズムの処理速度からスループットの上限が決まってしまいます。現時点で、この値は問題ないレベルですが、もし限界が来たら更なる時間分割、エリア分割して、ロックの粒度を細かくする必要があります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210605/20210605160813.png" alt="f:id:nearme-jp:20210605160813p:plain" width="1200" height="623" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>他方、"ルーティングの状態"に対して個々の注文でバージョン管理するような楽観的ロックを行うことも可能です。しかしこの場合、新たな懸念があります。例えば、新規注文とキャンセルが同時にリクエストされたとき、新規注文は、キャンセルが行われた運行に対してマッチできたところが、マッチされない可能性があるのです。キャンセルされてない"ルーティングの状態"を基に次の"ルーティングの状態"を求めているためです。このとき、前述した影響ありうる注文セット全体を悲観的にロックする場合はどうでしょうか。キャンセル→新規注文の順番で処理された場合は当然、新規注文はキャンセルが行われた運行に対してマッチします。新規注文→キャンセルの順番では、新規注文は一旦その運行にマッチしませんが、次にキャンセルが行われたときに、キャンセルの注文に"近い"注文で再配置されるので、先ほどマッチしなかった新規注文が今度はマッチする可能性があります。全体の最適化という観点では、このような悲観的ロックに分があると考えられます。</p> <h2>おわりに</h2> <p>NearMeで構築している配車ルーティングの状態管理について説明しました。サービス分割、データの絞り込み、逐次的な最適化、ドラフトの分岐、運行を跨いだ注文の再配置、ロック処理など、様々な考慮が必要でした。そして、それらがルーティングの性能に関わっていることを示しました。</p> <p>最後になりますが、NearMeではエンジニアを募集しています!配車ルーティングの最適化についてさらに詳しく知りたい、また、自分ならもっと最適化できると思われた方はぜひ応募いただければと思います。</p> <ul> <li><a href="https://corp.nearme.jp/jobs/">採用サイトはこちら</a></li> </ul> <p><span style="font-size: 80%"><span style="color: #666666">Author: Kenji Hosoda</span></span></p> <p><a href="https://b.hatena.ne.jp/entry/" class="hatena-bookmark-button" data-hatena-bookmark-layout="basic-label" data-hatena-bookmark-lang="ja" title="このエントリーをはてなブックマークに追加"><img src="https://b.st-hatena.com/images/v4/public/entry-button/button-only@2x.png" alt="このエントリーをはてなブックマークに追加" width="20" height="20" style="border: none;" /></a><script type="text/javascript" src="https://b.st-hatena.com/js/bookmark_button.js" charset="utf-8" async="async"></script></p> nearme-jp 配車サービスのマルチテナント化について hatenablog://entry/26006613725873278 2021-05-08T22:40:59+09:00 2022-03-23T12:23:34+09:00 配車サービスを開発するにあたって、初めから取り入れようと思ったのがマルチテナント化です。 マルチテナント化とは、多くのSaaSサービスで見られるように、一つのアーキテクチャで複数の企業が個別にサービスを利用できるようにすることです。 結果としてこの機能は、配車固有の事情と相まって、事業展開に重要な役割を果たしてきました。 ここではその導入背景やアーキテクチャなどについて説明します。 背景 海外の配車サービスだと、個人のドライバーがユーザーの注文を直接受けるというのが多くあります。 一方、日本では、規制もあり、個人のドライバーというよりは、地域ごとのタクシー・ハイヤーの運行会社に所属しているドラ… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210508/20210508115626.png" alt="f:id:nearme-jp:20210508115626p:plain" width="1200" height="674" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>配車サービスを開発するにあたって、初めから取り入れようと思ったのがマルチテナント化です。 マルチテナント化とは、多くのSaaSサービスで見られるように、一つのアーキテクチャで複数の企業が個別にサービスを利用できるようにすることです。 結果としてこの機能は、配車固有の事情と相まって、事業展開に重要な役割を果たしてきました。 ここではその導入背景やアーキテクチャなどについて説明します。</p> <h2>背景</h2> <p>海外の配車サービスだと、個人のドライバーがユーザーの注文を直接受けるというのが多くあります。 一方、日本では、規制もあり、個人のドライバーというよりは、地域ごとのタクシー・ハイヤーの運行会社に所属しているドライバーが注文を受けるのが一般的です。</p> <p>注文は、直接配車アプリに通達されることもあれば、運行会社を経由することもあります。 運行会社は、そこで配車可否確認やドライバーの指示をしたり、運行スケジュールを組んだりします。 個別に配車システムを持っている場合もあります。</p> <p>各運行会社とどこまで密に連携するか、というところがマルチテナント化の判断ポイントです。 運行会社ごとに配車システムを提供できるようなマルチテナント化です。 連携を疎結合にして、個々のドライバーとユーザーとのマッチングに限定すれば必ずしもマルチテナント化は必要ありません。 しかし、事前予約含めた全体の運行スケジュールを考慮するといったマッチングの全体最適をするには、 マルチテナント化して内部に踏み込む必要があります。</p> <p>結局、NearMeでは、事前予約型の<a href="https://app.nearme.jp/airport-shuttle/">空港送迎の相乗りサービス</a>の検討において、 各運行会社と密に連携することでより深く最適化を行えると感じたのでマルチテナント化を採用しました。 ただし、既存の配車システムを丸々置き換えるつもりはなく、マッチングの最適化のための機能にフォーカスして開発しています。</p> <h2>アーキテクチャ</h2> <h3>基本構造</h3> <p>マルチテナントの設計にあたって、参考にしようと思ったのはGithubです。 データ構造が汎用的で、権限まわりが簡潔で扱いやすいというのが理由です。</p> <p>Githubでは、「リポジトリ」というリソースと、そのリソースに対して、「組織」、「メンバー」、「チーム」に基づいた権限管理が行われます。 個人利用と組織利用がありますが後者の場合は、ざっと説明すると次のようになります。</p> <ul> <li>1つのリポジトリは1つの組織に紐付く</li> <li>1つの組織は複数のリポジトリを持つ</li> <li>1人のメンバーは複数の組織に所属する</li> <li>1つの組織に複数のメンバーが所属する</li> <li>各メンバーはその組織のオーナーかそうでないかが選択できる</li> <li>オーナーはその組織が持つリポジトリに対してあらゆる操作が行える</li> <li>オーナーでないメンバーはチームによって権限を管理する</li> <li>1つのチームは1つの組織に紐付く</li> <li>1つの組織は複数のチームを持つ</li> <li>各チームには複数のメンバーが所属する</li> <li>チーム単位で、どのリポジトリにどういった権限を付与する(読み取り専用など)</li> </ul> <p>これを配車サービスに置き換えて考えてみると、リポジトリというのが、地域固有の配車サービスに対応できます。例えば、「羽田空港送迎」、「成田空港送迎」、「〇〇スクール送迎」などがそれぞれ個別のリポジトリに対応します。組織に対応するのが、各運行会社です。メンバーは運行オペレータになります。そして、各メンバーがどのサービスに対してどの役割があるかというのを、チームにて設定します。</p> <p>このようなデータ構造が実際のフローでどのように関わっているかを見てみます。例えば、「羽田空港送迎」に注文が入ると、注文は「羽田空港送迎」というサービスに紐づきます。料金は「羽田空港送迎」のサービスの設定に基づいて決まります。対応エリアかどうかもそのサービスの設定に基づいて判断されます。その後、「羽田空港送迎」を所有する組織のメンバーが注文に対応します。ルート情報や運行スケジュールに基づいて配車可否を判断し、配車可能の場合、組織に登録されている車両を割り当てます。これらのオペレーションの権限はチームごとに与えられます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210508/20210508232042.png" alt="f:id:nearme-jp:20210508232042p:plain" width="1200" height="648" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3>組織間連携</h3> <p>上記の基本構造で表現しきれないものとして、あるサービスで複数の運行会社が参加して注文を取るといった組織間連携が必要な機能があります。 ここで参考にしたのが、例えば、<a href="https://aws.amazon.com/jp/blogs/news/new-aws-resource-access-manager-cross-account-resource-sharing/">AWSのクロスアカウントでのリソース共有</a> のような仕組みです。 今回の場合、サービスごとに他組織に共有するという形で実現しました。 例えば、「羽田空港送迎」というサービスに入った注文を、そのサービスを所有する組織Aだけでなく、別の組織Bが取れるように、そのサービスにおいて組織Bを共有します。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nearme-jp/20210508/20210508233139.png" alt="f:id:nearme-jp:20210508233139p:plain" width="1200" height="810" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2>終わりに</h2> <p>現時点で、マルチテナント対応の基本的な配車システムはできてきました。 今後の展望として、組織間だけでなく個別のサービス間でも連携して、より緻密な運行の実現を探りたいと思っています。 また、Slackのようなイメージで、メンバー向けのリアルタイムな仕組みも取り入れていきたいです。 さらに、近い将来自動運転が当たり前になった世界でも通用できるように準備しておきたいところです。</p> <p>最後になりますが、NearMeではエンジニアを募集しています! まだまだ多くの可能性が潜んでいる領域です。興味を持った方はぜひ応募いただければと思います。</p> <ul> <li><a href="https://corp.nearme.jp/jobs/">採用サイトはこちら</a></li> </ul> <p><span style="font-size: 80%"><span style="color: #666666">Author: Kenji Hosoda</span></span></p> <p><a href="https://b.hatena.ne.jp/entry/" class="hatena-bookmark-button" data-hatena-bookmark-layout="basic-label" data-hatena-bookmark-lang="ja" title="このエントリーをはてなブックマークに追加"><img src="https://b.st-hatena.com/images/v4/public/entry-button/button-only@2x.png" alt="このエントリーをはてなブックマークに追加" width="20" height="20" style="border: none;" /></a><script type="text/javascript" src="https://b.st-hatena.com/js/bookmark_button.js" charset="utf-8" async="async"></script></p> nearme-jp NearMe Tech Blog 開始します! hatenablog://entry/26006613721161872 2021-04-27T11:58:36+09:00 2022-03-23T12:18:11+09:00 はじめまして。株式会社NearMeのCTOの細田です。 NearMeでは、AIを活用した相乗り送迎サービス「スマートシャトル」を展開しています。 この度、私達が挑戦している領域の技術的な面白さや奥深さを伝えていけるように、技術ブログを開設しました。 日々の課題解決や技術調査などから得られた様々な知見を共有できればと思います。 また、アーキテクチャや新機能、技術戦略なども紹介していくつもりです。 どうぞよろしくお願いいたします。 <p>はじめまして。<a href="https://nearme.jp">株式会社NearMe</a>のCTOの細田です。 NearMeでは、AIを活用した相乗り送迎サービス<a href="https://app.nearme.jp/airport-shuttle/">「スマートシャトル」</a>を展開しています。 この度、私達が挑戦している領域の技術的な面白さや奥深さを伝えていけるように、技術ブログを開設しました。</p> <p>日々の課題解決や技術調査などから得られた様々な知見を共有できればと思います。 また、アーキテクチャや新機能、技術戦略なども紹介していくつもりです。</p> <p>どうぞよろしくお願いいたします。</p> <p><a href="https://b.hatena.ne.jp/entry/" class="hatena-bookmark-button" data-hatena-bookmark-layout="basic-label" data-hatena-bookmark-lang="ja" title="このエントリーをはてなブックマークに追加"><img src="https://b.st-hatena.com/images/v4/public/entry-button/button-only@2x.png" alt="このエントリーをはてなブックマークに追加" width="20" height="20" style="border: none;" /></a><script type="text/javascript" src="https://b.st-hatena.com/js/bookmark_button.js" charset="utf-8" async="async"></script></p> nearme-jp