Core MLを用いた、iPhone での機械学習あれこれ(1)
※(2020/09/21追記)本記事執筆時点ではPyTorchのモデルを直接Core MLモデルに変換する方法がありませんでしたが、現在はCore ML Toolsのバージョン4.0以降を用いることで可能となっています。それに関する記事も作成しましたので、併せてご覧ください。chemicalfactory.hatenablog.com
最近はPython + PyTorchによる機械学習(特にディープラーニング)に興味を持っており、参考書を片手に色々と遊びながら勉強しています。色々と面白いことが出来そうなので、将来的にはこれを応用したiPhoneアプリを作成してみたいと思ったのですが、PyTorchで実装したモデルをiPhoneアプリに組み込む方法があるのかが気掛かりでした。
調べてみたところ、iPhoneではiOS 11以降で利用可能な「Core ML」という機械学習用のライブラリや、「Vision」という画像認識用のライブラリが公開されているようです。2020年1月現在では、PyTorchのモデルを直接Core MLのモデルに変換する方法は無さそうですが、一度ONNXモデル (Open Neural Network Exchange、Microsoft社とFacebook社による標準フォーマット)を噛ませることで変換可能となるようです。
また、Core ML用のオープンソースのモデルがApple社によって公開されており、更に「Create ML」というCore ML用の画像分類モデルなどを容易に作成可能なツールも用意されているようです(Xcode 11であれば、標準で付属しています)。これらを用いれば自力で機械学習のコードをほとんど書くことなしに、例えばiPhoneのカメラに写った動物や物体の種類が何であるのか認識するようなアプリを作ることも出来てしまうようです。
そこで本記事のシリーズでは、PyTorchモデルのCore MLモデルへの変換や、Create MLの利用など、Core ML周りについて色々調べて試して分かったことを、雑多にですがメモして行きます(まだまだ勉強不足で、分かっていないことも多いですが)。
PyTorchモデルを、Core MLモデルに変換して利用
PyTorchモデルの準備(手書き数字分類を例として)
まずはベタな題材として、PyTorchでMNISTの手書き数字を分類するためのモデルを用意しました。こちらをCore MLモデルに変換して、iPhoneアプリで利用出来るようにすることが目標です。
import torch import torchvision import torch.nn as nn import torch.optim as optim import torch.nn.functional as F import torchvision.transforms as transforms batch_size = 10 trainset = torchvision.datasets.MNIST(root='./data', train=True, download=True, transform=transforms.ToTensor()) trainloader = torch.utils.data.DataLoader(trainset, batch_size=batch_size, shuffle=True, num_workers=0) testset = torchvision.datasets.MNIST(root='./data', train=False, download=True, transform=transforms.ToTensor()) testloader = torch.utils.data.DataLoader(testset, batch_size=batch_size, shuffle=False, num_workers=0) class MNIST_Conv_MN(nn.Module): def __init__(self): super(MNIST_Conv_MN, self).__init__() self.conv1 = nn.Conv2d(1, 8, 3) self.pooling = nn.MaxPool2d(2, 2) self.fc1 = nn.Linear(13 * 13 * 8, 10) def forward(self, x): x = self.conv1(x) x = F.relu(x) x = self.pooling(x) x = x.view(-1, 13 * 13 * 8) x = self.fc1(x) return x model=MNIST_Conv_MN() criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters()) num_epochs = 10 for epoch in range(num_epochs): train_loss = 0 train_acc = 0 val_loss = 0 val_acc = 0 model.train() for i, (inputs, labels) in enumerate(trainloader): optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) train_loss += loss.item() train_acc += (outputs.max(1)[1] == labels).sum().item() loss.backward() optimizer.step() avg_train_loss = train_loss / len(trainloader.dataset) avg_train_acc = train_acc / len(trainloader.dataset) model.eval() with torch.no_grad(): for inputs, labels in testloader: outputs = model(inputs) loss = criterion(outputs, labels) val_loss += loss.item() val_acc += (outputs.max(1)[1] == labels).sum().item() avg_val_loss = val_loss / len(testloader.dataset) avg_val_acc = val_acc / len(testloader.dataset) print("Epoch [{}/{}], Loss: {loss:.4f}, val_loss: {val_loss:.4f}, val_acc: {val_acc:.4f}"\ .format(epoch+1, num_epochs, i+1, loss=avg_train_loss, val_loss=avg_val_loss, val_acc=avg_val_acc)) torch.save(model.state_dict(), "MNIST.pth")
※モデルの構成は「Chainerで作るコンテンツ自動生成AIプログラミング入門」という書籍のサンプルを参考にしています。また学習ループの書き方は「PyTorchニューラルネットワーク実装ハンドブック」という書籍を参考にしています。
※MNISTのデータセットはtorchvision.datasetsで作成しています。MNISTのピクセル値は本来0〜255ですが、transforms.ToTensor()でテンソルに変換すると0.0〜1.0に正規化される仕様のようです。
こちらの実行結果の例を示します。比較的シンプルなモデルですが、概ね97〜98%程度の認識精度は出せるようです。ここではモデルを”MNIST.pth”という名前で保存しておきます。
Epoch [1/10], Loss: 0.0241, val_loss: 0.0119, val_acc: 0.9669
Epoch [2/10], Loss: 0.0108, val_loss: 0.0093, val_acc: 0.9708
Epoch [3/10], Loss: 0.0081, val_loss: 0.0082, val_acc: 0.9739
Epoch [4/10], Loss: 0.0067, val_loss: 0.0068, val_acc: 0.9781
Epoch [5/10], Loss: 0.0060, val_loss: 0.0069, val_acc: 0.9781
Epoch [6/10], Loss: 0.0054, val_loss: 0.0063, val_acc: 0.9792
Epoch [7/10], Loss: 0.0049, val_loss: 0.0069, val_acc: 0.9792
Epoch [8/10], Loss: 0.0045, val_loss: 0.0066, val_acc: 0.9801
Epoch [9/10], Loss: 0.0043, val_loss: 0.0062, val_acc: 0.9819
Epoch [10/10], Loss: 0.0039, val_loss: 0.0065, val_acc: 0.9810
ONNXを噛ませた、PyTorch→Core MLのモデル変換
ここから先の内容は、以下の外部サイトの内容やコードを大いに参考にさせて頂いています。まずはこちらを参考にONNX、onnx-coremlのインストールを済ませておきます。
qiita.com
そして先ほど保存した”MNIST.pth”を用意して、以下のコードを実行すれば、PyTorchの手書き数字認識用モデルがONNXモデルを経由してCore MLモデルに変換されます(ここでは”MNIST.mlmodel”という名前で保存)。
import torch import torchvision import torch.nn as nn import torch.optim as optim import torch.nn.functional as F import torchvision.transforms as transforms import onnx from onnx_coreml import convert class MNIST_Conv_MN(nn.Module): def __init__(self): super(MNIST_Conv_MN, self).__init__() self.conv1 = nn.Conv2d(1, 8, 3) self.pooling = nn.MaxPool2d(2, 2) self.fc1 = nn.Linear(13 * 13 * 8, 10) def forward(self, x): x = self.conv1(x) x = F.relu(x) x = self.pooling(x) x = x.view(-1, 13 * 13 * 8) x = self.fc1(x) return x model = MNIST_Conv_MN() model.load_state_dict(torch.load('MNIST.pth')) dummy_input = torch.FloatTensor(1, 1, 28, 28) torch.onnx.export(model, dummy_input, 'MNIST.proto', verbose=True) model_onnx = onnx.load('MNIST.proto') onnx.checker.check_model(model_onnx) coreml_model = convert( model_onnx, mode='classifier', image_input_names=['input'], image_output_names=['output'], class_labels=[i for i in range(10)] ) coreml_model.save('MNIST.mlmodel')
コードの構成は参考サイトのものを、ほとんどそのまま使用させて頂いていますが、ONNXモデル変換時のダミー入力(dummy_input)は、本モデルの入力に合わせてtorch.FloatTensor(1, 1, 28, 28)としました。また、本モデルの出力数は10(数字0〜9の分類結果)となりますので、Core MLモデル変換時のclass_labelsはそのまま同様に数字で埋めています。
変換が上手く行けば、以下のようにCore MLモデルのファイルが生成されます。
Core MLモデルの内容確認
Xcode(本記事執筆時点:Version 11.3)でSingle View appのプロジェクトを新規作成して、先ほど作成したCore MLモデル(”MNIST.mlmodel”)をドラッグ&ドロップでプロジェクトに追加してみます。
Xcode上で追加したモデルを選択すると、以下のようにモデルの詳細が表示されます。
この中でも「Prediction」の項が重要であり、こちらの「Name」が後ほどコード上でモデルに入力を渡したり、出力を受け取ったりするための変数名となります。そして入力の型を見ると「MultiArray(Float32 1 x 28 x 28)」となっています。これは、モデルへの入力データを「MLMultiArray」という形式で与える必要があることを意味するようです。こちらについては後述します。
Core MLモデルを使ってみる。
またまたベタな題材かもしれませんが、作成したCore MLモデルを用いて、下図のように黒色のキャンバス内(280 x 280のUIImageView)に白色で数字を手書きして「予測」ボタンを押すと、書かれた数字が何か予測するアプリを作成したいとします。
このような場合、最初にキャンバス内の画像(UIImage)を28 x 28にリサイズするまでは良いのですが、先述のようにモデルの入力データはMLMultiArrayなので、そのまま入力することは出来ず形式変換する必要があります。
そもそもMLMultiArrayとは何なのかという話ですが、要は特定の型(Floatなど)を有する多次元配列のようです…と言っても実際のメモリ上では一次元配列で表現されるようです。例えばRGBの3チャンネルを有する画像の場合は、以下の模式図のように各チャンネルを順番に一次元配列へと変換するような形でMLMultiArrayに格納すれば良いようです。
…と言うだけなら簡単ですが、MLMultiArrayでは現在のところ、numpyにおける配列操作やPyTorchにおけるTensor操作のように便利な機構はなく、UIImageをピクセルデータの配列に変換して、MLMultiArrayに適切なデータの並びで格納するような処理なども自前で用意する必要がありそうです(以下の外部サイトを参考にさせて頂きました)。中々に敷居が高いです。
KerasとCoreMLでMNIST手書き文字認識
MLMultiArrayとポインタ
MLMultiArrayを見据えたUnsafeMutableRawPointerの取り回し
ここではCore MLモデル(”MNIST.mlmodel”)を追加したプロジェクトからスタートして、最初の参考サイトにおけるCore ML周りのコードをほとんどそのまま使わせて頂き、手書き数字を予測するアプリを作成してみました。コードを以下に示します。
(ただし、UIImageViewに手書きで数字を書けるようにする処理は主眼ではなく、長くなってしまいますので省略しました。以下の外部サイトなどが参考になると思います)
iOS標準機能の良いお絵描きアプリを目指して・・・
import UIKit import CoreML class ViewController: UIViewController { //"canvas"は数字を手書きするための、280 x 280のUIImageView。 //"predictLabel"は予測結果を表示するためのラベル。 //いずれもstoryboard上で準備済みとする。 @IBOutlet weak var canvas: UIImageView! @IBOutlet weak var predictLabel: UILabel! //-------------------------------------------------------------------- // 本来はここに、"canvas"に手書きで数字を書けるようにするための // コードが存在するが、長くなり過ぎるため省略 //-------------------------------------------------------------------- //“predictButton"は「予測」ボタンであり、storyboardで用意済みであるとする。 @IBAction func predictButtonPushed(_ sender: Any) { if let inputImage = self.canvas.image{ coreMLRequest(image: inputImage) } } func coreMLRequest(image: UIImage){ let imgSize: Int = 28 let imageShape: CGSize = CGSize(width: imgSize, height: imgSize) //(280, 280)の画像サイズを(28, 28)にリサイズして、更にpixelBufferに変換。 let imagePixel = image.resize(to: imageShape).getPixelBuffer() //(1, 28, 28)のMLMultiArrayを生成し、pixelBufferに変換した画像を格納する。 let mlarray = try! MLMultiArray(shape: [1, NSNumber(value: imgSize), NSNumber(value: imgSize)], dataType: MLMultiArrayDataType.float32 ) for i in 0..<imgSize*imgSize { mlarray[i] = imagePixel[i] as NSNumber } //プロジェクトに追加したCore MLのモデル("MNIST.mlmodel")をロード let MNISTModel = MNIST() //画像データを格納したMLMultiArrayをモデルに入力し、書かれている数字を予測する。 //入力用の変数名(input_1)、出力用の変数名(_10)は"MNIST.mlmodel"の詳細で確認した通り。 if let prediction = try? MNISTModel.prediction(input_1: mlarray) { if let first = (prediction._10.sorted{ $0.value > $1.value}).first{ self.predictLabel.text = "予測:\(Int(first.key))" } } } } extension UIImage { //画像を指定のサイズにリサイズする(参考サイトのコードそのまま)。 func resize(to newSize: CGSize) -> UIImage { UIGraphicsBeginImageContextWithOptions(CGSize(width: newSize.width, height: newSize.height), true, 1.0) self.draw(in: CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height)) let resizedImage = UIGraphicsGetImageFromCurrentImageContext()! UIGraphicsEndImageContext() return resizedImage } //画像をpixelBufferに変換。ただし参考サイトのコードを一部変更して、 //ピクセル値を二値化はせずに0.0〜1.0の値として扱う(元々のモデルに合わせる) func getPixelBuffer() -> [Float] { guard let cgImage = self.cgImage else { return [] } let bytesPerRow = cgImage.bytesPerRow let width = cgImage.width let height = cgImage.height let bytesPerPixel = 4 let pixelData = cgImage.dataProvider!.data! as Data var buf : [Float] = [] for j in 0..<height { for i in 0..<width { let pixelInfo = bytesPerRow * j + i * bytesPerPixel let r = CGFloat(pixelData[pixelInfo]) let g = CGFloat(pixelData[pixelInfo+1]) let b = CGFloat(pixelData[pixelInfo+2]) let v: Float = floor(Float(r + g + b)/3.0)/255.0 buf.append(v) } } return buf } }
“MNIST.mlmodel”のモデル詳細を確認した際に、「Prediction」項で確認した入出力用の変数をそのまま用います(変数名を変えられたら良いのですが、現在のところ方法が分かっていません)。
一例として「5」の数字を描いた場合における、本モデルの出力(prediction._10)の中身を表示すると以下のようになります。画像が0〜9の各数字である確率がdictionaryとして出力されますので、確率が最大となる数字が予測結果となります。今回はきちんと「5」が確率最大となっております(実のところ、本アプリの精度は体感的に高いとは言えず、特に「6」「8」「9」などの数字は誤って予測されやすいですが…)。
[6: -7.831572532653809, 3: 0.5545886158943176, 9: -3.484532594680786, 5: 4.532480239868164, 0: -12.719040870666504, 1: -9.382288932800293, 8: -7.790628433227539, 4: -15.417695999145508, 2: -5.6677656173706055, 7: -6.926422595977783]
というわけで、当初の目的であった「PyTorchで実装したモデルをiPhoneアプリに組み込む」をひとまず実現することが出来ました。しかし、MLMultiArrayの扱いは敷居が高く、出来ればこれを介さずに、画像などを入力としてモデルに与える方法が欲しい所です。また記事の冒頭で述べた通り、Apple社が公開しているオープンソースのCore MLモデルや、Create MLという魅力的なツールもありますので、これらも使ってみたい所です。
次回以降の記事では、これらについてもメモして行きたいと思います。