第 15 章: 機械学習 API とモジュール設計¶
15.1 はじめに¶
シリーズの最後の章です。ここまでに作ったモデルは、テストと go run ./cmd/chapters chapterNN の中でしか動きませんでした。この章では、第 7 章の線形回帰(映画の興行収入)と第 8 章のパイプライン(乗客の生存)を HTTP の予測 API として公開します。
題材は Python 版の第 15 章 と同じで、3 つのエンドポイントを作ります。
| メソッド | パス | 役割 |
|---|---|---|
GET |
/health |
モデルを読み込めるかどうかを返す |
POST |
/cinema/sales |
映画の特徴量から興行収入を予測する |
POST |
/survived |
乗客の特徴量から生存を予測する |
Go 版で対比するのは Java 版(Javalin・検査例外・sealed interface)と TypeScript 版(ライブラリが限られる環境)です。Go 版の特徴ははっきりしています。Web フレームワークを入れません。 標準ライブラリの net/http だけで作ります。Go 1.22 でルーティングにメソッドとパスのパターンが入ったので、この章の要件はフレームワーク無しで足ります(ADR 008)。
依存が標準ライブラリだけで済むかわりに、ほかの言語版がフレームワークに任せていたことを自分で書きます。JSON の読み書き、入力の検証、エラーの HTTP ステータスコードへの変換です。書く量は増えますが、どこで何が起きているかは全部見えます。
15.2 層を分ける¶
4 つの層と依存の向き¶
パッケージ internal/chapter15 の中を、ファイルで 4 つの層に分けます。Go にはパッケージより細かい可視性の単位が無いので、層の境界は「どのファイルが何を import しているか」で表します。
api.go プレゼンテーション層 net/http・JSON・ステータスコード
validation.go プレゼンテーション層 要求の型と検証
service.go アプリケーション層 置き場からモデルを読んで予測する
domain.go ドメイン層 Movie・Passenger・モデルの約束
store.go インフラ層 ファイルへの保存と読み込み
main.go 組み立て 学習・保存・サーバーの起動
依存の向きは内側(ドメイン)へ向けます。
api.go ──→ service.go ──→ domain.go ←── store.go
↑
(約束だけを知る)
service.go が知っているのは ModelStore という約束(インターフェース)だけで、その実装が JSON のファイルなのか、データベースなのか、テストのスタブなのかを知りません。domain.go は net/http を import しません。これが守れていれば、API の形を変えてもドメインは動き、ドメインのテストはサーバーを起動せずに書けます。
Java 版は同じ分け方をクラスとパッケージで表し、置き場の組み立てを Main に置きました。Go でも組み立ては main.go に置きます。違うのは、Go ではインターフェースを実装する側が「実装します」と宣言しないことです。FileModelStore は ModelStore を実装していると書きません。メソッドの形が合っていれば実装したことになります。
インターフェースは使う側に置く¶
Go の流儀では、インターフェースは実装する側ではなく使う側のパッケージに置きます。この章では ModelStore・SalesModel・SurvivalModel の 3 つの約束を、使う側である domain.go に書きます。
// SalesModel は興行収入を予測する約束。
type SalesModel interface {
PredictSales(movie Movie) (float64, error)
}
// SurvivalModel は生存を予測する約束。
type SurvivalModel interface {
Survives(passenger Passenger) (bool, error)
}
// ModelStore は学習済みモデルの置き場の約束。
// 読み込めなければ ErrModelNotFound を包んだエラーを返す。
type ModelStore interface {
LoadSalesModel() (SalesModel, error)
LoadSurvivalModel() (SurvivalModel, error)
}
メソッドは 1〜2 個です。Go では小さいインターフェースが好まれます。標準ライブラリの io.Writer がメソッド 1 つであるように、約束が小さいほど差し替えやすくなります。この章のテストでも、ModelStore が 2 メソッドしかないおかげで、スタブが 10 行で書けます。
インサイドアウトで進める¶
作る順番は Java 版と同じく内側からです。ドメイン(値とモデルの約束)→ サービス → 置き場 → API の順に、それぞれテストを先に書きます。API から作ると、まだ無いサービスをモックで埋めることになり、モックの形が設計を決めてしまいます。
15.3 TODO リストの作成¶
## ドメインとサービス
- [ ] 映画・乗客の特徴量を型で表す
- [ ] モデルと置き場の約束をインターフェースで書く
- [ ] モデルが無いことを ErrModelNotFound で表す
- [ ] サービスが置き場からモデルを読んで予測する
- [ ] ヘルスチェックがモデルごとの状態を返す
## 置き場
- [ ] 第 7 章の線形回帰を JSON で保存・読み込みする
- [ ] 第 8 章のパイプラインを gob で保存・読み込みする(第 8 章の SavePipeline)
- [ ] ファイルが無ければ ErrModelNotFound を返す
## API
- [ ] POST /cinema/sales が興行収入を返す
- [ ] POST /survived が生存を返す
- [ ] 年齢と乗船港を省略できる
- [ ] 入力が不正なら 422 と理由の一覧を返す
- [ ] JSON として読めなければ 422 を返し、内部の型名を漏らさない
- [ ] モデルが無ければ 503 を返す
- [ ] GET /health がモデルごとの状態を返す
- [ ] 知らないパスは 404、許していないメソッドは 405
## 実データ
- [ ] 第 7・8 章と同じ条件で学習して保存する
- [ ] 学習したモデルで API が動く(統合テスト)
- [ ] go run ./cmd/chapters chapter15 でサーバーが起動する
15.4 ドメインとサービスを作る¶
値を型にする¶
要求の JSON をそのままモデルに渡さず、いったんドメインの型にします。
// Movie は映画の特徴量。SNS の評判 2 種類・主演の人気・原作の有無(0 か 1)。
type Movie struct {
SNS1 float64
SNS2 float64
Actor float64
Original int
}
// Passenger は乗客の特徴量。空文字列は欠損値として前処理に任せる。
type Passenger struct {
Pclass string
Sex string
Age string
SibSp string
Parch string
Fare string
Embarked string
}
Movie の型は数値ですが、Passenger はすべて文字列です。第 8 章のパイプラインが受け取るのは chapter02.Row(セルの文字列)で、欠損値を空文字列で表すからです。年齢が分からない乗客の予測を受け付けるには、「値が無い」を型の中に残したまま前処理まで運ぶ必要があります。Java 版は OptionalDouble で表しましたが、Go には Optional がありません。ポインタ(*float64)を使う手もありますが、行き先がセルの文字列である以上、ここで文字列にしてしまうのがいちばん素直です。
ドメインの型は、自分を第 7・8 章の入力に変換する方法を知っています。
// Features は第 7 章のモデルに渡す特徴量にする。
func (m Movie) Features() (chapter02.Features, error) {
return chapter02.NewFeatures(
chapter07.CinemaFeatures,
[]float64{m.SNS1, m.SNS2, m.Actor, float64(m.Original)},
)
}
// Row は第 8 章のパイプラインに渡す行にする。
func (p Passenger) Row() (chapter02.Row, error) {
return chapter08.Passenger(p.Pclass, p.Sex, p.Age, p.SibSp, p.Parch, p.Fare, p.Embarked)
}
番兵のエラーで「モデルが無い」を表す¶
Java 版は検査例外 ModelNotFoundException を使い、失敗の可能性が throws として宣言に現れるようにしました。Go に例外はありません。エラーは戻り値で、種類の判別には番兵のエラー(sentinel error)を使います。
// ErrModelNotFound はモデルを読み込めないことを表す番兵のエラー。
// 呼び出し側は errors.Is で判別し、API では 503 に変える。
var ErrModelNotFound = errors.New("モデルがありません")
// ModelNotFound は、どのモデルが無いのかを添えたエラーを作る。
func ModelNotFound(name string) error {
return fmt.Errorf("%w: %s", ErrModelNotFound, name)
}
%w が肝心です。%v で書くとただの文字列になりますが、%w で包むと、包まれたエラーを errors.Is でたどれます。
err := ModelNotFound("cinema")
errors.Is(err, ErrModelNotFound) // true
err.Error() // "モデルがありません: cinema"
Java 版と比べると、得たものと失ったものがはっきりします。得たのは、関数型との相性です。Java 版はヘルスチェックで「検査例外を投げる関数」を渡せず、自前の関数型インターフェース Loader を用意する羽目になりましたが、Go では error がただの戻り値なので、そのまま関数に渡せます。失ったのは、コンパイラの強制です。throws に相当するものが無いので、どのエラーが返りうるかは実装を読むか、ドキュメントのコメントを読むしかありません。だからインターフェースのコメントに「読み込めなければ ErrModelNotFound を包んだエラーを返す」と書いて、約束の一部にします。
既存のモデルをアダプターで約束に合わせる¶
第 7・8 章のモデルには手を入れません。約束に合わせるのはアダプターの役割です。
// linearSalesModel は第 7 章の線形回帰のモデルを SalesModel の約束に合わせる。
type linearSalesModel struct {
model chapter07.LinearModel
}
func (a linearSalesModel) PredictSales(movie Movie) (float64, error) {
features, err := movie.Features()
if err != nil {
return 0, err
}
return a.model.Predict(features)
}
この型は小文字で始まる非公開の型です。パッケージの外に見せる必要はありません。外から見えるのは SalesModel という約束だけです。pipelineSurvivalModel も同じ形で、第 8 章の FittedPipeline を包み、1 行の表を作って予測し、chapter08.Survived(1)と比べて bool にします。
サービスは HTTP を知らない¶
// PredictionService は置き場からモデルを読み込んで予測する。HTTP には依存しない。
type PredictionService struct {
store ModelStore
}
// NewPredictionService は置き場を差し込んだサービスを作る。
func NewPredictionService(store ModelStore) *PredictionService {
return &PredictionService{store: store}
}
// PredictSales は映画の興行収入を予測する。
func (s *PredictionService) PredictSales(movie Movie) (float64, error) {
model, err := s.store.LoadSalesModel()
if err != nil {
return 0, err
}
return model.PredictSales(movie)
}
ヘルスチェックは、モデルごとに読み込めるかどうかを返します。
// ModelHealth はモデルの名前と、読み込めるかどうか。map ではなくスライスにして並びを固定する。
type ModelHealth struct {
Name string
Ready bool
}
// Health はモデルごとに読み込めるかどうかを返す。
func (s *PredictionService) Health() []ModelHealth {
_, salesErr := s.store.LoadSalesModel()
_, survivalErr := s.store.LoadSurvivalModel()
return []ModelHealth{
{Name: SalesModelName, Ready: salesErr == nil},
{Name: SurvivalModelName, Ready: survivalErr == nil},
}
}
戻り値を map[string]bool ではなくスライスにしたのは、Go の map は反復順がランダムだからです(第 7 章で係数を並べるときにも同じ判断をしました)。Java 版は LinkedHashMap で並びを保てますが、Go の map にその選択肢はありません。表示や繰り返しで並びが要るなら、スライスで持つのが Go の作法です。JSON にするときだけ map に直します(JSON のオブジェクトのキーは encoding/json が並べ替えてくれます)。
15.5 モデルを保存して読み込む¶
2 つのモデル、2 つの保存形式¶
// salesModelFile は興行収入のモデルのファイル。第 7 章のモデルは数値だけなので JSON にする。
func (s *FileModelStore) salesModelFile() string {
return filepath.Join(s.ModelDir, SalesModelName+".json")
}
// survivalModelFile は生存予測のモデルのファイル。前処理を含むので第 8 章と同じ gob にする。
func (s *FileModelStore) survivalModelFile() string {
return filepath.Join(s.ModelDir, SurvivalModelName+".gob")
}
線形回帰のモデル(chapter07.LinearModel)は切片・列名・係数だけなので、encoding/json でそのまま書けます。公開フィールドだけが JSON になるので、モデルのフィールドが大文字で始まっていることが効いてきます。
// SaveSalesModel は線形回帰のモデルを JSON で保存する。
func (s *FileModelStore) SaveSalesModel(model chapter07.LinearModel) error {
if err := os.MkdirAll(s.ModelDir, 0o750); err != nil {
return fmt.Errorf("保存先を作れません: %w", err)
}
encoded, err := json.Marshal(model)
if err != nil {
return fmt.Errorf("モデルを JSON にできません: %w", err)
}
if err := os.WriteFile(s.salesModelFile(), encoded, 0o600); err != nil {
return fmt.Errorf("モデルを保存できません: %w", err)
}
return nil
}
生存予測のパイプラインは、前処理(欠損値の補完で求めた中央値・最頻値、ダミー変数の列)と決定木を含みます。JSON にするとインターフェースの具体型が失われるので、第 8 章で作った SavePipeline(encoding/gob と gob.Register)をそのまま使います。保存の形式はモデルの中身で選ぶ、ということです。
ファイルが無いことをエラーの種類に変える¶
// LoadSalesModel は興行収入のモデルを読み込む。ファイルが無ければ ErrModelNotFound を返す。
func (s *FileModelStore) LoadSalesModel() (SalesModel, error) {
contents, err := os.ReadFile(s.salesModelFile())
if err != nil {
if os.IsNotExist(err) {
return nil, ModelNotFound(SalesModelName)
}
return nil, fmt.Errorf("モデルを読み込めません: %w", err)
}
var model chapter07.LinearModel
if err := json.Unmarshal(contents, &model); err != nil {
return nil, fmt.Errorf("モデルの JSON が壊れています: %w", err)
}
return linearSalesModel{model: model}, nil
}
「ファイルが無い」と「読めるが壊れている」を区別します。前者はモデルをまだ学習していないだけなので 503(一時的に使えない)、後者は直さないと動かないので 500 にします。os.IsNotExist で判別し、それ以外は %w で包んで上へ返します。
この分岐を書かずに全部 ErrModelNotFound にしてしまうと、ディスクの権限の問題が「モデルが無い」と表示され、原因を探せなくなります。エラーは、呼び出し側が判断に使える粒度で分けます。
15.6 net/http でエンドポイントを作る¶
httptest でサーバーを起動せずにテストする¶
API のテストには net/http/httptest を使います。ポートを開かずにハンドラーを直接呼べます。
// call は API を呼んで、応答の状態コードと本文を返す。
func call(t *testing.T, handler http.Handler, method, path, body string) (int, string) {
t.Helper()
request := httptest.NewRequest(method, path, strings.NewReader(body))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
return recorder.Code, recorder.Body.String()
}
置き場はスタブに差し替えます。学習データが無くても API の振る舞いを全部テストできます。
// stubStore は置き場のスタブ。学習しなくても API の振る舞いを確かめられる。
type stubStore struct {
sales chapter15.SalesModel
survival chapter15.SurvivalModel
}
func (s stubStore) LoadSalesModel() (chapter15.SalesModel, error) {
if s.sales == nil {
return nil, chapter15.ModelNotFound(chapter15.SalesModelName)
}
return s.sales, nil
}
stubStore はどこにも「ModelStore を実装します」と書いていません。メソッドの形が合っているので実装になります。テスト用のスタブを、本体のパッケージに何も足さずに _test.go の中だけで作れるのは、Go の構造的なインターフェースの利点です。
Go 1.22 のルーティング¶
// NewHandler はサービスを使う API を作る。listen はしない。
func NewHandler(service *PredictionService) http.Handler {
mux := http.NewServeMux()
// Go 1.22 以降のルーティングは、メソッドとパスをまとめて書ける。
// 登録していないメソッドには 405 が、登録していないパスには 404 が自動で返る。
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
health(w, service)
})
mux.HandleFunc("POST /cinema/sales", func(w http.ResponseWriter, r *http.Request) {
predictSales(w, r, service)
})
mux.HandleFunc("POST /survived", func(w http.ResponseWriter, r *http.Request) {
predictSurvival(w, r, service)
})
return mux
}
NewHandler は http.Handler を返すだけで、ListenAndServe は呼びません。組み立てと起動を分けると、テストからは組み立てだけを使えます。Java 版が Javalin.create(...) を返して start しなかったのと同じ考え方です。
Go 1.22 より前の ServeMux はパスしか見なかったので、メソッドの判別はハンドラーの中で if r.Method != http.MethodPost と書くか、フレームワークに頼るしかありませんでした。今はパターンに書けて、405 は ServeMux が返します。確かめておきます。
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:8015/unknown
404
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:8015/cinema/sales
405
(curl は本文が無ければ GET で送るので、POST だけを登録した /cinema/sales は 405 になります。)
要求の型はポインタで受ける¶
Go の数値の既定値は 0 で、文字列は空文字列です。そのまま JSON をデコードすると、「値が 0 だった」と「JSON に値が無かった」を区別できません。Java 版が int ではなく Integer で受けて null を残したのと同じ問題です。Go ではポインタで受けます。
// MovieRequest は興行収入の予測の要求。
// Go の数値は 0 が既定値なので、JSON に値が無かったことを表すためにポインタで受ける。
type MovieRequest struct {
SNS1 *float64 `json:"sns1"`
SNS2 *float64 `json:"sns2"`
Actor *float64 `json:"actor"`
Original *int `json:"original"`
}
バッククォートの中はフィールドのタグで、JSON のキーと Go のフィールド名の対応を書きます。SibSp を sib_sp として受けるのもタグの仕事です(Java 版は @JsonProperty でした)。
検証の結果を型で表す¶
Java 版は sealed interface の Validated を作り、switch のパターンマッチで正しい場合と不正な場合を分けました。Go には sealed interface がありません。第 3 章では非公開メソッドを持つインターフェースで判別共用体を代用しましたが、ここでは構造体 1 つで足ります。
// Validated は検証の結果。Go には sealed interface が無いので、
// 理由の一覧が空かどうかで「正しい」「不正」を表す構造体にする。
type Validated[T any] struct {
Value T
Errors []string
}
// Valid は理由が 1 つも無いかどうか。
func (v Validated[T]) Valid() bool {
return len(v.Errors) == 0
}
判別共用体との違いは、不正なのに Value を読めてしまうことです(読むとゼロ値が返ります)。Java 版の switch は、Invalid の枝で値を取り出そうとするとコンパイルが通りません。Go ではその保証が無いので、呼び出し側の規律で守ります。第 3 章の決定木のように「型スイッチの既定の分岐でエラーを返す」手も使えますが、検証結果のように分岐が 2 つで、しかも片方が「値がある」だけの場合は、構造体のほうが読みやすくなります。言語に無い道具の代用は、毎回同じ答えにしなくてよい、ということです。
検証の規則はジェネリクスで書きます。
// notNegative は値が負であれば理由を返す。値が無ければ何も言わない(required の担当)。
func notNegative[N float64 | int](field string, value *N) string {
if value == nil || *value >= 0 {
return ""
}
return field + " は 0 以上にしてください"
}
// oneOf は値が選択肢に無ければ理由を返す。値が無ければ何も言わない。
func oneOf[E comparable](field string, value *E, allowed []E) string {
if value == nil || slices.Contains(allowed, *value) {
return ""
}
...
}
[N float64 | int] は型の合併を型引数の制約に書いたものです。comparable は == で比べられる型という組み込みの制約で、slices.Contains がそれを要求します。Java 版の Checks は Object と Number で受けていましたが、Go のジェネリクスなら「数値だけ」「比較できる型だけ」と絞れます。
検証は、規則を並べて空文字列でないものを集めるだけです。
// Validate は検証して、正しければ映画の特徴量にする。
func (r MovieRequest) Validate() (Validated[Movie], error) {
return validate(
[]string{
required("sns1", r.SNS1 != nil),
required("sns2", r.SNS2 != nil),
required("actor", r.Actor != nil),
required("original", r.Original != nil),
notNegative("sns1", r.SNS1),
notNegative("sns2", r.SNS2),
notNegative("actor", r.Actor),
oneOf("original", r.Original, originalValues),
},
func() (Movie, error) {
return Movie{SNS1: *r.SNS1, SNS2: *r.SNS2, Actor: *r.Actor, Original: *r.Original}, nil
},
)
}
必須の検査を先に全部並べ、値を組み立てる関数は理由が 1 つも無いときだけ呼ばれます。だから *r.SNS1 のポインタの参照外しが安全です。ここを間違えると nil の参照外しでパニックになるので、順番が設計です。
不正な入力では、理由がまとめて返ります。
$ curl -s -X POST localhost:8015/cinema/sales -d '{"sns1": 100}'
{"detail":["sns2 は必須です","actor は必須です","original は必須です"]}
$ curl -s -X POST localhost:8015/cinema/sales \
-d '{"sns1": -1, "sns2": 2000, "actor": 300, "original": 2}'
{"detail":["sns1 は 0 以上にしてください","original は 0、1 のどれかにしてください"]}
JSON の失敗を 422 に変え、内部の型名を漏らさない¶
Java 版は Javalin の例外ハンドラーで Jackson の例外を 422 に変えました。Go では、デコードの戻り値をその場で見ます。
// decode は本文を JSON として読む。読めなければ 422 を返して false を返す。
func decode(w http.ResponseWriter, r *http.Request, request any) bool {
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(request); err != nil {
writeJSON(w, http.StatusUnprocessableEntity, validationErrorResponse{Detail: []string{invalidJSON}})
return false
}
// 本文に 2 つ目の JSON が続いていないかを確かめる。
if err := decoder.Decode(new(json.RawMessage)); !errors.Is(err, io.EOF) {
writeJSON(w, http.StatusUnprocessableEntity, validationErrorResponse{Detail: []string{invalidJSON}})
return false
}
return true
}
2 回目の Decode は、Go の JSON デコーダーの性質への対処です。json.NewDecoder はストリームを読むので、{"sns1":1}{"sns1":2} のように JSON が 2 つ続いていても 1 つ目だけ読んで成功します。「本文の残りが io.EOF であること」を確かめて、初めて「本文はちょうど 1 つの JSON だった」と言えます。
デコードのエラーのメッセージには json: cannot unmarshal string into Go struct field MovieRequest.sns1 of type float64 のように内部の型名が入ります。そのまま返すと実装の詳細が外に漏れるので、決まった文言に置き換えます。テストでも確かめます。
if strings.Contains(response, "chapter15") || strings.Contains(response, "float64") {
t.Errorf("本文 = %s, want 内部の型名を含まない", response)
}
$ curl -s -X POST localhost:8015/cinema/sales -d '{"sns1": "たくさん"}'
{"detail":["JSON の形式または値の型が正しくありません"]}
エラーをステータスコードに変える¶
// writeFailure はドメインのエラーを HTTP のステータスコードに変える。
func writeFailure(w http.ResponseWriter, err error) {
if errors.Is(err, ErrModelNotFound) {
writeJSON(w, http.StatusServiceUnavailable, errorResponse{Detail: err.Error()})
return
}
writeJSON(w, http.StatusInternalServerError, errorResponse{Detail: "予測できませんでした"})
}
errors.Is で番兵のエラーを判別します。ここが、fmt.Errorf で %w を使った理由です。モデルが無いときは名前も返します(どちらのモデルが無いかは運用で知りたい情報です)。それ以外は 500 にして、中身は返しません。
$ curl -s -X POST localhost:8015/cinema/sales -d '{...}' # モデルを学習する前
{"detail":"モデルがありません: cinema"}
Java 版は例外ハンドラーを 1 か所に登録して、ハンドラーの中では例外を投げるだけでした。Go はハンドラーごとに if err != nil を書きます。行数は増えますが、変換が起きる場所がコードに見えます。フレームワークの例外ハンドラーは、登録し忘れると 500 になって初めて気づく(Java 版でまさにそれが起きました)のに対し、Go では書き忘れがコンパイルエラーか、すぐ見つかる書き漏れになります。
応答を書く部分も自分で書きます。
// writeJSON は応答を JSON で書く。
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
// ここまで来ると状態コードは送信済みなので、記録するしかない。
log.Printf("応答を書けません: %v", err)
}
}
ヘッダー → 状態コード → 本文の順です。WriteHeader の後にヘッダーを足しても効きません。そして本文を書いている途中の失敗は、もう応答を変えられないので記録するしかありません。errcheck(第 5 章)が Encode の戻り値を見ろと言ってくるので、この判断はコードに残ります。
15.7 実データで学習したモデルで動かす¶
学習と保存¶
第 7・8 章とまったく同じ条件(testSize 0.2、シード 0、深さ 5、balanced の重み)で学習します。
// TrainAndSaveModels は第 7・8 章と同じ条件でモデルを学習し、置き場に保存する。
func TrainAndSaveModels(dataDir string, store *FileModelStore) error {
cinema, err := chapter07.PrepareCinema(filepath.Join(dataDir, "cinema.csv"), testSize, seed)
if err != nil {
return err
}
salesModel, err := chapter07.Fit(cinema.XTrain, cinema.TTrain)
if err != nil {
return err
}
if err := store.SaveSalesModel(salesModel); err != nil {
return err
}
...
}
統合テスト¶
スタブのテストとは別に、実データで学習したモデルをつなぐテストを書きます。データが無ければスキップします。
// trainedStore は実データで学習して一時ディレクトリに保存した置き場を返す。
func trainedStore(t *testing.T) *chapter15.FileModelStore {
t.Helper()
store := chapter15.NewFileModelStore(t.TempDir())
if err := chapter15.TrainAndSaveModels(requireData(t), store); err != nil {
t.Fatalf("TrainAndSaveModels() でエラー: %v", err)
}
return store
}
t.TempDir() はテストごとの一時ディレクトリを作り、テストが終わると消します。保存先をテストから差し替えられるように、FileModelStore がディレクトリを持つ設計にしておいたのが効きます。
ここでは httptest.NewServer を使って、実際にポートを開いて HTTP で呼びます。httptest.NewRequest がハンドラーを直接呼ぶのに対し、こちらはネットワーク越しなので、http.Post のクライアント側まで含めて確かめられます。
handler := chapter15.NewHandler(chapter15.NewPredictionService(trainedStore(t)))
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
response, err := http.Post(
server.URL+"/cinema/sales",
"application/json",
strings.NewReader(`{"sns1": 100, "sns2": 2000, "actor": 300, "original": 1}`),
)
期待値は、書く前に実際に動かして測りました。最初は Java 版の値を書いてテストが落ち、Go の分け方の違い(第 2 章)がここまで効いていることを確認できました。
--- FAIL: TestTrainedModels/保存したモデルを読み込んで予測できる (0.14s)
trainedmodels_test.go:56: 興行収入 = 7984.316555463484, want 8306.043795298237
学習してサーバーを起動する¶
server := &http.Server{
Addr: fmt.Sprintf("127.0.0.1:%d", Port),
Handler: NewHandler(service),
ReadHeaderTimeout: readHeaderTimeout,
}
return server.ListenAndServe()
http.ListenAndServe(addr, handler) という 1 行の書き方もありますが、それだと時間切れの設定が既定のまま(無制限)になります。ヘッダーを少しずつ送り続けて接続を占有する Slowloris という攻撃に対して、ReadHeaderTimeout は最小限の備えです。gosec の G112 が指摘する項目でもあります(第 5 章のとおり golangci-lint の既定では gosec は動きませんが、指摘される理由が分かっていれば最初から書けます)。
実行するとモデルを学習・保存してから待ち受けます。
$ go run ./cmd/chapters chapter15
モデル cinema: true
モデル survived: true
http://localhost:8015 で待ち受けます
別の端末から呼びます。
$ curl -s localhost:8015/health
{"status":"ok","models":{"cinema":true,"survived":true}}
$ curl -s -X POST localhost:8015/cinema/sales \
-d '{"sns1": 100, "sns2": 2000, "actor": 300, "original": 1}'
{"sales":7984.316555463484}
$ curl -s -X POST localhost:8015/survived \
-d '{"pclass": 1, "sex": "female", "sib_sp": 0, "parch": 0, "fare": 80}'
{"survived":true}
年齢と乗船港を省略しても動きます。空文字列(欠損値)のまま第 8 章のパイプラインに渡り、訓練データから求めた中央値・最頻値で補完されるからです。前処理をモデルと一緒に保存しておくと、予測するときも学習のときと同じ手順が適用されます。第 8 章でパイプラインごと保存した理由が、ここで回収されます。
テストの実行結果¶
$ go test ./internal/chapter15/... -cover
ok .../internal/chapter15 1.342s coverage: 57.6% of statements
$ ML_DATA_DIR=$PWD/../data/sukkiri-ml go test ./internal/chapter15/... -cover
ok .../internal/chapter15 1.635s coverage: 77.7% of statements
学習データが無くても API のテストは全部走ります(第 4 章で見たとおり、go test はパッケージのディレクトリで走るので、実データを使うには ML_DATA_DIR を渡します)。スタブのテストと統合テストを分けておくと、CI ではデータ無しで API の振る舞いを守れます。
15.8 まとめ¶
この章では、第 7・8 章のモデルを標準ライブラリだけで HTTP API にしました。
- 層の分離 — ドメイン・アプリケーション・インフラ・プレゼンテーションをファイルで分け、依存の向きを内側にそろえた。インターフェースは使う側(ドメイン)に置き、実装は「実装します」と宣言しない
- 番兵のエラー — 「モデルが無い」を
ErrModelNotFoundで表し、fmt.Errorfの%wで包んでerrors.Isで判別した。Java 版の検査例外と違ってコンパイラは強制しないので、インターフェースのコメントを約束の一部にした - アダプター — 第 7・8 章のモデルには手を入れず、非公開の型で約束に合わせた。保存形式はモデルの中身で選び、数値だけの線形回帰は JSON、前処理を含むパイプラインは第 8 章の gob にした
- 入力の検証 — 数値の既定値が 0 である Go では、欠落を表すためにポインタで受ける。検証結果は sealed interface ではなく構造体 1 つで表し、規則はジェネリクス(
float64 | int・comparable)で書いた - フレームワーク無しの API — Go 1.22 のルーティングで 404・405 を
ServeMuxに任せ、JSON のデコード・検証・エラーの変換・応答の書き出しは自分で書いた。ストリームのデコーダーが 2 つ目の JSON を見逃すこと、エラーのメッセージに内部の型名が入ることは、書いてみて初めて分かる - テストの粒度 — スタブで学習データ無しに全部のエンドポイントを確かめ、実データの統合テストは
httptest.NewServerでネットワーク越しに呼び、データが無ければスキップした
シリーズの振り返り¶
第 1 章の「20 代ならきのこ派」という手書きのルールから、Go 版でも次の順に進んできました。
| 部 | 章 | 学んだこと | Go 版で効いた言語の性質 |
|---|---|---|---|
| 第 1 部 | 第 1〜3 章 | データを読み込み、前処理し、決定木をデータから学ばせる基本サイクル | 構造体と map[string]string、error の戻り値、ジェネリクス、非公開メソッドのインターフェース |
| 第 2 部 | 第 4〜6 章 | その過程を支えるバージョン管理・静的解析・CI | Go Modules、gofmt・go vet・golangci-lint、go test -cover |
| 第 3 部 | 第 7〜9 章 | 回帰と、現実のデータの前処理 | gonum の mat、stat との突き合わせ、golang.org/x/text |
| 第 4 部 | 第 10〜12 章 | 複数のモデルの比較と評価 | インターフェースによる共通化、スライスとクロージャ |
| 第 5 部 | 第 13〜15 章 | 正解の無いデータの扱いと、モデルを API として届けるまで | encoding/gob、encoding/json、net/http |
Go 版の一貫した特徴は、依存の少なさでした。使った外部ライブラリは gonum と golang.org/x/text の 2 つだけで、決定木・ランダムフォレスト・ロジスティック回帰・K-means・正則化は自作が最終実装です(ADR 008)。ライブラリが揃っている Python 版や、Tribuo・ML.NET と突き合わせられた Java 版・C# 版と比べると、「ライブラリが無いときにどう作るか」を示す章が多くなりました。これは TypeScript 版 と同じ立場です。
そして、Go にはほかの言語版が当たり前に使っていた道具がありません。例外も、判別共用体も、Optional も、継承もありません。無いものは毎回、別の道具で代用してきました。例外は error の戻り値と番兵のエラーで、判別共用体は非公開メソッドのインターフェース(第 3 章)と構造体(第 15 章)で、Optional はポインタと空文字列で。同じ問題に毎回同じ代用を当てはめないことが、Go で読みやすさを保つコツだと分かりました。
次の言語へ¶
Go 版で、第 2 波の 4 言語目が終わりました。残るは Rust です(執筆計画)。
Go 版で最後まで残った不一致は、乱数でした。math/rand は Java の java.util.Random とも .NET の Random とも乱数列が違うので、訓練データとテストデータの分かれ方が違い、正解率も係数も、ほかの言語版と一致しません(件数だけは一致します)。同じアルゴリズムを同じデータに適用しても、擬似乱数の実装が違えば結果は揃わない。多言語で同じ題材を書いてみて、いちばんはっきり見えた事実でした。数値の再現性を求めるなら、乱数の生成方法までそろえる必要があります。