はじめに
世は大AI時代、日夜多数のAIモデルが登場し世間を騒がせています。
そうなると当然、自分のアプリケーションにもAIを組み込みたいと思うもの。
組み込むという目的を達成するだけであれば、何かと話題のChatGPTや各種クラウドプロバイダーの提供するAIサービスのように、開発者側としては中身を意識することなく単にHTTPリクエストを作成すれば利用できるものも多数あります。こうしたサービスは高機能なモデルを簡単に使用することができ、非常に便利なものです。
しかし、推論をフロントエンドで完結させたいとか、自前のインフラを使用したいとか、単に技術的な興味として自分で動かしてみたいとか、そういった理由でこうしたサービスを使用したくない場合もあるでしょう。今回は、古典的なモデルであるResNetを利用した画像分類を実装してみようと思います。
OnnxRuntimeとは
世間一般では、機械学習アプリケーションとなるとPyTorchやTensorFlowのようなライブラリをPythonを通して利用するケースが多いかと思います。もちろん、C#erとしても
- pythonで推論を実装し、何らかのIPC,RPCプロトコルを介して呼び出す
- python.net でdotnetと連携する
- torchsharp などC#バインディング実装を利用する
など選択肢がありますが、アプリケーションでやりたいことが新規の機械学習モデルの作成や調整ではなく、既存のモデルによる推論にのみある場合は OnnxRuntime の利用が便利です。
Onnxは機械学習モデルを表現するフォーマットと、それを利用するプログラミング言語側インターフェイスとハードウェア側のランタイムを提供しており、だいたい以下のようなメリットがあると思います
- 様々な環境とハードウェアで高速に推論が可能
- C#をふくむ、様々な言語に対応
- シンプルかつ明確なインターフェイスを持つ
- onnxフォーマットの取り扱いやすさ
機械学習モデル
開発やデバッグを進めるために、単に既存モデルを利用するだけであっても知っておきたい前提知識を説明しておきます。あまり機械学習に詳しいわけではないので間違いがあったらすいません。
まず、一般にコンピュータでの計算というのは、入力データから対応する出力データを得る過程として定義できるでしょう。特に機械学習の場合、計算の過程で何かファイル操作やネットワーク操作をすることがないため、純粋静的の関数のように考えられます。
入力や出力として、様々な形式のデータが考えられます。これらの一般的な表現としてテンソル、つまり適当な数値型(単精度浮動小数点数など)の多次元配列を使用します(多次元配列が、1個の数値や1次元配列を表現できることに留意)。その各次元や数値が何を意味するかは表現すべきデータ次第ですが、とにかく多次元配列に対して何かの演算を実施して希望の多次元配列を得ることが目標と定義できました。
古典的なアルゴリズムの場合、入力データから出力を得る手順をコードとして(制御構造を含む命令のリストとして)記述しますが、機械学習の文脈では基本的な数学計算の組み合わせでそれを表現することを目指します。加算や乗算に加えて、 x > 0 ? x : 0
のような「スパイス」を加えると、その組み合わせで理論上は任意の入出力関係を表現できるはずであることが背景にあります(あくまで理論上ですが)。
この中で、主に乗算で「何を掛けるのか」というような、演算に突っ込む定数パラメータによって出力が変わります。この定数パラメータを適切に設定することで、希望の出力が得られることを目指すわけですが、その設定は人間が頑張るのではなく最適化アルゴリズムによって最適化するわけです(ここが機械学習であるポイント)。この定数パラメータが一般に「重み」と呼ばれるものです。一方、そもそもどのような演算を設定するのか(被演算データや回数など)というのも機械学習では重要な選択であり、この演算構造と重みの両方をもって、ある機械学習モデルを表現しまた再現することが可能になります。
.onnx 形式モデル
学習の結果得られた機械学習モデルの状態は何らかの方法でディスクに永続化したいわけですが、その形式や含まれるデータはライブラリごとに様々です。.onnx の拡張子が使用される形式のモデルは、今回のOnnxRuntimeが受け付けるモデル形式であり、かつ乱立するモデル形式の中で中間的な、様々な他形式から変換することができるフォーマットとなっています。
onnxモデルの実体はprotobufでシリアライズされたオブジェクトであり、機械学習モデルの構造と重みの情報を含みます。表現としてはC#er的にはCIL(共通中間言語)とやや似通った思想の、ハードウェアを抽象化した演算の記述となっています。
こうしたonnxモデルの中身を可視化するwebアプリケーションが知られており、こうしたツールを活用することでonnxモデルを正しく取り扱うことができます。
さて、機械学習モデルを推論で利用する場合、機械学習モデルは静的関数のように見えることになります。したがって、その引数の数、型、名前とその戻り値の型を知ることで呼び出しが可能になります。加えて、機械学習では型は数値型の多次元配列であり、それが概念的に何を意味するのかはわかりませんから、その情報もなんとかして手にいれる必要があります。
このうち、前者はonnx形式で含まれていますから、可視化ツールで調べることができます。後者は一般にはモデルの開発元などから情報として入手するか、なんとなく推測する必要があります。
ResNetによる画像分類
ResNetは畳み込みニューラルネットワーク(CNN)に分類される、古典的な画像分類モデルです。最新の画像分類モデルと比較すると能力は限られますが、その分出回っている情報が多く、かつ構造が理解しやすいモデルであり、計算も比較的軽量です。
大きさによっていくつか亜種があり、後ろに数値がつきます。今回はResNet50を使用します。一般に出回っているResNet50では、入力画像に対して、事前に定義された1000個のクラス(物体の名前)それぞれに対する確率(信頼度)を出力とします。
ResNet50の使用については、公式にチュートリアルがあります。本記事の実装と一致はしませんが、参考にしてください。
さて、ResNetは有名なためその入出力は明らかなのですが、今回は練習を兼ねてツールを使用して調べてみましょう。まず以下より、.onnx形式のResNet50のモデルを保存しておいてください。
次に、netron.app で保存したモデルを読み込みます。読み込みを実施すると、計算のフローがグラフとして可視化されるはずです。余談ではありますが、眺めてみるとサイズの異なる畳み込み層とバッチ正規化、活性化関数が繰り返され、確かに残渣接続が配置されているのがわかり面白いです。
話題を戻して、グラフの始点であるノード(今回は data
)をクリックすると、その詳細が表示されます。これを見ると、入力は data という名前の1個だけで、それは float32 型で Nx3x244x244 という大きさのテンソル、出力は同様に float32 型で Nx1000 という大きさのテンソルであるとわかります。Nはバッチサイズ(=入力画像の枚数)で、1つの画像を判定する場合Nは1になります。こうして得られた情報はこの後のコーディングで必要になるため重要です。
なお、これだけでは、入力の Nx3x224x224 が何を意味し、単精度浮動小数点の各要素が何を意味するのか、また出力の Nx1000 が何を意味し、単精度浮動小数点の各要素が何を意味するのかは未知のままであり、別途知る必要があります。
ResNetの場合、入力は解像度を224x224にした画像を、色(R,G,B)ごとに分けて3x244x244とし、各ピクセル値0~255を0~1にマッピングした上で決まった方法で正規化したもの、出力は1000個のクラスそれぞれに対応し、各クラスの可能性の強さに対応します。
推論の記述
入力の処理
まず、入力画像を 1x3x244x244 のテンソルにする必要があります(今回は1枚だけ推論を実施します)。そのために、jpeg等の画像ファイルをデコードして、クロップまたはリサイズした上で、色ごとに値を取得するコードが必要です。C#で画像処理を実施する方法はいくつかありますが、今回はクロスプラットフォームでライセンスが緩いSkiaSharpを使用します。
このライブラリを使用して、画像の中心部分を切り抜いてからさらに224x224にリサイズすることにします。クロップとリサイズの結果を確認できるように(モデルに何が入力されるのかわかるように)、編集結果を画像として保存していますがこれは本番では削除してください。
クロップとリサイズに成功したら、色ごとに分離してテンソルに変換します。なお、モデルに入力するときに、(R,G,B)ごとに以下のパラメータを使用して平均と分散を正規化する必要があるためその処理を含んでいます。
var mean = new[] { 0.485f, 0.456f, 0.406f };
var stddev = new[] { 0.229f, 0.224f, 0.225f };
注意点として、Pixels
プロパティの参照が重いためこれをループの外にくくりだす工夫を入れています。ベクトル化とかもできそうな雰囲気ですがそこまでは実施していません。
static DenseTensor<float> LoadImage(string path)
{
using var image = SKBitmap.Decode(path);
int cropWidth = image.Width / 2;
int cropHeight = image.Height / 2;
const int resizedWidth = 224;
const int resizedHeight = 224;
using var resized = CropAndResizeImage(image, resizedWidth, resizedHeight, cropWidth, cropHeight);
// 編集された画像を確認するためのコード、本番では削除
var dump = SKImage.FromBitmap(resized);
using FileStream fileStream = new FileStream("dump.png", FileMode.Create);
dump.Encode(SKEncodedImageFormat.Png, 100).SaveTo(fileStream);
var tensor = new DenseTensor<float>([1, 3, resizedWidth, resizedHeight]);
var mean = new[] { 0.485f, 0.456f, 0.406f };
var stddev = new[] { 0.229f, 0.224f, 0.225f };
// avoid calling GetPixel() for each pixel loop(this method is slow)
var pixels = resized.Pixels;
for (int i = 0; i < pixels.Length; i++)
{
var pixel = pixels[i];
tensor[0, 0, i % resizedWidth, i / resizedWidth] = (pixel.Red / 255f - mean[0]) / stddev[0];
tensor[0, 1, i % resizedWidth, i / resizedWidth] = (pixel.Green / 255f - mean[1]) / stddev[1];
tensor[0, 2, i % resizedWidth, i / resizedWidth] = (pixel.Blue / 255f - mean[2]) / stddev[2];
}
var reds = resized.Pixels.Select(x => x.Red / 255f).ToArray();
var greens = resized.Pixels.Select(x => x.Green / 255f).ToArray();
var blues = resized.Pixels.Select(x => x.Blue / 255f).ToArray();
return tensor;
}
static SKBitmap CropAndResizeImage(SKBitmap image, int targetWidth, int targetHeight, int cropWidth, int cropHeight)
{
var sourceWidth = image.Width;
var sourceHeight = image.Height;
if (sourceWidth < cropWidth || sourceHeight < cropHeight)
{
throw new ArgumentException("crop size is larger than source image size");
}
var cropX = (sourceWidth - cropWidth) / 2;
var cropY = (sourceHeight - cropHeight) / 2;
var resized = new SKBitmap(targetWidth, targetHeight);
using var canvas = new SKCanvas(resized);
canvas.DrawBitmap(image, new SKRect(cropX, cropY, cropX + cropWidth, cropY + cropHeight), new SKRect(0, 0, targetWidth, targetHeight));
canvas.Flush();
return resized;
}
推論の実行
それでは、いよいよ推論を実行します。OnnxRuntimeのパッケージを追加したうえで
保存したモデルへのパスを指定して InferenceSession
を初期化します。また、入力として先ほど確認した名前 data
とテンソルを与えます(これは1x3x224x224でなければなりません。
入力を与えて推論を実行すると、先ほど確認したように、1x1000のテンソルが得られるはずです。1x1000 から 1000要素の1次元に変換してしまいます(Linqなどが使用できます)。こうして得られた生の値はSoftmax関数を使用して確率分布に変換でき、その結果を確率が高いものから順番に10個表示してみます。そのクラスが何であるかはインデックスの位置によって知ることができます。位置と名前の対応関係はインターネット等で別途取得する必要があります(コピペして GetClassName
関数にハードコードするか、適当にデシリアライズ等を実装してください)。
結果の前処理/後処理を除くと、推論自体は非常にシンプルで、しっかりと型が付き、LinqなどC#機能との親和性も高くできていることがわかります。
static void RunInference(DenseTensor<float> inputImageBatch)
{
const string modelPath = @"{path_to_dir}\resnet50-v2-7.onnx";
using var session = new InferenceSession(modelPath);
var inputs = new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor("data", inputImageBatch)
};
var results = session.Run(inputs);
var result = results.First().AsEnumerable<float>();
var probabilities = Softmax(result);
var top = probabilities.Select((x, i) => (x, i)).OrderByDescending(tuple => tuple.x).Take(10)
.Select(tuple => (tuple.x, GetClassName(tuple.i))).ToArray();
foreach (var (prob, name) in top)
{
Console.WriteLine($"{name}: {prob * 100:f2}%");
}
}
static IEnumerable<float> Softmax(IEnumerable<float> input)
{
var sum = input.Sum(x => Math.Exp(x));
return input.Select(x => (float)(Math.Exp(x) / sum));
}
static string GetClassName(int index)
{
// クラス名を参照する
}
この関数に、先ほど作成した画像前処理の関数で得られるテンソルを与えてあげると、画像分類の結果が得られるはずです。手元の環境では、以下の画像(jpeg,5472x3648)を入力すると、前処理に212ms、推論に238msかかって
meatloaf: 48.88%
mashed potato: 23.98%
plate: 13.59%
burrito: 3.87%
broccoli: 2.98%
pot pie: 1.05%
pizza: 0.60%
cauliflower: 0.58%
guacamole: 0.42%
acorn squash: 0.40%
と推論結果が得られました。推論直後のメモリ使用量は164MBでした。
meatloafはハンバーグのような見た目の肉料理のようです。1000個の限られたクラスの中で画像に近い・関連するクラスの信頼度が高くなっており、まぁまぁの結果ではないでしょうか。
GPU(cuda)の使用
デフォルトでは、CPUが推論に使用されるため、先程の例でも暗黙的にCPUで推論が行われました。しかし、実際にはたくさんの画像を推論する場合やさらに重いモデルを使用する場合、GPUなどのより高速なデバイスで計算をしたくなるでしょう。
Onnx Runtimeは様々なデバイスに対応しており、こうしたユースケースにも対応できます。今回は私のPCはNvidiaのGPUなので、cudaを使用してみます。
CPU用パッケージと競合するという報告が見られたため、一応先程の Microsoft.ML.OnnxRuntime
を削除し、置き換える形で以下のパッケージを参照します(使用するデバイスごとにパッケージが異なります)。
次に、以下のバージョン対照表をもとに適するソフトウェアを導入します。今回はCUDA 12.x系の最新版とCuDNN 9系の最新版を導入します。インストールできていない場合、Unable to find an entry point named 'OrtSessionOptionsAppendExecutionProvider_CUDA'
や onnxruntime::ProviderLibrary::Get [ONNXRuntimeError] : 1 : FAIL : LoadLibrary failed with error 126 ""
といったエラーが発生することがあります。適切なバージョンの導入を再確認し、開発環境を再起動してみてください。
なお、インストール後も私の環境(win11,x64,net8.0)ではエラーが取れず、ビルドで生成される onnxruntime_providers_cuda.dll
の依存関係をツールで確認したところ cudnn64_9.dll
が解決できていませんでした。ということでインストール先、C:\Program Files\NVIDIA\CUDNN\v9.4\bin\12.6
に手動でpathを通したところ解決しました。
導入ができれば、InferenceSession
を初期化する部分で以下のようにオプションを指定するだけでGPUを使用できます。
using var gpuSessionOptions = SessionOptions.MakeSessionOptionWithCudaProvider(0);
using var session = new InferenceSession(modelPath, gpuSessionOptions);
なお、1枚の画像をResNet50に入力するだけの場合、GPUの使用で逆にパフォーマンスが劣化したため、CPUで十分高速な場合無理にGPUを使用する必要はなさそうです。