🗂️

Core MLのmlmodelファイルフォーマット詳解

2024/05/15に公開

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が開発したシリアライズフォーマットで、フリーソフトウェアとしてオープンソースライセンスで公開されています。

https://developers.google.com/protocol-buffers

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_pb2FeatureTypes_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.protoModelメッセージに定義されている様々な情報が取り出せるはずです。

たとえば読み込んだモデルが画像分類モデルで、かつクラスラベルを保持している場合、次のようにしてクラスラベルの一覧を取得できます。

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について知っておくことは非常に有益です。

参考になる公式ドキュメント/リファレンスとしては以下があります。

たとえば本章で既に使用した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
脚注
  1. Core ML Model Format Specification」でも確認可能ですが、これらのドキュメントも.protoファイル内にあるコメントから生成されたものです。 ↩︎

  2. 「Core ML Tools実践入門」の「第9章 Core MLモデルのサイズを小さくする」参照。 ↩︎

Discussion