こんにちは。ラボメンバー3号の篠原(@SHINOHARATTT)です。
以前、やましんさんが投稿した記事がありました。
現在の感染者数の推移から、SEIRモデルを描画するためのパラメータを推定し、今後の発症者数を予測するという、とても面白い記事でした。
経緯
私もこの記事を読みながら「おおー、すごい。これみんなが好きなときに見れればもっといいなぁ」と思いながら、WEB公開を真面目に考えました。予測するためのスクリプトはPythonで記載されています。画像を見るには手元のPCにPythonをセットアップする必要がありました。また、ライブラリも入れたり、Python3系列の環境も整えねばならず・・・あー、もうめんどくさい!!!となったのは内緒です。
実行するにあたってはソースとなるCSVを、その都度、Kaggleからダウンロードしてくる必要があります。また、更新タイミングもまちまちで、一日に一回見るのも結構大変・・・。取得してきてからその都度スクリプトを実行していました。
実は、やましんさん、毎日ブログ記事の方の画像も差し替えていたのです。ご存知でしたか…?
私も最新データを見たくて、でも環境構築がめんどくさかったのです。(エンジニアは、めんどくさいの塊です。鬼です。めんどくさいってことしか考えてないです。
最初に、このスクリプトを動かすためのDocker環境を整備しました。家のPCでも会社のPCでも・・・。
見るために、通勤中の電車から私が所有しているVPSにSSH接続してコマンド叩いておりました。
でもさすがに、疲れてきたというかめんどくさくなってきたというか……
というわけで、コロナウイルス流行予測をWebで誰でも見られるように公開しました!
今回は、これを公開するにあたって考えたことと、実装についてのお話をしたいと思います。
なお、実際に公開されたWEBページが見たいだけの皆様は、以下のURLから開いてみてください!
技術選定
使っている技術のお話から。今回の目的は以下の通り。
- 最新のデータを定期的に取得する
- 計算のスクリプトを自動実行したい
- Webにグラフ表示または画像配信で、いつでも見れる環境が欲しい
これらの要件を満たすために、一つずつクリアしていくことにしました。
リポジトリは、こちらで公開しております。
Python使ってるのでサーバーサイドはもちろんdjangoとかFlaskなんだろーー?と思ったそこのあなた!!
良い判断ですね!私も使ってみたかった。でも、今回は違います!!
Pythonのスクリプトは思ったよりも仕事していません。いや、メインの仕事してました。検証データのjsonフォイルを出力するまでがお仕事です。
ではこのシステムを組むにあたって、どのような順序で自動配信されているのか、そして我々がどのようにこれを実現しているのか。
順を追って説明します。
- Docker環境を使用し、開発環境を統一
- KaggleのAPIを叩いて最新のCovid-19.CSVを取得(Bash)
- 各国の国名、人口を設定(Python)
- Webで使いやすい形に整形されたJSONファイルを出力(Python)
- JSONファイルを読み込んで、Chart.jsに表示。(Nuxt)
- サーバーの公開用ディレクトリにアップロード(Bash)
- サーバー運用コストの省力化と、自動化(VPSでCronを走らせる)
実は、保守コストを考え、各ファクターで機能分担をさせています。作りとしてはあまり褒められたものでもなく、部分によってはかなり負荷がかかるものではありますが、公開を優先するために関わった人が慣れている技術を積極的に選定しています。
各技術の選定理由
コードの解説はしません。技術選定の理由について少しお話させていただきます。 結構複合的なシステムでしたので、こういった小規模開発案件のネタとしても使えるかなと思っています。
Dockerの使用
今回Dockerを使用したのは、やはり個々人の開発環境をなるべく統一したかったというのがあります。先日公開された記事はPythonですが、これは開発環境によってはPythonコマンドで実行できたり、Python3と共存していたり。またNode.jsのようにプロジェクトごとにライブラリを選択して建てることも大変な上に、Pyenvなどの構築も求められます。
しかし、Dockerを使用することでこれらの環境依存の問題を解決し、個人の開発環境も汚さないといった状況を作ることができるので選択しました。
ついでにいうと、その後のメンテナンス性や、コンテナが増えることも想定してDocker-composeを使用しています。
JSONファイルへの出力
Pythonのコードについては既に、以前の記事でやましんさんが触れられているので、ここでの解説も省略させていただきます。
このデータ出力を考えたとき、WEBにいかに効率的にデータを渡せるかということを考えました。出せるだけ全部をデータに出力しておくと、WEB側は使うか使わないかの判断に託すことができます。
今回はデータ規模については実績数値、予測数値を国でグループ化すると同時に、出力時の時間も残してとにかく記録を出せるようにしました。出力データの数値はそのままグラフ生成時に突っ込んでいる配列を使っているため、これの実装自体はとても楽です。
def plot_bar(self, ax): width = 0.5 # 辞書型の初期化 self.graph["fact"] = dict() self.graph["fact"]["infected"] = dict() self.graph["fact"]["recovered"] = dict() self.graph["fact"]["deaths"] = dict() for day, infected, recovered, deaths in zip(self.timestamp, self.infected, self.recovered, self.deaths ): bottom = 0 ax.bar(day, infected, width, bottom, color='red', label='Infectious') # グラフの出力データに追記 self.graph["fact"]["infected"][day.strftime("%Y/%m/%d")] = infected bottom += infected ax.bar(day, recovered, width, bottom, color='blue', label='Recovered') # グラフの出力データに追記 self.graph["fact"]["recovered"][day.strftime("%Y/%m/%d")] = recovered bottom += recovered ax.bar(day, deaths, width, bottom, color='black', label='Deaths') # グラフの出力データに追記 self.graph["fact"]["deaths"][day.strftime("%Y/%m/%d")] = deaths bottom += deaths ax.set_ylabel('Confirmed infections',fontsize=20) handler, label = ax.get_legend_handles_labels() ax.legend(handler[0:3] , label[0:3], loc="upper left", borderaxespad=0. , fontsize=20) return def plot_estimation(self, ax, estimatedParams): day = self.timestamp[0] day_list = [] max = 0 # 辞書型の初期化(感染者予測 self.graph["estimation"] = dict() estimated_value_list = [] for estimated_value in self.estimate4plot(estimatedParams.x[0])[:,2]: if max < estimated_value: max = estimated_value peak = (day, estimated_value) day_list.append(day) estimated_value_list.append(estimated_value) day += datetime.timedelta(days=1) if estimated_value < 0: break ax.annotate(peak[0].strftime('%Y/%m/%d') + ' ' + str(int(peak[1])), xy = peak, size = 20, color = "black") ax.plot(day_list, estimated_value_list, color='red', label="Estimation infection", linewidth=3.0) # 感染者予測を突っ込む self.graph["estimation"]["infection"] = estimated_value_list day = self.timestamp[0] day_list = [] estimated_value_list = [] for estimated_value in self.estimate4plot(estimatedParams.x[0])[:,3]: day_list.append(day) estimated_value_list.append(estimated_value) day += datetime.timedelta(days=1) if estimated_value < 0: break ax.plot(day_list, estimated_value_list, color='blue', label="Estimation recovered", linewidth=3.0) # 回復者予測を突っ込む self.graph["estimation"]["recovered"] = estimated_value_list day = self.timestamp[0] day_list = [] estimated_value_list = [] for estimated_value in self.estimate4plot(estimatedParams.x[0])[:,4]: day_list.append(day) estimated_value_list.append(estimated_value) day += datetime.timedelta(days=1) if estimated_value < 0: break ax.plot(day_list, estimated_value_list, color='black', label="Estimation deaths", linewidth=3.0) # 死者予測を突っ込む self.graph["estimation"]["deaths"] = estimated_value_list ax.set_ylim(0,) handler, label = ax.get_legend_handles_labels() ax.legend(handler[0:6] , label[0:6], loc="upper right", borderaxespad=0. , fontsize=20) return
あとは、突っ込んだ辞書型をそのまま出力するだけです。
出力されるJSONは以下のように、データファーストとなるような実装をしています。これを、Javascriptのオブジェクトとして認識しています。
{ "fact": { "infected": { "2020/01/22": 2, "2020/01/23": 1, "2020/01/24": 1, }, "recovered": { "2020/01/22": 0, "2020/01/23": 0, "2020/01/24": 0, }, "deaths": { "2020/01/22": 0, "2020/01/23": 0, "2020/01/24": 0, } }, "estimation": { "infection": [ 1.0, 1.001343690430807, 1.057149907444775, 1.1604873710493135, 1.3082809855652382, 1.500433415450257, 1.7392685227686018, 2.0292066531306356, ], "recovered": [ 0.0, 0.02712800489579143, 0.05505548053170182, 0.0851617724612349, 0.1186923604385037, 0.15685109117056692, 0.20087311338956979, 0.2520859691391483, ], "deaths": [ 0.0, 0.0021553841476101903, 0.004374288136297892, 0.0067663042324873765, 0.009430388748244232, 0.012462190151582021, 0.015959843930437784, 0.02002882643985976, ] } }
また、出力先はバックアップのフォルダに日付と時刻で保存しつつ、メインのデータはNuxtのAssetsフォルダに出力しています。
Nuxt側は、こちらの出力を意識しなくてもいいような作り方を目指しています。
WEBフロント側の言語選定
序盤でも記載しましたが、これのWEBサイトを構築するにあたって、JavaScriptフレームワークのNuxt.jsを使用しています。選定理由はいくつかありますが、Vue.jsはそのままだと自分であれもこれもやらなきゃならないことが多いので、今回はちょっとパス。そしてNuxt.jsにしました。
Nuxtで静的配信を行うことによってユーザー側の描画コストと、サーバーサイドレンダリング時の負荷を圧縮しています。だって、請求怖いんだもん!!!
こちらは技術的要素の記載は特にありませんが、デザインとかレイアウトに大きく時間を割くことができなかったため、Bootstrapを使って簡略化しています。
また、画像はFacebookとTwitterのシェア記事のみ優先して使用し、なるべくシェアしやすい仕組みを目指しています。
(実は、OGP周りも結構しっかり実装していたりします。)
折れ線とバーグラフはそれぞれ色の濃淡を変更しているのみですが実績の積み上げと予測はなるべく判断しやすい用にしています。X軸の日付がとても細かくなってしまうため、ピークとマックスの日付は別に出力することとしました。
まだのっぺりしている感じがあるので改善のポイントではありますね。
BashでVPSでもサーバーでも動かす
基本的に全てのファイル操作についてはBashでスクリプトを組んでいます。そんなに難しい理由はないですが、BashであればCronで実行するのもかんたんですし、yarn generateなどのコマンドも叩けます。要は、リリースに向けた操作はこれだけで完結しちゃうんです。
今回は規模が小さく、基本的にやることはバッチ処理なので、ZIPファイルの取得から実行までこの手順で組んでいます。
ぜひとも覚えておいていただきたいのですが、Cronはなにかの自動実行のshを叩くだけで、実際の実行は全部Shellスクリプトに記載するといいでしょう。
ちなみにVPSサーバーは今回はMixhost様のをお借りしております。
いま、Mixhost様ではコロナウイルス関連のためのコンテンツ配信をサポートする体制をとっております。このサービスをお借りして、我々も公開にあたってVPSを無償でお借りしています。 6コア メモリは8GB、SSD500GBの環境ととても恵まれていると実感。太っ腹ぁ!!!
まとめ
そんなこんなで、真面目に作り始めてから2日で公開まで持っていくことができました。その後は細かいモディファイを繰り返していますが、公開を優先して今回はデザインもこだわらずにサイトを生成しています。
もしデザイン凝ってみたいとか、Python側のコードに触れてみたいという方いましたら、以下リポジトリにアクセスしてみてください。