第 15 章: 機械学習 API とモジュール設計¶
15.1 はじめに¶
シリーズの最後の章です。ここまでに作ったモデルは、テストと main.nako3 の報告の中でしか使えませんでした。この章では、第 7 章の線形回帰(映画の興行収入)と第 8 章のパイプライン(乗客の生存)を、ほかのプログラムから呼べる 予測の API にします。
ほかの言語版は、同じ 3 つのエンドポイントを HTTP で公開しています(Haskell 版の第 15 章・Java 版・PHP 版 など)。
| メソッド | パス | 役割 |
|---|---|---|
GET |
/health |
モデルを読み込めるかどうかを返す |
POST |
/cinema/sales |
映画の特徴量から興行収入を予測する |
POST |
/survived |
乗客の特徴量から生存を予測する |
ところが、なでしこ3(nadesiko3go 3.8.8)には HTTP サーバーの命令がありません。そこでこの章では、予測を JSON の標準入出力でやりとりするコマンド として作ります(ADR 015)。要求を {"method", "path", "body"} の JSON で標準入力から受け取り、応答を {"status", "headers", "body"} の JSON で標準出力に返します。
$ echo '{"method":"GET","path":"/cinema/sales"}' | ./bin/gonako main.nako3 chapter15-api
{"status":405,"headers":{"Allow":"POST"},"body":{"detail":"許していないメソッドです"}}
HTTP の代わりに JSON の封筒を使うだけで、経路・検証の理由・ステータスコードはほかの言語版の API と同じ約束にそろえます。この章の主題は「API とは約束(入力の検査・出力の形・失敗の返し方)である」ということで、その約束は通信の手段を HTTP から標準入出力に替えても変わらないからです。
この章でなでしこ3 らしいのは次の 4 点です。
- 約束は無名関数の辞書で書く — 型もインターフェースも無いので、置き場もモデルも
{"予測": 関数(入力)}のような辞書で表します。偽物の置き場は辞書のリテラル 1 つで作れます - 失敗はエラーではなく結果の辞書で返す —
エラー発生が運べるのは説明の文字列 1 つだけです。422 では理由を並べて返し、503 と 500 では失敗の種類を見分けたいので、{"成否", "値"}か{"成否", "理由一覧"}の辞書で返します - JSON の値の種類は自分で見分ける —
変数型確認は辞書・配列・NULL をどれもobjectと答え、無いキーを読むと NULL ではなくundefinedが返ります - 関数を名前空間で分けられない — 取り込んだファイルの関数はすべて同じ名前の空間に入ります。層ごとにファイルを分けても、名前はすべて一意にします
15.2 層を分ける¶
ファイルと依存の向き¶
src/ に、層ごとのファイルを置きます。
| 層 | ファイル | 役割 |
|---|---|---|
| プレゼンテーション | chapter15_api.nako3・chapter15_validation.nako3 |
要求の JSON を読み、検証し、応答を組み立てる。標準入出力のコマンド |
| アプリケーション | chapter15_service.nako3 |
置き場からモデルを読んで予測する。要求の形を知らない |
| ドメイン | chapter15_domain.nako3・chapter15_store.nako3 |
予測の入力・出力と、モデル・置き場の約束 |
| インフラ | chapter15_file_store.nako3 |
ファイルへの保存と読み込み |
| 組み立て | chapter15.nako3 |
学習・保存と、コマンドの入口 |
依存は外から内へ一方向で、!「./chapter15_service.nako3」を取り込む のように、使う側のファイルが使われる側を取り込みます。第 7 章の線形回帰のモデルも第 8 章のパイプラインも、この章のために 1 行も書き換えていません。
ただし、なでしこ3 の取り込みは 名前空間を作りません。chapter15_api.nako3 の関数も chapter08.nako3 の関数も、同じ 1 つの空間に並びます。Haskell 版の Chapter15.Api.route のように修飾して呼び分けることができないので、層を分けても名前の衝突は防げません。そこで、この章の関数は 予測経路決定・売上予測実行・映画検証 のように、役割と題材の名詞を含めた名前にしました。名前が重なっていないことは、送り仮名を落とした形(第 9 章)で比べて確かめました。
置き場の約束を辞書で書く¶
置き場は、読み込みの関数を 2 つ詰めた辞書です。
# 学習済みモデルの置き場の約束。
#
# 置き場は {"映画モデル読込": 関数(), "乗客モデル読込": 関数()} の辞書である。
# どちらの関数も、読み込めればモデル(ドメイン層の {"予測": 関数(入力)})を持つ成功の結果を、
# 読み込めなければ失敗の結果を返す。エラーは起こさない。
Haskell 版が「関数を詰めたレコード」を選んだのと同じ形です。なでしこ3 には型クラスもインターフェースも無いので、選択肢はこれしかありません。
モデルも {"予測": 関数(入力)} の辞書にしました。第 10 章でモデルを「学習して予測する関数を返す」無名関数の辞書で表したのと同じ流儀です。
偽物の置き場は、辞書のリテラル 1 つで作れます。
# モデルを持たない偽の置き場。失敗を返す無名関数を 2 つ詰めるだけで作れる。
空置き場 = {"映画モデル読込": 関数() 映画モデル名のモデル不在作成で戻る ここまで, "乗客モデル読込": 関数() 乗客モデル名のモデル不在作成で戻る ここまで}
辞書から取り出した関数は、いったん変数に入れてから呼びます。置き場["映画モデル読込"]() とは書けません(第 10 章)。サービスの層に、そのための小さな関数を 1 つ置きました。
# 置き場の読み込みの関数を呼ぶ。辞書から取り出した関数は、変数に入れてから呼ぶ。
●(置き場と読込名の)置き場読込実行とは
読込 = 置き場[読込名]
読込()で戻る
ここまで
辞書が保証しないこと¶
辞書が決めるのは キーの名前 だけです。関数の引数の数も、戻り値の形も、「無ければ見つからないを返す」という取り決めも書けません。Haskell 版のレコードは少なくとも型を決めていましたが、なでしこ3 の辞書はそれすら決めません。キーを書き違えても、取り出した値を呼んだ時点で「関数ではない値を呼び出そうとしました。」という実行時エラーになって初めて分かります。
この取り決めは、15.5 節の 約束のテスト で守ります。型の支えが無いぶん、約束のテストが唯一の支えになります。
15.3 TODO リストの作成¶
- 要求の JSON を読み、型が合わなければ弾く
- 必須・0 以上・選択肢の検証をし、理由を並べて返す
- 映画・乗客の特徴量を辞書で表す
- 置き場の約束を無名関数の辞書と約束のテストで表す
- 第 7 章の線形回帰と第 8 章のパイプラインをファイルに保存・読み込みする
- ファイルが無ければ「見つからない」、読めなければ「読めない」を返す
- サービスが置き場からモデルを読んで予測し、ヘルスチェックを返す
-
POST /cinema/sales・POST /survivedが予測を JSON で返す - 入力が不正なら 422、モデルが無ければ 503、それ以外は 500
- 知らないパスは 404、許していないメソッドは 405
- 要求を JSON で標準入力から読み、応答を JSON で標準出力に返す(要求そのものが読めなければ 400)
- 第 7・8 章と同じ条件で学習し、
model/に保存する
15.4 要求を読んで検証する¶
JSON の値の種類を見分ける¶
検証の最初の段は「本文が辞書で、列の型が合っているか」です。ここでなでしこ3 の 変数型確認 が問題になります。JSONデコード で読んだ値を実際に確かめると、次のように答えました。
| JSON | 変数型確認 |
|---|---|
1・1.5・1.0・1e2 |
number(1.0 は 1、1e2 は 100 になる) |
"x" |
string |
true・false |
boolean |
null・[1]・{"h": 1} |
どれも object |
| 無いキー | undefined |
辞書と配列と NULL を見分けられません(第 8 章でも踏みました)。そこで、NULL は === NULL で、辞書と配列は JSONエンコード で JSON に戻した先頭の文字で見分けます。
# JSON から読んだ値の種類を返す。
# 変数型確認 は辞書・配列・NULL をどれも「object」と答えるので、JSON に戻した先頭の文字で見分ける。
●(値の)要求値種類とは
型 = 値の変数型確認
もし、型 = 「number」ならば
「数値」で戻る
ここまで
もし、型 = 「string」ならば
「文字列」で戻る
ここまで
もし、型 = 「boolean」ならば
「真偽」で戻る
ここまで
もし、型 !== 「object」ならば
「未定義」で戻る
ここまで
もし、値 === NULLならば
「NULL」で戻る
ここまで
先頭 = (値をJSONエンコード)の1から1を文字抜出
もし、先頭 = 「[」ならば
「配列」で戻る
ここまで
「辞書」で戻る
ここまで
無いキーを読むと undefined が返り、=== NULL は偽になります。「無い」と「null」を同じに扱いたいので、列を読む関数では ハッシュキー存在 で先に確かめて NULL にそろえました。
# 列の値を読む。無いか null なら NULL を返す。
●(本文の列名を)要求列取得とは
もし、(本文の列名がハッシュキー存在) = 偽ならば
NULLで戻る
ここまで
本文[列名]で戻る
ここまで
型が合うかを確かめる¶
列の型は「数値」「整数」「文字列」の 3 つです。整数の列は、小数部の無い数だけを許します。
# 値が列の型に合えば真を返す。NULL と、表に無い列(型が「」)は問わない。
# 整数の列は、小数部の無い数だけを許す(1.0 はよい。1.5 は弾く)。
●(値を型で)要求型適合判定とは
種類 = 値の要求値種類
もし、(種類 = 「NULL」) または (型 === 「」)ならば
真で戻る
ここまで
もし、型 = 「数値」ならば
(種類 === 「数値」)で戻る
ここまで
もし、型 = 「整数」ならば
もし、種類 !== 「数値」ならば
偽で戻る
ここまで
(値 === (値を切捨))で戻る
ここまで
もし、型 = 「文字列」ならば
(種類 === 「文字列」)で戻る
ここまで
偽で戻る
ここまで
この関数で落とし穴を 2 つ踏みました。どちらも lint は通ります。
1 つ目は、比べた結果を返す行です。最初は 種類 = 「数値」で戻る と書きました。行の頭の = は代入と読まれるので、これは「「数値」で戻る の結果を 種類 に代入する」文になり、実行すると「『return』はまだ実行に対応していません。」で止まりました。比べた結果を返すときは、(種類 === 「数値」)で戻る のように比べる式をかっこで囲みます。
2 つ目は、切り捨ての命令です。最初は (値 === (値の切捨)) と書きました。切捨 の書式は「【S】を切捨」で、「の」では呼べません。かっこの中なので未定義の名前として undefined になり、エラーも出ずに どの数も整数ではない と判定しました。テストが「整数の列に 1.0 が来てもよい」で落ちて気づきました。第 5 章の「引数が足りないと直前の値で埋められる」と同じく、書式を ./bin/gonako doc で確かめる ことが唯一の予防です。
理由を集めて、無ければ値を作る¶
値の検証は、理由を配列に集めます。なでしこ3 の配列は参照で渡るので、検証の関数が受け取った配列に追加すると、呼び出し元の配列に理由が入ります。
# 必須の列が空なら理由を追加する。
●(理由一覧に値と列名を)必須検証とは
もし、値 === NULLならば
理由一覧に「{列名} は必須です」を配列追加
ここまで
ここまで
第 8 章までは「配列は参照で渡るので、書き換える前に複製する」と、参照渡しを避けるべき落とし穴として扱ってきました。ここでは逆に、それを使って理由を集めています。
映画の検証は、型の確認と、必須・0 以上・選択肢の検証を順に並べます。
# 検証して、正しければ映画の特徴量にする。型が違えば JSON の理由だけを返す。
●(本文の)映画検証とは
読込結果 = 本文を映画列型で要求本文読込
もし、読込結果["成否"] = 偽ならば
読込結果で戻る
ここまで
SNS1 = 本文の「sns1」を要求列取得
SNS2 = 本文の「sns2」を要求列取得
俳優 = 本文の「actor」を要求列取得
原作 = 本文の「original」を要求列取得
理由一覧 = []
理由一覧にSNS1と「sns1」を必須検証
理由一覧にSNS2と「sns2」を必須検証
理由一覧に俳優と「actor」を必須検証
理由一覧に原作と「original」を必須検証
理由一覧にSNS1と「sns1」を非負検証
理由一覧にSNS2と「sns2」を非負検証
理由一覧に俳優と「actor」を非負検証
理由一覧に原作と「original」を[「0」, 「1」]で選択肢検証
理由一覧と{"sns1": SNS1, "sns2": SNS2, "actor": 俳優, "original": 原作}の検証結果作成で戻る
ここまで
テストは Haskell 版と同じ理由の並びを期待値にしました。
「負の数と選択肢の外の値は理由を並べて返す」で(『{"sns1": -1, "sns2": 2000, "actor": 300, "original": 2}』の試験本文の映画検証)と{"成否": 偽, "理由一覧": [「sns1 は 0 以上にしてください」, 「original は 0、1 のどれかにしてください」]}が一致確認
JSON をテストに書くときは 『』 で囲みます。「」 の中の {…} は値の埋め込みと読まれるので、JSON の波かっこが書けません。
15.5 置き場の約束をテストで書く¶
約束のテストを本物と偽物の両方に走らせる¶
約束のテストは、置き場を 2 つ(モデルを持っているものと、持っていないもの)受け取る関数にします。
# 置き場の約束。本物にも偽物にも同じものを走らせる。
# 辞書が決めるのは関数の名前だけで、「無ければ見つからないを返す」「同じ入力には同じ答えを返す」は書けない。
# そこをこのテストで守る。
●(名前と満杯置き場と空置き場の)置き場約束確認とは
売上読込 = 満杯置き場と「映画モデル読込」の試験モデル読込
「{名前}: 興行収入のモデルを読み込める」で(売上読込["成否"])と真が一致確認
「{名前}: 興行収入のモデルは映画から数値を返す」で((売上読込["値"])と見本映画の試験予測)と{"成否": 真, "値": 13906}が一致確認
生存読込 = 満杯置き場と「乗客モデル読込」の試験モデル読込
「{名前}: 生存予測のモデルは女性に真を返す」で((生存読込["値"])と見本女性の試験予測)と{"成否": 真, "値": 真}が一致確認
「{名前}: 生存予測のモデルは男性に偽を返す」で((生存読込["値"])と見本男性の試験予測)と{"成否": 真, "値": 偽}が一致確認
二度目 = 満杯置き場と「映画モデル読込」の試験モデル読込
「{名前}: 同じ入力を二度読み込んでも同じ答えを返す」で((二度目["値"])と見本映画の試験予測)と((売上読込["値"])と見本映画の試験予測)が一致確認
「{名前}: 興行収入のモデルが無ければ見つからないを返す」で(空置き場と「映画モデル読込」の試験モデル読込)と{"成否": 偽, "失敗": 「見つからない」, "名前": 「cinema」}が一致確認
「{名前}: 生存予測のモデルが無ければ見つからないを返す」で(空置き場と「乗客モデル読込」の試験モデル読込)と{"成否": 偽, "失敗": 「見つからない」, "名前": 「survived」}が一致確認
ここまで
「偽物」と作り物置き場と空置き場の置き場約束確認
「ファイル」と(満杯フォルダのファイル置き場作成)と(空フォルダのファイル置き場作成)の置き場約束確認
本物の置き場は、一時フォルダ作成 で作ったフォルダに作り物のモデルを保存して作ります。作り物のモデルは Haskell 版と同じで、売上 = 1 + 2·SNS1 + 3·SNS2 + 4·actor + 5·original の線形回帰(見本の映画で 13906)と、女性が生存し男性が死亡する 4 件で学習した第 8 章のパイプラインです。
# 作り物の線形回帰のモデル。売上 = 1 + 2*SNS1 + 3*SNS2 + 4*actor + 5*original。
作り物線形モデル = 1と映画特徴量列を[2, 3, 4, 5]で線形回帰モデル作成
学習データが無くても、置き場の約束のテストは本物・偽物の両方で走ります。
15.6 ドメインとサービス¶
失敗を結果の辞書で表す¶
Haskell 版は失敗を直和型(LoadError・PredictError)で表し、ステータスコードへの変換をパターンマッチの網羅で守りました。なでしこ3 には直和型がありません。エラーはあります(エラー発生 と エラー監視)が、運べるのは説明の文字列 1 つだけです。
そこで、失敗も成功も 結果の辞書 で返すことにしました。読み込みの失敗は種類と名前を持ちます。
# モデルがまだ保存されていないときの結果。
●(名前の)モデル不在作成とは
{"成否": 偽, "失敗": 「見つからない」, "名前": 名前}で戻る
ここまで
# ファイルはあるが、モデルとして読めないときの結果。
●(名前の)モデル破損作成とは
{"成否": 偽, "失敗": 「読めない」, "名前": 名前}で戻る
ここまで
直和型と違い、「失敗」のキーに書ける値は何も縛られません。「見つからない」 を 「見付からない」 と書き違えても、どこも止まりません。これを捕まえるのも、失敗の種類ごとにステータスコードを確かめるテスト(15.8 節)です。
既存のモデルをアダプターで約束に合わせる¶
第 7 章の線形回帰のモデルを、興行収入のモデルの約束に包み直します。第 7 章の関数は特徴量が足りないとエラーを起こすので、エラー監視 で捕まえて予測の失敗に変えます。
# 第 7 章の線形回帰のモデルを、興行収入のモデルの約束に合わせる。
# 第 7 章にも第 8 章にも 1 行も手を入れていない。既存のモデルを約束の形に包み直すのがアダプターの役目である。
# 第 7 章の関数は特徴量が足りないとエラーを起こすので、エラーを予測の失敗に変える。
●(線形モデルの)映画売上モデル作成とは
予測 = 関数(映画)
特徴量 = {"SNS1": 映画["sns1"], "SNS2": 映画["sns2"], "actor": 映画["actor"], "original": 映画["original"]}
結果 = NULL
エラー監視
結果 = (線形モデルで特徴量を線形回帰一件予測)の予測成功作成
エラーならば
結果 = エラーメッセージの予測失敗作成
ここまで
結果で戻る
ここまで
{"予測": 予測}で戻る
ここまで
無名関数はクロージャになるので、線形モデル を覚えたまま辞書に入ります。乗客のほうは、第 8 章のパイプラインが読む CSV と同じ、セルが文字列の行に直してから予測します。分からない年齢と乗船港は空欄にすると、学習のときに求めた中央値・最頻値で補完されます。
「分からない年齢と乗船港は空欄の行にする」で(見本女性の乗客予測行作成)と{"Pclass": 「1」, "Sex": 「female」, "Age": 「」, "SibSp": 「0」, "Parch": 「0」, "Fare": 「50」, "Embarked": 「」}が一致確認
サービスは要求の形を知らない¶
サービスは、置き場からモデルを読んで当てはめ、2 種類の失敗を 1 つの形にまとめます。
# モデルを読み込んで当てはめ、2 種類の失敗を 1 つの形にまとめる。
●(置き場と読込名と入力の)置き場予測実行とは
読込結果 = 置き場と読込名の置き場読込実行
もし、読込結果["成否"] = 偽ならば
{"成否": 偽, "失敗": 「読込」, "読込失敗": 読込結果}で戻る
ここまで
予測 = 読込結果["値"]["予測"]
予測結果 = 予測(入力)
もし、予測結果["成否"] = 偽ならば
{"成否": 偽, "失敗": 「予測」, "理由": 予測結果["理由"]}で戻る
ここまで
予測結果で戻る
ここまで
要求の JSON も、ステータスコードも、ファイルも出てきません。
15.7 モデルをファイルに保存する¶
第 8 章で、学習済みのパイプラインを JSON で保存し、形と版と余りを確かめて読み込む関数(乗客モデル保存・乗客モデル読込)を作ってあります。生存予測のモデルはそれをそのまま使い、第 7 章の線形回帰のモデルも同じ形で保存します。
# 第 7 章の線形回帰のモデルを保存する。形式の名前と版を付ける。フォルダが無ければ作る。
●(線形モデルをフォルダに)売上モデル保存とは
フォルダにフォルダ作成
保存物 = {"形式": 映画モデル形式, "形式版": 映画モデル形式版, "モデル": 線形モデル}
(保存物をJSONエンコード)を(フォルダと映画モデル名のモデルファイルパス)に保存
ここまで
読み込みでは、「ファイルが無い」と「中身が壊れている」を分けます。前者は 503 に値する一時的な状態(まだ学習していない)で、後者は置き場の異常だからです。無いことは 存在 で先に確かめ、読み込みのエラーは エラー監視 で「読めない」に変えます。
# ファイルを読んでモデルにする。無ければ「見つからない」に、読めなければ「読めない」に変える。
●(フォルダと名前を読込処理で)モデルファイル読込とは
パス = フォルダと名前のモデルファイルパス
もし、(パスが存在) = 偽ならば
名前のモデル不在作成で戻る
ここまで
結果 = NULL
エラー監視
結果 = (読込処理(パス))の読込成功作成
エラーならば
結果 = 名前のモデル破損作成
ここまで
結果で戻る
ここまで
JSONデコード は、先頭の値を 1 つ読んだところで止まり、後ろに続く文字を黙って無視します(第 8 章)。自分で保存したファイルなら元の文字列が分かるので、読んだ値を JSON に戻して元の文字列と比べ、余りが無いことを確かめます。形の違い・版の違い・後ろの余りは、どれも「読めない」になることをテストで確かめました。
保存と読み込みで値が変わらないことも確かめます。0.1 + 0.2・1 ÷ 3 のように十進で割り切れない数を切片と係数にしたモデルを保存して読み込み、見本の映画の予測が保存前と同じになることをテストにしました。
往復フォルダ = 一時フォルダ作成
端数モデル = (0.1 + 0.2)と映画特徴量列を[1 ÷ 3, 2 ÷ 3, 0.1, 1e-17]で線形回帰モデル作成
端数モデルを往復フォルダに売上モデル保存
往復読込 = (往復フォルダのファイル置き場作成)と「映画モデル読込」の試験モデル読込
「保存と読み込みを挟んでも予測値は 1 ビットも変わらない」で((往復読込["値"])と見本映画の試験予測)と((端数モデルの映画売上モデル作成)と見本映画の試験予測)が一致確認
テスト補助の 一致確認 は両方を JSONエンコード した文字列で比べます。gonako の JSONエンコード は 0.30000000000000004 のように元の float64 に戻せる桁数で書くので、文字列が同じなら値も同じです。
15.8 振り分けと応答¶
経路の振り分けは表を引くだけ¶
経路は、パスごとに許すメソッドと行き先を持つ辞書の表にしました。
# 経路の表。パスごとに、許すメソッドと行き先を持つ。
予測経路表 = {"/health": [「GET」, 「ヘルス」], "/cinema/sales": [「POST」, 「売上予測」], "/survived": [「POST」, 「生存予測」]}
# メソッドとパスから行き先を決める。知っているパスに違うメソッドなら、許すメソッドを添える。
●(方法とパスの)予測経路決定とは
もし、(予測経路表のパスがハッシュキー存在) = 偽ならば
{"行き先": 「不明」}で戻る
ここまで
経路 = 予測経路表[パス]
もし、方法 !== 経路[0]ならば
{"行き先": 「許可外」, "許可": 経路[0]}で戻る
ここまで
{"行き先": 経路[1]}で戻る
ここまで
メソッドの比べ方は !== なので、get と小文字で送ると 405 になります。メソッドの文字列をそのまま比べる Haskell 版の振り分けと同じ振る舞いです。
失敗をステータスコードに変える¶
# 予測の失敗をステータスコードに変える。
# モデルが見つからないのは一時的な状態なので 503、読めないモデルと予測の失敗は、
# 内部の事情を応答に出さないよう 500 にする。
●(失敗の)予測失敗応答作成とは
もし、(失敗["失敗"] = 「読込」) かつ (失敗["読込失敗"]["失敗"] = 「見つからない」)ならば
503と(失敗["読込失敗"]の読込失敗説明)の説明応答作成で戻る
ここまで
500と「予測できませんでした」の説明応答作成で戻る
ここまで
Haskell 版では、失敗の種類を増やしたときにこの関数の節を書き漏らすと、コンパイラが止めました。なでしこ3 では、種類を増やしても何も止まらず、その種類は黙って 500 になります。ステータスコードごとのテスト(200・404・405・422・500・503)を並べて、写し方を固定しました。
応答は辞書、コマンドは JSON の文字列¶
ハンドラーは応答を辞書で返します。{"status", "headers", "body"} の辞書をそのまま 一致確認 で比べられます。
「許していないメソッドは 405 で Allow を添える」で(作り物置き場と「GET」と「/cinema/sales」とNULLで予測要求処理)と{"status": 405, "headers": {"Allow": 「POST」}, "body": {"detail": 「許していないメソッドです」}}が一致確認
コマンドの層は、要求の文字列を読んでハンドラーに渡し、応答を JSON の文字列にします。要求そのものが読めない(JSON でない・辞書でない・method と path が文字列でない)ときは 400 にしました。HTTP のサーバーが、形の壊れた要求に返すステータスコードです。
# 要求の JSON の文字列を読み、応答の JSON の文字列を返す。
# 要求そのものが読めなければ(JSON でない・オブジェクトでない・method と path が文字列でない)400 にする。
●(置き場と要求文字列で)予測コマンド処理とは
要求 = NULL
エラー監視
要求 = 要求文字列をJSONデコード
エラーならば
要求 = NULL
ここまで
応答 = 400と要求外形不正文言の説明応答作成
もし、(要求の要求値種類) = 「辞書」ならば
方法 = 要求の「method」を要求列取得
パス = 要求の「path」を要求列取得
もし、((方法の要求値種類) = 「文字列」) かつ ((パスの要求値種類) = 「文字列」)ならば
応答 = 置き場と方法とパスと(要求の「body」を要求列取得)で予測要求処理
ここまで
ここまで
応答をJSONエンコードして戻る
ここまで
応答のキーは status・headers・body の順に並びます。なでしこ3 の辞書はキーを入れた順を保ち、JSONエンコード もその順で書くからです(第 2 章)。Haskell 版の aeson はキーを並べ替えたので、応答の並びを約束にできるのはなでしこ3 版のほうでした。
後ろに続く文字は捕まえられない¶
保存したモデルと違い、送られてきた要求は元の文字列の形が分かりません。空白や改行やキーの順は送り手しだいなので、JSON に戻して比べる手が使えないのです。そのため {"method": "GET", "path": "/health"} おまけ のような要求は、後ろの おまけ を無視して 200 を返します。直せない限界として、そのとおりの振る舞いをテストに書いて残しました。
# JSONデコード は先頭の値を 1 つ読んだところで止まり、後ろに続く文字を黙って無視する。
# 送られてきた要求は元の文字列が分からないので、第 8 章のモデルの読み込みのように JSON に戻して比べられない。
「要求の後ろに続く文字は見逃す(JSONデコード の限界)」で(作り物置き場と『{"method": "GET", "path": "/health"} おまけ』で予測コマンド処理)と『{"status":200,"headers":{},"body":{"status":"ok","models":{"cinema":true,"survived":true}}}』が一致確認
標準入出力の入口¶
標準入力を読むのは、組み立てのファイルの 1 か所だけです。
# 要求の JSON の文字列を受け取り、フォルダのモデルで予測して、応答の JSON を 1 行で返す。
●(フォルダと入力の)予測コマンド実行とは
応答 = (フォルダのファイル置き場作成)と入力で予測コマンド処理
応答 & 改行で戻る
ここまで
# 標準入力から要求の JSON を読み、model/ のモデルで予測して、応答の JSON を返す。
●第15章API実行とは
予測モデルフォルダと(標準入力全取得)の予測コマンド実行で戻る
ここまで
予測コマンド実行 はフォルダと入力を引数で受け取るので、テストからは一時フォルダと要求の文字列を渡して確かめられます。第15章API実行 そのものはテストから呼びません。標準入力全取得 が入力を待って止まるからです。章の選び方のテストでも、chapter15-api だけは呼ばない分岐にしました。
モデルが無い状態で、コマンドを実際に動かしました。
$ echo '{"method":"GET","path":"/health"}' | ./bin/gonako main.nako3 chapter15-api
{"status":200,"headers":{},"body":{"status":"degraded","models":{"cinema":false,"survived":false}}}
$ echo '{"method":"POST","path":"/cinema/sales","body":{"sns1":200,"sns2":500,"actor":3000,"original":1}}' | ./bin/gonako main.nako3 chapter15-api
{"status":503,"headers":{},"body":{"detail":"学習済みモデル cinema が見つかりません"}}
$ echo '{"method":"POST","path":"/cinema/sales","body":{"sns1":-1,"sns2":2000,"actor":300,"original":2}}' | ./bin/gonako main.nako3 chapter15-api
{"status":422,"headers":{},"body":{"detail":["sns1 は 0 以上にしてください","original は 0、1 のどれかにしてください"]}}
$ echo '{"method":"POST","path":"/cinema/sales","body":{"sns1":"おおい"}}' | ./bin/gonako main.nako3 chapter15-api
{"status":422,"headers":{},"body":{"detail":["JSON の形式または値の型が正しくありません"]}}
$ echo '{"method":"GET","path":"/unknown"}' | ./bin/gonako main.nako3 chapter15-api
{"status":404,"headers":{},"body":{"detail":"見つかりません"}}
$ echo '{"method": ' | ./bin/gonako main.nako3 chapter15-api
{"status":400,"headers":{},"body":{"detail":"要求の形式が正しくありません"}}
どの応答でもコマンドの終了コードは 0 です。HTTP のサーバーが 404 を返してもサーバー自身は動き続けるのと同じく、要求の成否は応答の status で表し、終了コードはコマンドが応答を書けたかどうかだけを表します。
15.9 実データで学習したモデルで動かす¶
学習と保存¶
第 7・8 章と同じ条件(テストデータの割合 0.2、シード 0、深さ 5、balanced の重み)で学習します。
# 第 7・8 章と同じ条件でモデルを学習する。ファイルを読まないので、読み込んだ中身さえあればテストで確かめられる。
# 既存の章の関数を呼んでいるだけで、第 7 章にも第 8 章にも手を入れていない。
●(映画CSVと乗客CSVの)予測モデル学習とは
映画分割 = 映画CSVを予測テスト割合と予測乱数シードで映画前処理
目的値一覧 = (映画分割["訓練正解"])の映画目的値変換
線形モデル = (映画分割["訓練特徴量"])と目的値一覧を映画特徴量列で線形回帰学習
乗客分割 = 乗客CSVを予測テスト割合と予測乱数シードで乗客データ準備
訓練表 = {"列名一覧": 乗客特徴量列, "行一覧": 乗客分割["訓練特徴量"]}
パイプライン = (予測深さ上限と予測クラス重みの乗客パイプライン構築)と訓練表と(乗客分割["訓練正解"])で乗客パイプライン学習
{"売上": 線形モデル, "生存": パイプライン}で戻る
ここまで
自作の小さなデータ(映画 12 件・乗客 12 件)で、この関数の結果が第 7 章・第 8 章の手順を直接呼んだ結果と同じになることをテストにしました。
./bin/gonako main.nako3 chapter15 で学習して model/ に保存し、そのあと chapter15-api で予測します。
./bin/gonako main.nako3 chapter15
echo '{"method":"POST","path":"/cinema/sales","body":{"sns1":200,"sns2":500,"actor":3000,"original":1}}' | ./bin/gonako main.nako3 chapter15-api
期待する出力は次のとおりです。
モデル cinema: 読み込めます
モデル survived: 読み込めます
予測は ./bin/gonako main.nako3 chapter15-api に要求の JSON を標準入力で渡します
{"status":200,"headers":{},"body":{"sales":7730.45742168702…}}
予測値はほかの言語版と一致するか¶
Haskell 版は、見本の映画(sns1=200, sns2=500, actor=3000, original=1)の予測値を次のように記録しています。
| 言語版 | 予測値 |
|---|---|
| Java・Scala・Clojure | 7730.457421687023 |
| Haskell | 7730.457421687022 |
| PHP | 7730.457421687019 |
| Elixir | 7730.457421687016 |
分割(どの行が訓練データに入るか)は第 2 章の線形合同法で全言語版と一致していますが、正規方程式を解く手順の違いで最後の桁が動きます。なでしこ3 版は部分ピボット付きのガウスの消去法(第 7 章)で解くので、どの版と最後の桁まで一致するかは分かりません。テストでは 1e-6 の許容を決めて突き合わせます。
「実データの興行収入の予測がほかの言語版と一致する」で[実売上応答["status"], 実売上応答["body"]["sales"]]と[200, 7730.457421687023]を0.000001で近似確認
実データでの実測について
この章は学習データの無い環境で書いたため、上の予測値はまだなでしこ3 版の実装で実測していません。学習データを配置した環境で npx gulp apps:check:nadesiko3 を実行し、実データの 3 件の保留が消えて通ることを確かめてから、この注記を外します。実データと同じ形の架空のデータ(映画 100 件・乗客 891 件)では、chapter15 で学習して保存したモデルに chapter15-api で問い合わせた興行収入の予測値 7497.291121711996 が、第 7 章の Python の参照実装で学習した値と最後の桁まで一致し、1 等客室の女性と 3 等客室の男性の生存の予測(生存・死亡)も第 8 章の参照実装と一致しました。架空のデータを ML_DATA_DIR で指したときに実データの分岐で落ちるのは、ほかの言語版の値と比べる 1 件(値の不一致)だけです。
命令の数¶
gonako の 1 回の実行につき 2 億命令の上限(第 3・10・14 章)に対して、実データと同じ大きさの架空のデータで測った命令の数は次のとおりです。
| 実行 | 命令の数 |
|---|---|
chapter15(2 つのモデルを学習して保存する) |
約 2522 万 |
chapter15-api の 1 回(ヘルスチェック) |
約 2 万 |
chapter15-api の 1 回(興行収入の予測) |
約 2100 |
chapter15-api の 1 回(生存の予測) |
約 2.2 万 |
| 第 15 章のテスト(学習データのあるとき) | 約 2554 万 |
| 第 15 章のテスト(学習データの無いとき) | 約 30 万 |
学習のほとんどは第 8 章のパイプラインで、予測の 1 回は上限の 1 万分の 1 にも届きません。要求ごとにモデルのファイルを読み直しても、命令の数では困りませんでした。
15.10 品質チェック¶
cd apps/nadesiko3
./tools/check.sh
文法・整形・テスト・テストが最後まで走ったか・関数の網羅の 5 つを確かめます。この章で関数が 45 個増え、関数の網羅は 315 件中 315 件(直接 246 件、間接 69 件)になりました。第 15 章のテストは 94 件(学習データが無いときは実データの 3 件が保留)です。
型の支えが無いので、テストが空振りしていないことを、実装をわざと壊して確かめました。8 通り壊して、どれもテストが落ちました。
| 壊し方 | 落ちたテスト |
|---|---|
| 503 にする条件を「読めない」に変える | 2 件 |
| 整数の列の確認をやめる | 1 件 |
| 保存したモデルの後ろの余りを確かめない | 1 件 |
ヘルスチェックを常に ok にする |
1 件 |
| ファイルが無いことを確かめない(無いと読めないを区別しない) | 2 件 |
| 原作の選択肢の検証を消す | 2 件 |
| 決定木の深さを 4 にする | 1 件 |
| 405 にしない(メソッドを見ない) | 3 件 |
15.11 まとめ¶
この章では、第 7・8 章のモデルを、JSON の標準入出力でやりとりする予測のコマンドにしました。なでしこ3 に固有の論点は次のとおりです。
- HTTP が無くても API の約束は書ける — 要求と応答を JSON の封筒にし、経路・検証の理由・ステータスコードをほかの言語版とそろえた。通信の手段は約束の外にある
- 約束は無名関数の辞書で書き、テストで守る — 辞書が決めるのはキーの名前だけで、引数も戻り値も縛れない。約束のテストを本物と偽物の両方に走らせた
- 失敗はエラーではなく結果の辞書で返す — エラーは説明の文字列しか運べない。理由の一覧と失敗の種類を持たせるために辞書にした。直和型のような網羅の確認は無いので、ステータスコードごとにテストを並べた
- JSON の値の種類は自分で見分ける — 辞書・配列・NULL はどれも
object、無いキーはundefined。JSON に戻した先頭の文字とハッシュキー存在で見分けた - 比べた結果を返すときはかっこで囲む —
種類 = 「数値」で戻るは代入と読まれ、実行時に止まる。lint は通る - 命令の書式は
docで確かめる —(値の切捨)は「を」で呼ぶ命令を「の」で呼んだためundefinedになり、どの数も整数でないと判定した。lint は通る - 取り込みは名前空間を作らない — 層ごとにファイルを分けても、関数の名前はすべて一意にする
TODO リスト(この章の完了時点):
- 要求の JSON を読み、型が合わなければ弾く
- 必須・0 以上・選択肢の検証をし、理由を並べて返す
- 映画・乗客の特徴量を辞書で表す
- 置き場の約束を無名関数の辞書と約束のテストで表す
- 第 7 章の線形回帰と第 8 章のパイプラインをファイルに保存・読み込みする
- ファイルが無ければ「見つからない」、読めなければ「読めない」を返す
- サービスが置き場からモデルを読んで予測し、ヘルスチェックを返す
-
POST /cinema/sales・POST /survivedが予測を JSON で返す - 入力が不正なら 422、モデルが無ければ 503、それ以外は 500
- 知らないパスは 404、許していないメソッドは 405
- 要求を JSON で標準入力から読み、応答を JSON で標準出力に返す(要求そのものが読めなければ 400)
- 第 7・8 章と同じ条件で学習し、
model/に保存する(実データでの実測は未確認。15.9 節の注記を参照)
15.12 なでしこ3 版のまとめ¶
番外編を 15 章書き終えたので、なでしこ3 版で分かったことをまとめます。
なでしこ3 版は、14 の言語版のあとに 日本語で書く言語 として加えました(ADR 015)。対比の軸は 2 つでした。1 つは Python 版 で、同じ手順を英語の識別子と日本語の文で書き比べること。もう 1 つは Haskell 版・Go 版 で、機械学習のライブラリが無いことです。
日本語で書けることと、日本語の名前の難しさ¶
機械学習の用語はそのままコードになりました。(特徴量一覧と正解一覧を列名一覧と深さ上限で)乗客重み付き部分木学習 のように、関数の定義が手順の説明として読めます。
一方で、名前の付け方は 15 章を通していちばん多く踏んだ落とし穴 でした。
- 助詞とひらがなは名前を割る(第 1 章)。
ルールで判定は「ルール」の定義に、きのこ派の年代は「き」「の」「こ派」に割られます - 漢字に挟まれた送り仮名は名前から落ちる(第 9 章)。
外れ値試験と外値試験は同じ関数です - 組み込みの名前と重なると、分かりにくい形で壊れる。
終了はプロセスを終了コード 0 で黙って終わらせ(第 3 章)、対象は引数に値が入らず(第 8 章)、次は「『ここまで』がありません」になり(第 3 章)、巡回・違反は構文の語と読まれ(第 12 章)、表示は代入できません(この章) - 呼び方の違いは lint を通る。定義と違う助詞で呼ぶと直前の値が入り(第 13 章)、命令を「の」で呼ぶと
undefinedになり(この章)、名前を付けた関数を値の位置に書くとその場で呼ばれます(第 11 章)
日本語で書けることは、英語の識別子より自由に書けることを意味しませんでした。助詞が文法の役割を持つぶん、名前に使える語は英語より狭い のです。
守りはテストだけ¶
なでしこ3 版には、型の検査もカバレッジもありませんでした。lint は未定義の名前を半分しか捕まえません。残る守りはテストだけです。
そのテストの道具も自作しました。テスティングフレームワークが無く、ASSERT等 は最初の失敗で止まるので、失敗を数えて最後に報告するテスト補助を作りました(第 1 章)。カバレッジの代わりに、テストから届かない関数を見つける「関数の網羅」を作りました(第 5 章)。そして、テストが空振りしていないことを、実装や検査をわざと壊して落ちることで確かめ続けました(第 1・6・7・10〜14 章とこの章)。
テスト補助そのものにも不具合がありました。近似確認 は差が NaN のとき合格にしていて、第 7 章で見つけて直しました。守りの道具も、守られる側のコードと同じようにテストする 必要があります。
命令の上限という新しい制約¶
gonako は 1 回の実行で 2 億命令を超えると止まります(第 3 章)。素朴に書いた第 8・10・14 章は上限に届き、計算の結果を 1 ビットも変えずに書き方を変えて 収めました。命令を数える処理系を作り、足す順と同点の扱いを守りながら、繰り返しを Go で書かれた命令に置き換え、同じ計算を数え上げに直しました。速い書き方を入れたときは、素朴な書き方をテストに残して丸ごと比べました(第 14 章)。
ほかの 14 の言語版では、性能は「速いか遅いか」の問題でした。なでしこ3 版では「動くか止まるか」の問題になりました。
自作の正しさを何で支えたか¶
なでしこ3 には機械学習のライブラリも行列の命令もなく、線形代数(連立一次方程式と固有値分解)も自作しました(第 7・13 章)。突き合わせる相手のライブラリは 1 つもありません。
代わりに支えたのは、次の 3 つでした。
- ほかの言語版の数値 — 第 2 章で
java.util.Randomと同じ線形合同法を float64 だけで自作し、シード 0 の並びがほかの言語版と一致することを確かめました。この土台の上で、各章の期待値をほかの言語版の値にしました - Python の参照実装 — 同じ規則・同じ演算順で書いた参照実装と、実データと同じ形の架空のデータで、報告を 1 文字ずつ、数値を最後の桁まで突き合わせました
- 満たすべき性質 — 固有値分解の A v = λ v(第 13 章)、alpha 0 のリッジ回帰と線形回帰の一致と KKT 条件(第 12 章)、交差検証で全行がちょうど 1 度テストされること(第 11 章)
ただし、実データ(配布データ)での実測は、どの章もまだ行っていません。学習データの無い環境で書いたためです。記事に載せた実データの数値は、ほかの言語版の値を「期待する出力」として示したもので、各章の注記に書いたとおり、学習データを置いた環境で確かめるまで未確認です。CI は main への pull request で初めて走り、学習データの無いランナーで、実データのテストを保留にしたまますべての検査が通りました。
最後に¶
第 1 章の「20 代ならきのこ派」という手書きのルールから始めて、この章で学習したモデルを JSON で届けるところまで来ました。
なでしこ3 版で効いたのは、約束をテストに書き、そのテストが本当に落ちることを確かめる という、どの言語版でも同じ規律でした。型が無く、lint が半分しか捕まえず、名前が文法と衝突する言語では、その規律が省けませんでした。
変更を楽に安全にできること。 日本語で書けることは読みやすさを与えてくれましたが、変更を安全にしてくれたのはテストでした。それが、14 の言語版のあとに日本語の言語で書いて分かったことです。
なでしこ3 版のほかの章は なでしこ3 版のトップ から辿れます。