第 15 章: 機械学習 API とモジュール設計¶
15.1 はじめに¶
ここまでの章では、モデルを学習して評価するところまでを扱いました。モデルは、学習しただけでは誰の役にも立ちません。この章では、第 7 章の興行収入の予測と第 8 章の生存の予測を、HTTP の API として公開します。
API を作ると、機械学習とは別の関心事が増えます。
- 外から来る JSON が正しい形か(入力の検証)
- 学習済みモデルをどこから読み込むか(モデルの置き場)
- モデルが無いときや、予期しない例外が起きたときに、何を返すか(エラーの扱い)
これらを 1 つのファイルに書くと、変更が難しくなります。関心事ごとに層(モジュール)を分け、依存の向きをそろえます。
Python 版の第 15 章・Kotlin 版の第 15 章・TypeScript 版の第 15 章 と同じ API を作ります。F# 版では、次の 3 点に注目してください。
- ドメインを 判別共用体 で表し、ありえない値(4 等の客室など)を型で作れないようにする
- 失敗を例外ではなく、組み込みの
Result<'T, 'E>で返す。入力の検証では、and!の計算式で不正な理由をまとめて集める - モデルの置き場を 関数のレコード にして、テストではスタブに差し替える。インターフェースやクラスは使わない
15.2 レイヤードアーキテクチャ¶
4 つの層と依存の向き¶
他の版と同じく、4 つの層に分けます。
| 層 | ファイル | 役割 | 知っている層 |
|---|---|---|---|
| ドメイン | Domain.fs |
入力(映画・乗客)と予測結果の型、予測できなかった理由(判別共用体)、「モデル」と「モデルの置き場」の約束(関数の型と関数のレコード) | 何も知らない |
| アプリケーション | PredictionService.fs |
予測のユースケース(モデルを読み込んで予測する、ヘルスチェック) | ドメイン |
| インフラ | FileModelStore.fs・Training.fs |
モデルのファイルへの保存・読み込みと、第 7・8 章のモデルをドメインの約束に合わせるアダプター。学習して保存する処理 | ドメイン、第 7・8 章 |
| プレゼンテーション | RequestValidation.fs・PredictionApi.fs |
入力の検証、Giraffe のエンドポイント、HTTP の状態コード | ドメイン、アプリケーション |
ポイントは、アプリケーション層がインフラ層を知らない ことです。predictSales は「モデルの置き場(ModelStore)から読み込んで予測する」ことしか知らず、それがファイルなのか、テスト用のスタブなのかを気にしません。
Kotlin 版・TypeScript 版と同じく、プレゼンテーション層の createWebApplication は、置き場を引数で受け取ります。どの置き場を使うかを決めるのは、サーバーを起動する Main.fs(15.8 節)です。このため、プレゼンテーション層もインフラ層を知りません。
各モジュールの先頭には、どの層かをドキュメントコメントで書いています。
/// アプリケーション層。置き場からモデルを読み込んで予測するユースケースと、ヘルスチェック。ドメイン層だけを知る
module MachineLearning.Chapter15.PredictionService
F# では、ファイルの順番がそのまま依存の向きになります(第 1 章)。.fsproj に Domain.fs・PredictionService.fs・RequestValidation.fs・PredictionApi.fs・FileModelStore.fs・Training.fs・Main.fs の順に並べるので、ドメイン層がプレゼンテーション層を参照するような逆向きの依存は、そもそもコンパイルできません。
インサイドアウトで進める¶
TDD の進め方には、外側(API)から作る アウトサイドイン と、内側(ドメイン・アプリケーション)から作る インサイドアウト があります。この章ではインサイドアウトを選びます。内側の層は外側を知らないので、スタブだけでテストできます。
15.3 TODO リストの作成¶
TODO リスト:
- ドメインの型を決める
- 予測サービスを作る
- モデルが無ければ失敗を返す
- ヘルスチェック
- 入力を検証する
- 不正な理由をまとめて返す
- 年齢と乗船港は省略できる
- Giraffe でエンドポイントを作る
- 不正な入力は 422、モデルが無ければ 503、予期しない例外は 500
- モデルを保存して読み込む
- 実データで学習したモデルで動かす
15.4 ドメイン層とアプリケーション層¶
ドメインを判別共用体で表す¶
// src/MachineLearning/Chapter15/Domain.fs
/// 興行収入を予測する映画の特徴量
type Movie =
{
Sns1: float
Sns2: float
Actor: float
/// 原作があるか
Original: bool
}
/// 客室の等級
type Pclass =
| First
| Second
| Third
type Sex =
| Male
| Female
/// 乗船した港
type Embarked =
| Cherbourg
| Queenstown
| Southampton
/// 生存を予測する乗客。年齢と乗船港は分からないことがある
type Passenger =
{
Pclass: Pclass
Sex: Sex
Age: float option
SibSp: int
Parch: int
Fare: float
Embarked: Embarked option
}
- 第 8 章では、客室の等級を
int、性別と乗船港をstringで表していました。CSV の値をそのまま持つためです。API のドメインでは、とりうる値を判別共用体で列挙します。Pclassの値はFirst・Second・Thirdの 3 つしか作れないので、「4 等の客室」はドメインの中に入り込めません - 原作の有無も、
0.0か1.0の数値ではなくboolにしました - 分からないことがある年齢と乗船港は
optionです
モデルと置き場を関数の型で表す¶
/// 予測できなかった理由
type PredictionError =
/// 学習済みモデルが見つからない。持つのはモデルの名前だけで、ファイルのパスは持たない
| ModelNotFound of model: string
/// 学習済みモデルのファイルはあるが、読み込めない(壊れている・形が違う)
| ModelUnreadable of model: string
/// 映画の特徴量から興行収入を予測するモデル
type SalesModel = Movie -> float
/// 乗客が生存するかを判定するモデル
type SurvivalModel = Passenger -> bool
/// 学習済みモデルの置き場。読み込めなければ Error を返す
type ModelStore =
{
LoadSalesModel: unit -> Result<SalesModel, PredictionError>
LoadSurvivalModel: unit -> Result<SurvivalModel, PredictionError>
}
- TypeScript 版では、エラーを
Errorを継承したクラスで表し、例外として投げていました。F# 版では、予測できなかった理由を 判別共用体 で表し、ResultのErrorとして返します。呼び出す側は、失敗をmatchで扱うまで結果の値を取り出せません - エラーはモデルの名前だけを持ち、ファイルのパスを持ちません。応答に内部の情報(サーバーのディレクトリ構成)が漏れないようにするためです
- モデルは「映画を受け取って金額を返す関数」そのものです。第 11 章の
Model<'T>と同じく、関数の型に名前を付けています - 置き場は、2 つの関数を持つ レコード です。TypeScript 版のインターフェースに当たりますが、レコードなので、テストでは
{ ... }で直接作れ、withで一部だけ差し替えられます
Red: スタブで予測サービスをテストする¶
// tests/MachineLearning.Tests/Chapter15/Stubs.fs
/// sns1 に 1000 を足すだけの興行収入のモデル
let stubSalesModel: SalesModel = fun movie -> 1000.0 + movie.Sns1
/// 女性なら生存と判定するだけのモデル
let stubSurvivalModel: SurvivalModel = fun passenger -> passenger.Sex = Female
/// 常にスタブのモデルを返す置き場
let stubModelStore: ModelStore =
{
LoadSalesModel = fun () -> Ok stubSalesModel
LoadSurvivalModel = fun () -> Ok stubSurvivalModel
}
/// モデルが 1 つも無い置き場
let emptyModelStore: ModelStore =
{
LoadSalesModel = fun () -> Error(ModelNotFound "cinema")
LoadSurvivalModel = fun () -> Error(ModelNotFound "survived")
}
// tests/MachineLearning.Tests/Chapter15/PredictionServiceTest.fs
[<Fact>]
let ``映画の特徴量から興行収入を予測する`` () =
Assert.Equal(Ok { Sales = 1200.0 }, predictSales stubModelStore movie)
[<Fact>]
let ``モデルが無ければ ModelNotFound の失敗を返す`` () =
Assert.Equal(Error(ModelNotFound "cinema"), predictSales emptyModelStore movie)
[<Fact>]
let ``モデルを読み込めればそれぞれ true を返す`` () =
Assert.Equal({ Cinema = true; Survived = true }, health stubModelStore)
スタブのモデルは、学習もファイルも使わない、ただの関数です。Result の値も、レコードと同じく構造的に比べられるので、Ok { Sales = 1200.0 } をそのまま期待値に書けます。
error FS0039: 名前空間 'Chapter15' が定義されていません。
error FS0039: 型 'SalesModel' が定義されていません。
Green: Result.map で予測する¶
// src/MachineLearning/Chapter15/PredictionService.fs
/// モデルごとに読み込めるかどうか
type Health = { Cinema: bool; Survived: bool }
let predictSales (store: ModelStore) (movie: Movie) : Result<SalesPrediction, PredictionError> =
store.LoadSalesModel() |> Result.map (fun model -> { Sales = model movie })
let predictSurvival (store: ModelStore) (passenger: Passenger) : Result<SurvivalPrediction, PredictionError> =
store.LoadSurvivalModel()
|> Result.map (fun model -> { Survived = model passenger })
let health (store: ModelStore) : Health =
{
Cinema = store.LoadSalesModel() |> Result.isOk
Survived = store.LoadSurvivalModel() |> Result.isOk
}
/// 利用者に見せるエラーの説明
let describe (error: PredictionError) : string =
match error with
| ModelNotFound model -> $"学習済みモデル {model} が見つかりません"
| ModelUnreadable model -> $"学習済みモデル {model} を読み込めません"
Result.mapは、Okなら中の値を変換し、Errorならそのまま通します。TypeScript 版ではmapResultを自作しましたが、F# では標準ライブラリにありますdescribeのmatchは、エラーのすべての場合を扱います。最初はModelNotFoundだけでしたが、15.7 節でModelUnreadableを加えたとき、コンパイラが次のエラーで知らせてくれました
error FS0025: この式のパターン マッチが不完全です たとえば、値 'ModelUnreadable (_)' はパターンに含まれないケースを示す可能性があります。
エラーの種類を増やしたときに、説明を書き忘れることがありません。第 3 章の網羅性の検査が、ここでも効いています。
15.5 入力を検証する(プレゼンテーション層)¶
何を検証するか¶
API の入力は、映画なら {"sns1": 200, "sns2": 500, "actor": 3000, "original": 1}、乗客なら {"pclass": 1, "sex": "female", "age": null, "sib_sp": 0, "parch": 0, "fare": 50, "embarked": "C"} の JSON です。項目名と、とりうる値は他の版と同じです。
- 数値の項目は 0 以上。
sib_sp・parchは整数 originalは 0 か 1、pclassは 1・2・3、sexはmale・female、embarkedはC・Q・Sageとembarkedは省略でき、nullも省略と同じ扱い- 不正な項目がいくつあっても、理由をまとめて 返す
Red: 理由をまとめて返すテスト¶
// tests/MachineLearning.Tests/Chapter15/RequestValidationTest.fs
[<Fact>]
let ``映画の JSON を Movie にする`` () =
Assert.Equal(Ok movie, parseMovie movieJson)
[<Theory>]
[<InlineData("pclass", "4", "pclass は 1、2、3 のどれかにしてください")>]
[<InlineData("sex", "\"unknown\"", "sex は male、female のどれかにしてください")>]
[<InlineData("fare", "-1", "fare は 0 以上にしてください")>]
[<InlineData("embarked", "\"X\"", "embarked は C、Q、S のどれかにしてください")>]
[<InlineData("sib_sp", "0.5", "sib_sp は整数にしてください")>]
let ``乗客の特徴量が不正ならその理由を返す`` (field: string, value: string, reason: string) =
Assert.Equal(Error [ reason ], parsePassenger (passengerJsonWith field value))
[<Fact>]
let ``不正な理由をまとめて返す`` () =
let json =
"""{"pclass": 4, "sex": "female", "sib_sp": 0, "parch": 0, "fare": -1}"""
Assert.Equal(
Error [ "pclass は 1、2、3 のどれかにしてください"; "fare は 0 以上にしてください" ],
parsePassenger json
)
[<Theory>]と[<InlineData(...)>]は、同じテストを入力を変えて何度も実行する xUnit の書き方です。1 行のInlineDataが 1 回のテストになりますpassengerJsonWithは、正しい乗客の JSON の 1 つの項目だけを差し替えるテスト用の関数です。項目と値の組のリストから JSON を組み立てます"""..."""は、中に"をそのまま書ける三重引用符の文字列です
検証の結果を Result で表す¶
// src/MachineLearning/Chapter15/RequestValidation.fs
/// 検証の結果。正しければ値を、不正なら理由の一覧を持つ
type Validation<'T> = Result<'T, string list>
項目ごとの検証は、Result を返す小さな関数を Result.bind でつなげて書きます。
let private nonNegativeNumber name json =
tryField name json
|> required name
|> Result.bind (number name)
|> Result.bind (notNegative name)
「項目がある」「数値である」「0 以上である」を順に確かめ、どこかで失敗したら、その理由の Error がそのまま最後まで流れます。Result.bind は、Ok なら次の検証に進み、Error なら残りを飛ばします。
and! で理由をまとめて集める¶
Result.bind でつなぐと、最初の失敗で止まります。1 つの項目の中ではそれで十分ですが、項目どうしは 全部 検証して、理由をまとめて返したいところです。そこで、項目どうしを and! でつなぐ計算式(コンピュテーション式)を作ります。
/// 項目ごとの検証をまとめる計算式。and! でつないだ項目は、どれかが不正でも残りを検証し、理由をすべて集める
type ValidationBuilder() =
member _.BindReturn(result: Validation<'T>, f: 'T -> 'U) : Validation<'U> = Result.map f result
member _.MergeSources(left: Validation<'T>, right: Validation<'U>) : Validation<'T * 'U> =
match left, right with
| Ok l, Ok r -> Ok(l, r)
| Error l, Error r -> Error(l @ r)
| Error errors, Ok _
| Ok _, Error errors -> Error errors
let validation = ValidationBuilder()
MergeSourcesは、and!でつないだ 2 つの結果を 1 つにまとめる方法です。両方がErrorなら、理由のリストを@でつなげます。ここが、「最初の失敗で止まらずに、理由を集める」仕組みですBindReturnは、まとめた結果から最後の値を作る方法です- この 2 つのメソッドを持つクラスのインスタンス(
validation)を作ると、validation { ... }の中でlet!とand!が使えるようになります
let parsePassenger (body: string) : Validation<Passenger> =
body
|> parseWith (fun json ->
validation {
let! pclass =
tryField "pclass" json
|> required "pclass"
|> Result.bind (oneOf "pclass" [ "1", First; "2", Second; "3", Third ])
and! sex =
tryField "sex" json
|> required "sex"
|> Result.bind (oneOf "sex" [ "male", Male; "female", Female ])
and! age =
json
|> optional "age" (fun value -> number "age" value |> Result.bind (notNegative "age"))
and! sibSp = nonNegativeInteger "sib_sp" json
and! parch = nonNegativeInteger "parch" json
and! fare = nonNegativeNumber "fare" json
and! embarked =
json
|> optional "embarked" (oneOf "embarked" [ "C", Cherbourg; "Q", Queenstown; "S", Southampton ])
return
{
Pclass = pclass
Sex = sex
Age = age
SibSp = sibSp
Parch = parch
Fare = fare
Embarked = embarked
}
})
let!とand!で並べた 7 つの検証は、互いに依存しません。すべてがOkのときだけreturnのレコードが作られ、1 つでもErrorがあれば、すべての理由を集めたErrorになりますoneOfは、JSON の値("1"や"female")を判別共用体の値(FirstやFemale)に対応づけます。検証を通った値は、もう文字列ではありません。検証と変換を同時に 行うので、検証済みの値だけがドメインに入ります- TypeScript 版では zod のスキーマで同じことをしました。F# では、ライブラリを使わずに言語の計算式で書けます
inline と型の制約¶
「0 以上か」を確かめる notNegative は、float にも int にも使いたい関数です。最初は次のように書きました。
let private notNegative (name: string) (value: 'N) : Validation<'N> =
if value >= LanguagePrimitives.GenericZero then
Ok value
else
Error [ $"{name} は 0 以上にしてください" ]
すると、コンパイルエラーになりました。
error FS0064: このコンストラクトによって、コードの総称性は型の注釈よりも低くなります。型変数 'N' は型 'float' に制約されました。
LanguagePrimitives.GenericZero は「その型の 0」ですが、.NET のジェネリックでは「数値型のどれか」という制約を表せません。F# のコンパイラは、最初に使われた float に型を固定してしまいました。関数を inline にすると、呼び出す場所ごとに実際の型(float や int)で関数の本体が展開されるので、どちらの型でも使えます。
/// inline にすると、呼び出す場所ごとに float・int など実際の型で展開される(GenericZero はその型の 0)
let inline private notNegative (name: string) (value: 'N) : Validation<'N> =
15.6 Giraffe でエンドポイントを作る(プレゼンテーション層)¶
Giraffe を導入する¶
Giraffe は、ASP.NET Core の上で動く F# 向けの Web フレームワークです。版は ADR 004 のとおり 8.3.0 です。統合テストには、サーバーを起動せずに HTTP の要求を送れる Microsoft.AspNetCore.TestHost を使います。
<!-- Directory.Packages.props -->
<PackageVersion Include="Giraffe" Version="8.3.0" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.12" />
<!-- src/MachineLearning/MachineLearning.fsproj -->
<ItemGroup>
<!-- 第 15 章の API(Giraffe)は ASP.NET Core の上で動く -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
ASP.NET Core は NuGet のパッケージではなく、.NET SDK に含まれる共有フレームワークです。FrameworkReference で参照します。
Red: TestHost で API をテストする¶
// tests/MachineLearning.Tests/Chapter15/PredictionApiTest.fs
/// 置き場を使う API を、ネットワークを使わないテスト用のサーバーで起動し、クライアントを返す
let clientWith (store: ModelStore) : Task<HttpClient> =
task {
let builder = WebApplication.CreateBuilder()
builder.WebHost.UseTestServer() |> ignore
let app = createWebApplication builder store
do! app.StartAsync()
return app.GetTestClient()
}
[<Fact>]
let ``映画の特徴量を送ると予測した興行収入を返す`` () =
task {
let! actual = postJson stubModelStore "/cinema/sales" movieJson
Assert.Equal((HttpStatusCode.OK, """{"sales":1200}"""), actual)
}
[<Fact>]
let ``興行収入のモデルが無ければ 503 を返す`` () =
task {
let! actual = postJson emptyModelStore "/cinema/sales" movieJson
Assert.Equal((HttpStatusCode.ServiceUnavailable, """{"detail":"学習済みモデル cinema が見つかりません"}"""), actual)
}
UseTestServer()に差し替えると、ネットワークのポートを開かずに、メモリの中で要求と応答をやりとりしますtask { ... }は、非同期の処理を書く計算式です。let!で非同期の結果を待ち、do!で値を返さない非同期の処理を待ちます。xUnit はTaskを返すテストをそのまま扱えます
最初は、テストを同期で書いていました(client.Send request)。すると、すべてのテストが次の例外で失敗しました。
System.NotSupportedException : This synchronous method is not supported due to the risk of threadpool exhaustion when running multiple tests in parallel. Use the asynchronous version of this method instead.
TestHost のクライアントは、同期の Send に対応していません。テストを並行して走らせたときにスレッドを使い果たすおそれがあるためです。task { } で SendAsync を待つ形に直しました。
エンドポイントを組み立てる¶
// src/MachineLearning/Chapter15/PredictionApi.fs
/// 予測の結果を応答にする。モデルが無ければ 503 とその説明を返す
let private respond (result: Result<'T, PredictionError>) : HttpHandler =
match result with
| Ok prediction -> json prediction
| Error error ->
setStatusCode StatusCodes.Status503ServiceUnavailable
>=> json {| detail = describe error |}
/// 本文を検証し、正しければ予測する。不正なら 422 と理由の一覧を返す
let private predictWith (parse: string -> Validation<'I>) (predict: 'I -> Result<'O, PredictionError>) : HttpHandler =
fun next ctx ->
task {
let! body = ctx.ReadBodyFromRequestAsync()
let handler =
match parse body with
| Error errors ->
setStatusCode StatusCodes.Status422UnprocessableEntity
>=> json {| detail = errors |}
| Ok input -> respond (predict input)
return! handler next ctx
}
/// 経路と HTTP のメソッドから、処理を選ぶ
let webApp (store: ModelStore) : HttpHandler =
choose
[
POST >=> route "/cinema/sales" >=> predictWith parseMovie (predictSales store)
POST
>=> route "/survived"
>=> predictWith parsePassenger (predictSurvival store)
GET >=> route "/health" >=> healthHandler store
setStatusCode StatusCodes.Status404NotFound >=> json {| detail = "見つかりません" |}
]
- Giraffe の
HttpHandlerは、「要求を受け取って、応答を作るか、次に回す」関数です。>=>は、2 つのハンドラーを「前が成功したら後ろを実行する」とつなぐ演算子で、POST >=> route "/cinema/sales" >=> ...は「POST で、経路が /cinema/sales なら、予測する」と読めます chooseは、リストのハンドラーを上から試し、最初に成功したものを使います。最後の 404 のハンドラーは、どれにも当たらなかった要求を受け止めますpredictWithは、検証の関数と予測の関数を受け取る 高階関数 です。映画と乗客の 2 つのエンドポイントで、同じ「検証して、予測して、応答にする」流れを共有しています- 検証の失敗(
ValidationのError)は 422、予測の失敗(PredictionError)は 503 と、失敗の型ごとに状態コードが決まります。例外の型で分ける TypeScript 版より、どこで何が起きうるかが型に表れます
予期しない例外と JSON の設定¶
/// 予期しない例外は記録し、内部の情報を出さずに 500 を返す
let private errorHandler (error: exn) (logger: ILogger) : HttpHandler =
logger.LogError(error, "予測中に例外が発生しました")
clearResponse
>=> setStatusCode StatusCodes.Status500InternalServerError
>=> json {| detail = "予測中にエラーが発生しました" |}
/// JSON の項目名を camelCase にし、日本語をエスケープせずにそのまま書く
let private jsonOptions =
JsonSerializerOptions(JsonSerializerDefaults.Web, Encoder = JavaScriptEncoder.Create(UnicodeRanges.All))
Resultで表していない失敗(モデルの中で起きた例外など)は、UseGiraffeErrorHandlerに渡したハンドラーが受け止めます。例外のメッセージは記録だけして、応答には出しません- System.Text.Json は、既定では日本語を
学のようにエスケープします。JavaScriptEncoder.Create(UnicodeRanges.All)を指定して、読める形のまま返します
匿名レコードと項目の順¶
ヘルスチェックの応答は、最初は匿名レコード {| status = status; models = models |} で書いていました。すると、テストが次のように失敗しました。
Expected: Tuple (OK, "{\"status\":\"ok\",\"models\":{\"cinema\":true,\"survived\":"···)
Actual: Tuple (OK, "{\"models\":{\"cinema\":true,\"survived\":true},\"status\""···)
F# の匿名レコードは、フィールドを 名前の順 に並べます(第 2 章の Notebook の表と同じです)。JSON の項目の順は意味を持ちませんが、他の版と同じ順で返すために、名前付きのレコードにしました。名前付きのレコードは、書いた順に項目を並べます。
/// ヘルスチェックの応答。匿名レコードはフィールドを名前の順に並べるので、項目の順を決めるために名前を付ける
type HealthResponse = { Status: string; Models: Health }
15.7 モデルを保存して読み込む(インフラ層)¶
第 7・8 章のモデルを約束に合わせる¶
置き場は、第 7 章の線形回帰と第 8 章のパイプラインを、ドメインの約束(SalesModel・SurvivalModel)に合わせます。
// src/MachineLearning/Chapter15/FileModelStore.fs
/// ドメインの乗客を、第 8 章のパイプラインが受け取る乗客にする
let toChapter08Passenger (passenger: Passenger) : SurvivedData.Passenger =
{
Pclass =
match passenger.Pclass with
| First -> 1
| Second -> 2
| Third -> 3
Sex =
match passenger.Sex with
| Male -> "male"
| Female -> "female"
Age = passenger.Age
SibSp = passenger.SibSp
Parch = passenger.Parch
Fare = passenger.Fare
Embarked =
passenger.Embarked
|> Option.map (function
| Cherbourg -> "C"
| Queenstown -> "Q"
| Southampton -> "S")
}
判別共用体から CSV の値への変換は、すべての場合を match で書きます。場合を書き忘れれば、網羅性の検査がコンパイルエラーにします。
ファイルが無い・読めないを区別する¶
/// ファイルが無ければ ModelNotFound、読み込めなければ ModelUnreadable にする
let private loadFile (name: string) (file: string) (read: string -> Result<'M, string>) : Result<'M, PredictionError> =
if not (File.Exists file) then
Error(ModelNotFound name)
else
read file |> Result.mapError (fun _ -> ModelUnreadable name)
/// ディレクトリに保存した学習済みモデルを、呼ばれるたびに読み込む置き場
let fileModelStore (directory: string) : ModelStore =
{
LoadSalesModel =
fun () ->
loadFile "cinema" (Path.Combine(directory, SalesModelFile)) readLinearModel
|> Result.map (fun model movie ->
predictLinearRegression model [ toCinemaFeatures movie ] |> List.exactlyOne)
LoadSurvivalModel =
fun () ->
loadFile "survived" (Path.Combine(directory, SurvivalModelFile)) ModelFile.loadModel
|> Result.map (fun pipeline passenger -> predictPassenger pipeline (toChapter08Passenger passenger) = 1)
}
- 壊れたファイルを「見つからない」と報告すると、利用者は原因を取り違えます。そこで、判別共用体に
ModelUnreadableを加えて区別しました(15.4 節の FS0025 は、このときに出たものです) Result.mapErrorは、Errorの中身だけを変換します。読み込みの詳しいエラーメッセージ(string)を、モデルの名前だけを持つModelUnreadableに置き換えて、内部の情報を外に出さないようにしていますResult.map (fun model movie -> ...)は、2 つの引数を取る関数を返しています。modelを覚えた「映画を受け取る関数」、つまりSalesModelになります- 置き場は、呼ばれるたびにファイルを読み込みます。学習し直してファイルを置き換えれば、サーバーを再起動せずに新しいモデルが使われます
保存の形式¶
第 7 章の線形回帰(LinearModel)は、切片と係数の Map を持つレコードです。System.Text.Json は、F# のレコードと Map をそのまま JSON に書き、読み戻せました(スクリプトで確かめました)。
{"Intercept":1.5,"Coefficients":{"SNS1":2}}
第 8 章のパイプラインは、第 8 章の ModelFile がすでに JSON で保存・読み込みしているので、それを使います。
// tests/MachineLearning.Tests/Chapter15/FileModelStoreTest.fs
[<Fact>]
let ``保存した線形回帰を読み込み、映画の特徴量から興行収入を予測する`` () =
let directory = emptyDirectory ()
saveSalesModel directory salesModel
match (fileModelStore directory).LoadSalesModel() with
| Ok predictSales ->
// 100 + 2 × 200 + 0.5 × 500 + 1 × 3000 + 10 × 1(原作あり)
Assert.Equal(3760.0, predictSales movie, 9)
| Error error -> Assert.Fail $"%A{error}"
[<Fact>]
let ``モデルのファイルが壊れていれば ModelUnreadable を返す`` () =
let directory = emptyDirectory ()
File.WriteAllText(Path.Combine(directory, SalesModelFile), "{")
Assert.Equal(Error(ModelUnreadable "cinema"), (fileModelStore directory).LoadSalesModel() |> Result.map ignore)
- 読み込んだモデルは関数なので、
=で比べられません。Result.map ignoreでOkの中身を捨ててから、Errorの側だけを比べています $"%A{error}"は、補間文字列の中で%A(F# の値を読める形で書く書式)を使う書き方です
15.8 実データで学習したモデルで動かす¶
学習と保存¶
第 7・8 章と同じ条件(テストデータの割合 0.2、シード 0、深さ 5、クラスの重みあり)で学習して保存します。
// src/MachineLearning/Chapter15/Training.fs
/// 第 7・8 章と同じ条件でモデルを学習し、ディレクトリに保存する
let trainAndSaveModels (dataDirectory: string) (modelDirectory: string) : unit =
let cinema = prepareCinema (Path.Combine(dataDirectory, "cinema.csv")) TestSize Seed
saveSalesModel modelDirectory (fitLinearRegression cinema.XTrain cinema.TTrain)
let x, t =
loadSurvived (Path.Combine(dataDirectory, "Survived.csv"))
|> splitFeaturesAndTarget
let survived = splitTrainTest TestSize Seed x t
fitPipeline
{
MaxDepth = Some MaxDepth
ClassWeight = Balanced
}
survived.XTrain
survived.TTrain
|> saveSurvivalModel modelDirectory
統合テスト¶
実データで学習して保存したモデルを、本物の置き場(fileModelStore)から読み込み、TestHost の API に送ります。
// tests/MachineLearning.Tests/Chapter15/TrainedModelsTest.fs
[<Fact>]
let ``実データで学習して保存したモデルで API が予測する`` () =
task {
requireData ()
let directory = Directory.CreateTempSubdirectory("model-").FullName
trainAndSaveModels (dataDir ()) directory
let store = fileModelStore directory
let! health = get store "/health"
let! sales = postJson store "/cinema/sales" movieJson
let! survived = postJson store "/survived" passengerJson
Assert.Equal((HttpStatusCode.OK, """{"status":"ok","models":{"cinema":true,"survived":true}}"""), health)
Assert.Equal(HttpStatusCode.OK, fst sales)
Assert.StartsWith("""{"sales":""", snd sales)
Assert.Equal((HttpStatusCode.OK, """{"survived":true}"""), survived)
}
スタブで確かめた API に、本物の置き場(インフラ層)を差し込むだけで、実データのモデルで動きます。置き場をレコードで表しておいたので、プレゼンテーション層とアプリケーション層は 1 行も変わっていません。
学習してサーバーを起動する¶
// src/MachineLearning/Chapter15/Main.fs
/// 置き場のモデルで予測する API を、指定したアドレスで起動する。止めるまで戻らない
let startServer (modelDirectory: string) (url: string) : unit =
let app =
createWebApplication (WebApplication.CreateBuilder()) (fileModelStore modelDirectory)
app.Run url
/// 学習して保存してから、API を起動する
let run (print: string -> unit) : unit =
trainAndReport ModelDirectory print
startServer ModelDirectory $"http://{Host}:{Port}"
テストで使った createWebApplication を、本物のサーバーでもそのまま使います。違うのは、WebApplicationBuilder に UseTestServer() をしないことだけです。
dotnet run --project src/MachineLearning -- chapter15
学習済みモデルを保存しました: cinema.json, survived.json
API を起動します: http://127.0.0.1:8015
別の端末から、curl で要求を送ります。
curl -s http://127.0.0.1:8015/health
curl -s -X POST -H "Content-Type: application/json" \
-d '{"sns1": 200, "sns2": 500, "actor": 3000, "original": 1}' \
http://127.0.0.1:8015/cinema/sales
curl -s -X POST -H "Content-Type: application/json" \
-d '{"pclass": 1, "sex": "female", "sib_sp": 0, "parch": 0, "fare": 50, "embarked": "C"}' \
http://127.0.0.1:8015/survived
curl -s -X POST -H "Content-Type: application/json" \
-d '{"pclass": 3, "sex": "male", "age": 30, "sib_sp": 0, "parch": 0, "fare": 8, "embarked": "S"}' \
http://127.0.0.1:8015/survived
curl -s -w " %{http_code}" -X POST -H "Content-Type: application/json" \
-d '{"pclass": 4, "sex": "female", "sib_sp": 0, "parch": 0, "fare": -1}' \
http://127.0.0.1:8015/survived
{"status":"ok","models":{"cinema":true,"survived":true}}
{"sales":7883.711445215134}
{"survived":true}
{"survived":false}
{"detail":["pclass は 1、2、3 のどれかにしてください","fare は 0 以上にしてください"]} 422
- 1 等の女性の乗客は生存、3 等の 30 歳の男性の乗客は死亡と予測されました
- 年齢を省略した乗客も予測できています。欠けた年齢は、第 8 章のパイプラインが客室の等級と性別ごとの中央値で補完します
- 不正な項目が 2 つある要求には、422 と 2 つの理由がまとめて返りました
学習済みモデルは apps/fsharp/model/ に保存されます。このディレクトリは .gitignore で除外しています(第 4 章)。
テストの実行結果¶
dotnet test
テストの実行の概要: 成功!
合計: 313
失敗: 0
成功: 313
スキップ済み: 0
第 1〜15 章のすべてのテストの件数です。第 15 章のテストは 38 件(Theory の入力ごとに 1 件と数えます)です。
この章の完成コード(src/MachineLearning/Chapter15/RequestValidation.fs)
/// プレゼンテーション層。HTTP の本文(JSON)を検証して、ドメイン層の型にする
module MachineLearning.Chapter15.RequestValidation
open System.Text.Json
open MachineLearning.Chapter15.Domain
/// 検証の結果。正しければ値を、不正なら理由の一覧を持つ
type Validation<'T> = Result<'T, string list>
/// 項目ごとの検証をまとめる計算式。and! でつないだ項目は、どれかが不正でも残りを検証し、理由をすべて集める
type ValidationBuilder() =
member _.BindReturn(result: Validation<'T>, f: 'T -> 'U) : Validation<'U> = Result.map f result
member _.MergeSources(left: Validation<'T>, right: Validation<'U>) : Validation<'T * 'U> =
match left, right with
| Ok l, Ok r -> Ok(l, r)
| Error l, Error r -> Error(l @ r)
| Error errors, Ok _
| Ok _, Error errors -> Error errors
let validation = ValidationBuilder()
/// 項目の値。無い項目と null は None
let private tryField (name: string) (json: JsonElement) : JsonElement option =
match json.TryGetProperty name with
| true, value when value.ValueKind <> JsonValueKind.Null -> Some value
| _ -> None
let private required (name: string) (value: JsonElement option) : Validation<JsonElement> =
value |> Option.map Ok |> Option.defaultValue (Error [ $"{name} を指定してください" ])
let private number (name: string) (value: JsonElement) : Validation<float> =
if value.ValueKind = JsonValueKind.Number then
Ok(value.GetDouble())
else
Error [ $"{name} は数値にしてください" ]
let private integer (name: string) (value: JsonElement) : Validation<int> =
match value.ValueKind, value.TryGetInt32() with
| JsonValueKind.Number, (true, n) -> Ok n
| _ -> Error [ $"{name} は整数にしてください" ]
/// inline にすると、呼び出す場所ごとに float・int など実際の型で展開される(GenericZero はその型の 0)
let inline private notNegative (name: string) (value: 'N) : Validation<'N> =
if value >= LanguagePrimitives.GenericZero then
Ok value
else
Error [ $"{name} は 0 以上にしてください" ]
/// JSON の値(数値か文字列)を、決められた値の一覧のどれかに対応づける
let private oneOf (name: string) (choices: (string * 'T) list) (value: JsonElement) : Validation<'T> =
let raw =
match value.ValueKind with
| JsonValueKind.String -> value.GetString()
| _ -> value.GetRawText()
match choices |> List.tryFind (fun (key, _) -> key = raw) with
| Some(_, choice) -> Ok choice
| None ->
let keys = choices |> List.map fst |> String.concat "、"
Error [ $"{name} は {keys} のどれかにしてください" ]
15.9 まとめ¶
この章では、第 7・8 章のモデルを HTTP の API として公開し、関心事ごとにモジュールを分けました。
- 依存の向きとファイルの順番 — ドメイン・アプリケーション・インフラ・プレゼンテーションの層に分け、F# のファイルの順番で依存の向きを強制した
- 判別共用体のドメイン — 客室の等級・性別・乗船港を判別共用体にし、ありえない値をドメインに入れないようにした。エラーを増やしたときは網羅性の検査が説明の書き忘れを知らせた
- Result と and! — 失敗を例外ではなく
Resultで返し、入力の検証ではand!の計算式で理由をまとめて集めた。検証と変換を同時に行い、検証済みの値だけをドメインに渡した - 関数のレコードで差し替える — モデルの置き場を関数のレコードにして、テストではスタブ、本番ではファイルの置き場を差し込んだ
- TestHost による統合テスト — サーバーを起動せずに API を確かめた。同期の送信が使えないことや、匿名レコードの項目の順といった、動かして初めて分かることをテストが見つけた
シリーズの振り返り¶
F# 版では、同じ 15 章を「型で誤りを防ぐ」ことに注目して進めました。
- 第 2 章の
optionで欠損値を、第 3 章の判別共用体で決定木を、第 15 章の判別共用体でドメインを表し、ありえない状態を型で作れないようにした - 第 3・15 章の網羅性の検査が、場合分けの漏れをコンパイルエラーにした
- 第 2・12・14 章の不変なデータと純粋な関数が、乱数やライブラリの振る舞いをテストで固定しやすくした
- 型プロバイダ(第 2・9・12 章)で CSV の形を、学習用テストで ML.NET・FSharp.Stats の癖を確かめた
次の言語へ¶
同じ題材を、ほかの言語の版でも読んでみてください。Python 版 では pandas と scikit-learn による標準的な書き方を、Kotlin 版 ではデータフレームと Tribuo を、TypeScript 版 では型付きのレコードと ml.js を使っています。同じ問題を別の言語で解くと、それぞれの言語が何を得意とするかが見えてきます。