Core MLのmlmodelファイルフォーマット詳解
mlmodelとは
mlmodelは、Protocol Buffersをベースとするオープンなフォーマットで、モデル仕様はcoremltoolsリポジトリで公開されています。具体的には同リポジトリのmlmodel/format
配下にある.proto
ファイル群によって、Protocol Buffersとしてのmlmodelファイルフォーマットが定義されています。
coremltools 3.3では30個の.protoファイルによってmlmodelフォーマットが定義されている
mlmodelファイルフォーマットを理解するメリット
mlmodelというフォーマット自体の知識がなくともCore MLフレームワークやcoremltoolsを利用することは可能ですが、ここをおさえておくことはCore MLと関わる上で非常に有益です。まずはどんなメリットがあるのかを説明します。
Core MLの機能をより具体的に把握できる
coremltoolsで変換したモデルは最終的にmlmodelファイルとしてエクスポートされ、
Core MLフレームワークはmlmodelファイルをもとに処理を行います。
すなわち、モデルのアーキテクチャやパラメータ等、Core MLがその機能を遂行するにあたって必要な情報はすべてこのmlmodelファイルに集約されているわけです。別の見方をすれば、Core MLとして何ができて何ができないかはこのmlmodelファイルフォーマットの定義を見れば把握できる、といえます。
具体的な例を出しましょう。たとえばWWDCでCore MLの新機能が発表されたら、これらの定義群を見ることでより具体的に新機能の実態を把握することができます。WWDC 2019の「Core ML 3 Framework」セッションでは『100以上の新しいレイヤーがサポートされた』とアナウンスされましたが、さすがにセッション内で100ものレイヤーをすべて列挙し解説するわけにはいきません。またそれらはCore MLフレームワークのAPIリファレンスに載ることもありません(Core MLフレームワーク側からは意識する必要がないため)。
しかし、mlmodel/format
配下にあるNeuralNetwork.proto
を見れば、ニューラルネットワークにおいて利用可能なすべてのレイヤーを確認することができます[1]。
ConvolutionLayerParams convolution = 100;
PoolingLayerParams pooling = 120;
ActivationParams activation = 130;
InnerProductLayerParams innerProduct = 140;
EmbeddingLayerParams embedding = 150;
BatchnormLayerParams batchnorm = 160;
MeanVarianceNormalizeLayerParams mvn = 165;
L2NormalizeLayerParams l2normalize = 170;
...
コミット履歴から容易に「新たに追加されたレイヤーはどれなのか」を確認できるので、.proto
ファイルの読み方を知っていればWWDCで発表される新機能を具体的に把握できるようになります。
デバッグや最適化の際に役立つ
Core MLのiOS側での実装は基本的に非常に簡単です。開発者はモデルの中身について最小限のことだけ意識すればアプリに機能を組み込めるように設計されており、その最小限の情報は.mlmodel
ファイルをXcodeで開くと表示されます。
またCore MLフレームワークのMLModel
クラスのmodelDescription
プロパティからMLModelDescription
型でモデルの情報を得られますが、その内容は入力・出力の情報(MLFeatureDescription
)やメタデータ(モデルの作成者やライセンス、バージョン等)で、結局のところXcodeプレビューで確認できる情報と大差ありません。
しかし実は、mlmodelファイルからはXcodeプレビューやMLModelDescription
オブジェクトで得られるものよりも遥かに多くの情報を取得できます。というより、mlmodelはオープンなフォーマットなので、mlmodelが持つすべての情報にアクセスできます。
XcodeやCore MLフレームワークのAPIからは得られないmlmodelファイルの情報としては、ほんの一例を挙げると以下のようなものがあります。
- 画像分類モデルにおけるクラスラベルの一覧
- 重みの精度
画像分類モデルにおけるクラスラベルの一覧は「そのモデルを使うとどのようなクラスが認識できるのか」という重要な情報ですし、モデルのクォンタイズ[2]を行ってモデルサイズを小さくするかどうか検討する際に現在の重みの精度について知ることは重要です。
このように、mlmodelフォーマットとその取り扱い方を理解しておけば、Core MLモデルを扱う際のデバッグや最適化など開発における多くの局面で役立ちます。
.protoファイルの読み方
本章の冒頭で「mlmodelは、Protocol Buffersをベースとするフォーマット」であると述べました。Protocol Buffers(略称protobuf
)はGoogleが開発したシリアライズフォーマットで、フリーソフトウェアとしてオープンソースライセンスで公開されています。
protobuf
では.proto
ファイルにスキーマを定義し、これを用いてデータのシリアライズ・デシリアライズを行います。以下に.proto
の例を示します。
syntax = "proto3";
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
-
syntax
ではこの.proto
ファイルで使用するProtocol Bufferのバージョンを宣言します。proto3
はProtocol Buffer Version 3を表します。mlmodelは現在、proto3
を使用しています。 -
.proto
ではデータ構造は「メッセージ」(message
)として定義され、各メッセージにはデータを格納する「フィールド」(field
)の名前と型が定義されます。上の例でいうとSearchRequest
がメッセージで、query
,page_number
,result_per_page
という名前のフィールドを持っています。query
の型はstring
です。 -
= 1
のようにフィールドに付記されている数値は値を代入しているのではなく、「タグ」(tag
)と呼ばれ、各フィールドを識別するためのIDを表しています。
次のようにrepeated
とついているフィールドは任意の数だけ(ゼロを含む)保持できることを意味します。別の表現をすると、repeated
フィールドは動的なサイズの配列のようなものです。下の例でいうとresults
フィールドはrepeated
なので、任意の数のResult
型の値を保持することができます。
message SearchResponse {
repeated Result results = 1;
}
またmlmodelの.proto
でよく使われているのがoneof
です。oneof
は、それに続くフィールド群のうちどれかひとつのフィールドを持つことを表します。
message SampleMessage {
oneof test_oneof {
string name = 4;
SubMessage sub_message = 9;
}
}
coremltoolsとprotobuf
*_pb2
本書ではこれまでにModel_pb2
やFeatureTypes_pb2
といった型を何度も扱ってきました。これらのソースコードはcoremltools
リポジトリのcoremltools/proto
ディレクトリ配下に格納されています。
*_pb2.py
ファイルを開くと、ヘッダ部分に次のように書かれています:
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: Model.proto
これらの*_pb2.py
ファイル群は人手で書かれたものではなく、.proto
ファイルからprotobuf
コンパイラによって生成されたものであることを示しています。
.proto
ファイルのコンパイル
.proto
をコンパイルするとcoremltools/proto
にある*_pb2.py
ファイルと同じものが生成されるのか、実際に手元で試してみましょう。
protobuf
をインストールしておきます。
$ pip install -U protobuf
また、coremltools
リポジトリをgit clone
し、mlmodel/format
配下の*.proto
ファイルをひとつのディレクトリ配下にコピーしておきます。
$ cp -R coremltools/mlmodel/format/ ./proto
$ cd proto/
protoc
コマンドを用いてコンパイルを実行します。--python_out
オプションには生成されるPythonコードの出力先となるパスを、また引数にコンパイルする.proto
ファイル群を指定します。
$ protoc --python_out={出力先のパス} *.proto
これを実行すると、coremltools/proto
ディレクトリにある*_pb2.py
ファイルと同じものが生成されるはずです。
これで、*_pb2.py
ファイルは.proto
ファイルからprotobuf
コンパイラによって生成されたものであることが確認できました。
つまりcoremltools
の*_pb2
モジュールとそこに定義されているクラスはmlmodel/format
配下の.proto
ファイルで定義されているというわけです。
*_pb2.py
と*.proto
の比較
ここで、*_pb2.py
ファイルと*.proto
ファイルを比較してみましょう。
たとえばModel.proto
に、Model
というmessage
が次のように定義されています。
message Model {
int32 specificationVersion = 1;
ModelDescription description = 2;
...
Model_pb2.py
で同部分を見ると、次のようになっています。
_MODEL = _descriptor.Descriptor(
name='Model',
full_name='CoreML.Specification.Model',
filename=None,
file=DESCRIPTOR,
containing_type=None,
fields=[
_descriptor.FieldDescriptor(
name='specificationVersion',
full_name='CoreML.Specification.Model.specificationVersion',
index=0,
number=1, type=5, cpp_type=1, label=1,
has_default_value=False, default_value=0,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
options=None),
_descriptor.FieldDescriptor(
name='description',
full_name='CoreML.Specification.Model.description', index=1,
number=2, type=11, cpp_type=10, label=1,
has_default_value=False, default_value=None,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
options=None),
...
上の比較から一目瞭然ですが、データ構造は*_pb2.py
ファイルよりも.proto
の方が遥かに把握しやすいです。
Pythonからcoremltools
を扱う際に直接見えるのは*.pb2
モジュールなので、その実態が.proto
ファイルにあることを知らないと*.pb2.py
の方を読もうとしてしまうかもしれません。
しかしデータ構造を辿って情報を取得したり変更したりする目的であれば、.proto
ファイルの方を参照する方がよいでしょう。
mlmodelファイルのデシリアライズ
mlmodelがprotobufをベースとするフォーマットであるならば、.mlmodel
ファイルにはシリアライズされたprotobufデータが入っていると考えられます。Protocol Buffers公式ドキュメントにあるガイド「Protocol Buffer Basics: Python」を参考に、mlmodel
ファイルをPythonで読み込んでみましょう。
ここでは純粋にmlmodelがprotobufとしてデシリアイズ可能であることを確かめるために、coremltools
パッケージを使わず、前項でprotoc
コマンドでコンパイルした*_pb2.py
ファイル群だけを利用します。コンパイル時に --python_out
オプションで指定したPythonファイルの出力先ディレクトリに移動し、そこでPythonコードを書いていきます。coremltools
はインポートせず、Model_pb2
(.proto
ファイルからコンパイルしたモジュール)だけをインポートします。
import Model_pb2
空のModel
オブジェクト(Model.proto
に定義されているメッセージに相当)を生成します。
spec = Model_pb2.Model()
mlmodelファイルを読み込み、ParseFromString()
メソッドを用いてパースした情報をModel
オブジェクトに展開します。
with open("hoge.mlmodel", "rb") as f:
spec.ParseFromString(f.read())
以上でmlmodelファイルのデシリアライズは完了です。このModel
オブジェクトからはModel.proto
のModel
メッセージに定義されている様々な情報が取り出せるはずです。
たとえば読み込んだモデルが画像分類モデルで、かつクラスラベルを保持している場合、次のようにしてクラスラベルの一覧を取得できます。
print(model.neuralNetworkClassifier.stringClassLabels)
これはModel
メッセージのneuralNetworkClassifier
フィールドから取得できるNeuralNetworkClassifier
メッセージ(NeuralNetwork.proto
に定義されている)の、
message Model {
...
oneof Type {
...
NeuralNetworkClassifier neuralNetworkClassifier = 403;
...
}
StringVector
型のstringClassLabels
フィールドの値を出力していることになります。
message NeuralNetworkClassifier {
...
oneof ClassLabels {
StringVector stringClassLabels = 100;
...
}
...
protobuf API
mlmodelをカスタマイズするということは結局のところprotobufの仕様に準拠したModel
メッセージをカスタマイズするということなので、protobuf
のPython APIについて知っておくことは非常に有益です。
参考になる公式ドキュメント/リファレンスとしては以下があります。
- Language Guide (proto3)
- Protocol Buffers Well-Known Types
- Protocol Buffers Version 3 Language Specification
- Python Reference
- Protocol Buffers Python API Reference
たとえば本章で既に使用したParseFromString()
もprotobuf
のAPIです。
以下で、mlmodelを扱う際に使用頻度が高いと思われるものをいくつか紹介します。
WhichOneof()
たとえばModel.proto
においてModel
メッセージは次のようにType
という名前のoneof
フィールド群を持っています。
message Model {
...
oneof Type {
PipelineClassifier pipelineClassifier = 200;
PipelineRegressor pipelineRegressor = 201;
Pipeline pipeline = 202;
...
}
}
Type
の中には数十種類ものフィールドが列挙されていますが、これらのうち実際どれをそのModel
オブジェクトが持っているのかを調べられるのがWhichOneof()
メソッドです。引数にはoneof
の名前を指定します。
model.WhichOneof('Type') # 'neuralNetworkClassifier'
HasField()
HasField()
は引数に指定した名前のフィールドを持っているかどうかをBoolean
で返すメソッドです。
print(model.HasField('neuralNetworkClassifier')) # True
print(model.HasField('kNearestNeighborsClassifier')) # False
ClearField()
ClearField()
は引数に指定した名前のフィールドをクリア(デフォルト値に戻す)します。次のコードでは、Metadata
メッセージのshortDescription
フィールドをクリアしています。
metadata = model.description.metadata
print(metadata.shortDescription) # Detects the scene of an image from 205...
metadata.ClearField('shortDescription')
print(metadata.shortDescription) # (出力なし)
add()
, append()
, extend()
次のようにメッセージ型のフィールドがrepeated
として定義されている場合、
message Foo {
repeated Bar bars = 1;
}
message Bar {
optional int32 i = 1;
optional int32 j = 2;
}
Pythonコードにおいてそのフィールドに対して次のようにadd()
メソッドを呼ぶことができます。これはそのフィールドに該当する型のオブジェクトを1つ生成して追加することを意味します。
foo = Foo()
bar = foo.bars.add() # Barオブジェクトを1つ追加
またappend()
メソッドは引数に渡したオブジェクトをフィールドに追加できます。
new_bar = Bar()
foo.bars.append(new_bar)
extend()
メソッドは引数に渡した複数のオブジェクトをフィールドに追加します。
another_bar = Bar()
foo.bars.extend([another_bar])
CopyFrom()
CopyFrom()
メソッドを用いると、メッセージのすべての値を同じ型のオブジェクトからコピーすることができます。
# modelsフィールドに新規Modelオブジェクトを追加し、another_modelの内容をコピー
model.pipelineClassifier.pipeline.models.add().CopyFrom(another_model)
ByteSize()
protobufデータのサイズを出力します。これはmlmodelファイルのサイズと一致します。
print(model.ByteSize()) # 24754375
-
「Core ML Model Format Specification」でも確認可能ですが、これらのドキュメントも
.proto
ファイル内にあるコメントから生成されたものです。 ↩︎ -
「Core ML Tools実践入門」の「第9章 Core MLモデルのサイズを小さくする」参照。 ↩︎
Discussion