証券番号の発行元をニューラルネットワークで推論した (scikit-learn)

遂に機械学習を実務で使うことに成功した

遂に機械学習を実務で使うことに成功した。しかも自分でやっているサービスで実現できて、とても嬉しいので記事にする。

背景

私が運営している海上コンテナ追跡サービス「MonCargo」では、顧客に船荷証券番号 (以下B/L番号) を登録してもらう必要がある。その際、どの船会社なのかを毎回選択する必要があるのだが、これが地味に手間になっているため自動判別してほしいという要望を顧客から頂いていた。

image

B/L番号にはパターンがあり、例えば ONE という船会社であれば ONEY という接頭辞が付いているので、この機能は割と簡単にできると思っていた。

ところが、実際にやってみると意外とパターンが多くて、例えば ONE のB/L番号では上述のパターン以外にも YGND+7桁の数字GOAD+8桁の数字 というパターンも存在する。さらに、19個の船会社があり、かつ B/L番号だけではなく予約番号でもこの自動判別を使いたいので、最低でも59種類のパターン分類を行う必要があった。しかも、パターン分類も複雑で、例えば 数字8桁 というパターンが複数の船会社で存在し、これは最初の数字が特定の数字で始まっているかどうかで判別しなければならなかった。

最初は気合でやろうと思っていたが、ふと機械学習を使えば良いのではと思った。なぜなら

  • 大量の文字パターンの条件分岐を完璧に実装するのは実質的に無理がある。上述の数字だけのパターンなどは、かなり注意深く見ないと分からない。
  • 仮に気合で実装したしても、新しいパターンが追加される度にプログラムを更新するのは負債だし、そこにリソースを割くべきではない。
  • 既にユーザーが入力してくれたデータがそれなりの量(約2,000件)あるので、機械学習できそうな気がする。
  • 何か新しいことをしたい。「機械学習使ってます」って言ってドヤりたい。

そこで、エンジニア人生で何度も挫折した機械学習を使って推論することを決意した。

設計

要件としては、推論の速度が肝心だと思った。入力支援のための機能で時間がかかってしまっては本末転倒だ。そのため、最初は学習済みのモデルをブラウザで動かす方針でいこうと思った。当然、 JavaScript で書かれた機械学習ライブラリを使えば良いと思い、 ml.js という組織が出しているパッケージを使おうと思った。だが、動かしてみると精度が低かったりエラーでうまく出力できなかったりしたので、この方法は諦めた。

そこで、前から気になっていた 機械学習 + Rust + WebAssembly での実装を検討した。 rust-ml/linfa: A Rust machine learning framework. 等、有望なライブラリがあり、面白そうだ。だが現実は厳しく、そもそも Rust もそこまで書けないし、 WebAssembly 未経験だし、機械学習もちょろっとしかやったことない人間なので、いまいち実装方法や WebAssembly での運用がよく分からず見送った。

結局、長いものに巻かれる形で、 Python + Scikit-Learn を採用した。 MonCargo のサーバーサイドは Node.js (TypeScript) で動いているので、 Node.js と Python 間はマイクロサービス的に HTTP で通信し、ブラウザからは直接叩かない形にした。なお Python のフレームワークとしては前に業務委託先で使っていた FastAPI を採用し、良い感じだった。

実装

この辺は雑なメモになります。

  • 学習データ(X)は文字列を UTF-8 でエンコードして数値型にした
  • 正解ラベル(Y)はカテゴリ変数にした。 Pandas 使うとすっきり書けた
  • ロジスティクス回帰、ランダムフォレスト、SVM なども試したが、ニューラルネットワークが一番精度良かった
    • 95%くらいで正解、しかも残り5%は人間でも判別が難しくて仕方ないもの
    • やってることはめっちゃ単純なので、ニューラルネットワークの過学習しやすいという面が良かったのかもしれない
    • 隠れ層を増やしたら精度が上がった
    • なんか Warning が出たので max_iter めちゃ大きくして解決した
  • train_test_split でテストして、結果を逐一スプレッドシートに吐き出して目視で確認した
    • この辺を丁寧にやったのが良かったと思う
    • 単純なスコアとか混同行列見てるだけだと分からないので、生データ見るの大事

ぶっちゃけパターンが決まっている番号から推論するだけなので、できて当たり前なのかもしれないが、パラメーターを調節していくと段々精度が上がっていき、たいへん興奮した。

まず前処理として、文字列を数値として扱えるようにしたり、正解ラベルをカテゴリ変数にする。具体的には本番データから BIツール(Metabase)で吐き出した CSV を読み取って pandas で変換している。ついでに Web API で利用するためのデータもここで書き出した。コードとしてはこんな感じ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import pandas as pd

df = pd.read_csv("./data/data_for_machine_learning_raw.csv", delimiter=",")

df = df[["website_type", "tracking_type", "tracking_number"]]
df["tracking_number"] = df["tracking_number"].apply(lambda x: x.strip())  # type:ignore

df["Y"] = df.apply(
    lambda row: row["website_type"] + "__" + row["tracking_type"], axis=1
)

# カテゴリ変数に変換
df["Y"] = pd.factorize(df["Y"].astype("category"))[0]

for i in range(0, 20):
    df["X" + str(i)] = df.apply(
        lambda row: 0
        if len(row["tracking_number"]) <= i
        else ord(row["tracking_number"][i]),
        axis=1,
    )

df.to_csv("./data/prepared.csv", index=False)

df_unique = df.drop_duplicates(subset=["Y"])  # type: ignore
df_unique[["website_type", "tracking_type", "Y"]].to_json(
    "./data/websites.json", indent=2, orient="records"
)

次にモデルのパラメーターをチューニングした。といってもニューラルネットワークの隠れ層のセット ( hidden_layer_sizes ) と精度を原始的なループで調べただけ。他に learning_ratesolver もいじってみたが、 hidden_layer_sizes が一番影響が大きかった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from typing import List, cast

import pandas as pd
from sklearn import metrics
from sklearn.model_selection import train_test_split
from sklearn.neural_network import MLPClassifier

df = pd.read_csv(
    "./data/prepared.csv",
    delimiter=",",
)

X = df
y = df["Y"]

splitted = train_test_split(X, y, test_size=0.2)
X_train, X_test, y_train, y_test = cast(List[pd.DataFrame], splitted)

for i in range(100, 200):
    for ii in range(100, 200):
        clf = MLPClassifier(
            hidden_layer_sizes=(i, ii),
            learning_rate="adaptive",
            solver="sgd",
            max_iter=4000,
        )

        clf.fit(X_train.iloc[:, 4:24], y_train)

        predicted = clf.predict(X_test.iloc[:, 4:24])
        accuracy = metrics.accuracy_score(y_test, predicted)

        print(f"classifier {clf}:")
        print(f"Accuracy: {accuracy}")

結果的に実装したモデルはこれだ。

1
2
3
4
5
6
7
8
from sklearn.neural_network import MLPClassifier

clf = MLPClassifier(
    hidden_layer_sizes=(100, 100),
    learning_rate="adaptive",
    solver="lbfgs",
    max_iter=10000,
)

scikit-learn が使いやすかった。 JS とか Rust のライブラリだと、もちろん機械学習のロジックは実装されているが、周辺の可視化とかテスト周りがどうやって良いか分からない感じがした。まあこれは、自分が長いこと(趣味で) Python を触っていたからかもしれないが…

インフラ構築とリリース

弊社のサービスはコストを抑えるため、 1台の EC2 t3.micro (Debian GNU/Linux) の上で稼働している。 アプリケーションと機械学習という異なるサービスを運用する場合、サーバーを分けたりサーバーレスにするのがセオリーだと思うが、コストを節約したかったしサーバーレスはコールドスタートが許されないので、同一のサーバーに同居させることにした。 構成管理は Ansible を使っているため、 Node.js と Python を同居させることにそこまで抵抗はなかった。

Python に関して、 Debian GNU/Linux で Python のインストールが死ぬほど面倒くさそうだったので、 pyinstaller で実行可能単一ファイルのバイナリにして、サーバーに置くことにした。これは想像以上に良くて、バイナリを置いて systemd いじれば構成管理もデプロイも完了するのは余りにもクリーンかつ楽だった。Node.js 側のアプリケーションも pkg でバイナリにしようかと思うくらい、 pyinstaller の体験はよかった。一点、 Glibc のバージョン互換性で手こずったものの、 GitHub Actions で古めの Ubuntu でビルドすれば大丈夫だった。

なお、ブラウザ → Cloudfront → Node.js → Python と複数のサーバーを跨ぐリクエストをしているが、体感的に速度的な問題には全くならなかった。

UI

割と地味な機能ながら、使ってほしいので、このようにツールチップを出した。実はこれが一番面倒だった説はある。

image

総括

  • 明らかに、今年最も技術的に面白い開発だった
  • 「個別のロジックの実装が面倒だから、機械学習させる」という発想はめっちゃ良かった
    • LLM とか流行りの技術ではなく、古典的な機械学習というのも良い
  • 自社サービスでこういう実験的なことができて嬉しい
  • ログ見ると、ほぼ毎日使われているので、ユーザーに必要な機能だと実感できている
comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy