Core ML で完結させない判断 — 推論先を振り分ける設計
ここまで読むと、オンデバイス推論がいつでも最適解のように見えるかもしれません。
私自身、最初はそう思っていました。手元のアプリの推論を全部 Core ML に寄せて、API 費用をゼロにするつもりでした。実際にやってみて、その考えが崩れたのは、ユーザーから「アプリが古いことしか答えない」という報告が届いたときです。
端末に焼き込んだモデルは、学習した時点の世界しか知りません。低遅延もプライバシーも手に入りますが、その代わりに「最新性」と「複雑な推論」を差し出しています。
現実的な答えは、Core ML か クラウドか、ではありません。リクエストごとに推論先を選ぶ ことです。
振り分けの判断軸
Core ML に寄せるべきか外に出すべきかは、リクエストの性質でほぼ決まります。私が実際に使っている判断軸を整理しておきます。
「迷ったら端末内」で構いません。外に出す判断のほうに理由を要求する、という非対称なルールにしておくと、プライバシー設計が崩れにくくなります。
ルーターを1つのモジュールに閉じ込める
この判断を各画面に散らかすと、半年後に必ず破綻します。判断は1箇所に集めます。
// src/ai/inferenceTarget.ts
export type InferenceTarget = 'core-ml' | 'cloud' ;
export interface InferenceInput {
text : string ;
/** 写真・位置情報・連絡先など、端末外に出したくない値を含むか */
hasSensitivePayload : boolean ;
/** モデルの学習時点より新しい情報を要求しているか */
needsFreshKnowledge : boolean ;
/** 多段階の推論や長文生成を要求しているか */
needsDeepReasoning : boolean ;
isOffline : boolean ;
}
export interface TargetDecision {
target : InferenceTarget ;
/** ログ・デバッグ用。UI には出さない */
rationale : string ;
}
const SHORT_INPUT_LIMIT = 50 ;
export function selectInferenceTarget ( input : InferenceInput ) : TargetDecision {
// 端末外に出せない条件は、他のどの条件よりも先に評価する
if (input.isOffline) {
return { target: 'core-ml' , rationale: 'offline' };
}
if (input.hasSensitivePayload) {
return { target: 'core-ml' , rationale: 'sensitive-payload' };
}
// 端末内モデルが原理的に答えられないものだけを外に出す
if (input.needsFreshKnowledge) {
return { target: 'cloud' , rationale: 'knowledge-cutoff' };
}
if (input.needsDeepReasoning) {
return { target: 'cloud' , rationale: 'reasoning-depth' };
}
// 短い入力は往復コストが割に合わない
if (input.text. length <= SHORT_INPUT_LIMIT ) {
return { target: 'core-ml' , rationale: 'short-input' };
}
return { target: 'core-ml' , rationale: 'default-local-first' };
}
最後の分岐を 'cloud' ではなく 'core-ml' にしている点が、この関数の性格を決めています。
判定に迷った入力は端末内へ落とします。クラウドへ出るのは、上の2つの明示的な理由に当てはまったときだけです。フォールバック先が安全側になっていれば、条件を書き足していくうちに情報が漏れる、という事故が起きません。
rationale をログにだけ流し、UI には出さないのも意図的です。「いまクラウドで処理しています」といった表示は、開発中は便利ですが、製品では利用者に判断を押し付けるだけになります。
振り分けた結果を測る
振り分けの良し悪しは、体感ではなく比率で見ます。記録するのは3つだけで足ります。
// src/ai/inferenceStats.ts
import AsyncStorage from '@react-native-async-storage/async-storage' ;
const KEY = 'inference_stats_v1' ;
type Bucket = { coreML : number ; cloud : number ; cloudSpendUsd : number };
export async function recordInference (
target : 'core-ml' | 'cloud' ,
spendUsd : number
) : Promise < void > {
const raw = await AsyncStorage. getItem ( KEY );
const bucket : Bucket = raw
? JSON . parse (raw)
: { coreML: 0 , cloud: 0 , cloudSpendUsd: 0 };
if (target === 'core-ml' ) {
bucket.coreML += 1 ;
} else {
bucket.cloud += 1 ;
bucket.cloudSpendUsd += spendUsd;
}
await AsyncStorage. setItem ( KEY , JSON . stringify (bucket));
}
/** 端末内で処理できた割合。ここが落ちたら振り分け条件を疑う */
export async function localHitRate () : Promise < number > {
const raw = await AsyncStorage. getItem ( KEY );
if ( ! raw) return 0 ;
const b : Bucket = JSON . parse (raw);
const total = b.coreML + b.cloud;
return total === 0 ? 0 : b.coreML / total;
}
私のアシスタント系アプリで1週間計測したときの数字を置いておきます。
localHitRate() が下がり始めたら、モデルの精度ではなく振り分け条件を先に疑ってください。私の場合、原因はいつも「クラウド行きの条件を安易に足していた」ことでした。
振り分けを入れると出てくる問題
実装してから気づいた点を3つ挙げます。
モデルの初期化は、最初の1回だけ遅くなります。 起動直後に最初のリクエストが来ると、MLModel のロードがそのまま体感待ち時間になります。起動時にバックグラウンドで温めておき、ロードが終わるまでは入力を受け付けて内部のキューに積んでおきます。Rork Max のプロンプトなら「アプリ起動時にバックグラウンドで Core ML モデルを初期化する」と書き添えるだけで通ります。
メモリ警告でモデルを手放す処理が、最初の実装では抜けていました。 端末内モデルは常駐します。didReceiveMemoryWarning 相当のタイミングで解放し、次の推論時に再ロードする経路を用意しておかないと、写真アプリと併用された瞬間に落ちます。
iOS 26 以降なら、外に出していた自然言語処理を引き戻せます。 Apple の FoundationModels は端末内で動く LLM なので、selectInferenceTarget() が 'cloud' を返していた入力のうち、要約や言い換えのような一部を端末側へ移せます。その設計は Rork で Apple FoundationModels を使う に分けて書きました。
Core ML は iOS だけのものです。 Android にも同じ体験を届けるなら、TensorFlow Lite か MediaPipe の推論 API を裏に置き、selectInferenceTarget() の戻り値はそのままにして実装だけ差し替えます。判断ロジックを1箇所に閉じ込めておく利点が、ここで効いてきます。
Create ML によるカスタムモデルの学習
Create ML の基本
Create MLは、Appleが提供するmacOS用アプリで、Xcodeと統合されており、UIベースで簡単にMLモデルを訓練できます。
対応するタスク
Image Classification :画像分類(犬種判定、植物認識等)
Object Detection :物体検出(顔検出、商品検出等)
Sound Classification :音声分類(環境音認識等)
Text Classification :テキスト分類(感情分析、スパム判定等)
Tabular Regression / Classification :表形式データ(需要予測等)
実践:画像分類モデルの学習フロー
ここでは、製造業での不良品検出モデル を例にします。
Step 1: 学習データセットの準備
Create MLでは、フォルダ構造によるデータセット指定が基本です。
dataset/
├── OK/
│ ├── ok_001.jpg
│ ├── ok_002.jpg
│ └── ...
└── NG/
├── ng_001.jpg
├── ng_002.jpg
└── ...
推奨:各クラス500〜5000画像。データの質が精度を大きく左右します。
Step 2: Create ML でのモデル生成
// Xcodeメニュー: File > New > ML Model
// UI操作:
// 1. "Image Classification" を選択
// 2. Training Data に dataset/ フォルダを指定
// 3. Validation Data に全体の10-20%を確保
// 4. Output に defect-detection.mlmodel を指定
// 5. "Train" ボタンをクリック
Step 3: 精度確認と反復改善
Create MLは訓練中にAccuracy (精度)とLoss (損失)のグラフを表示します。
Accuracy が 0.95 以上 :本番運用可能レベル
0.85-0.94 :追加データ・データクリーニングで改善
0.85 以下 :モデルアーキテクチャ見直し・前処理改善
良い訓練曲線: Train と Validation が並行して低下
悪い訓練曲線: Train は低下、Validation が上昇(過学習)
→ Validation データを増やす、または正則化パラメータ調整
転移学習による高速開発
ゼロからモデルを訓練するのは時間がかかります。Appleが提供する事前訓練済みモデル(VGG、ResNet等)から転移学習で高速化できます。
Create MLの UI上で、"Architecture" セクションで事前モデルを選択するだけです。
メリット
訓練時間 90% 削減 :数十分→数秒
データ量 大幅削減 :数千枚→数百枚でも精度維持
汎化性能向上 :一般的な画像特徴を保持
Core ML モデルの最適化とサイズ削減
Core MLモデルの容量
Create ML で書き出した .mlmodel は、そのままではかなりの容量になります。
App Store の上限は、iOS / iPadOS では展開後のアプリ全体で 4 GB 未満 、watchOS では 75 MB 未満と定められています(Maximum build file sizes )。
4 GB と聞くと余裕があるように思えます。ただ、私の場合に先に効いてきたのは上限そのものではなく、初回ダウンロードの完了率のほうでした。壁紙アプリで容量を増やしたときにも同じことが起きています。上限に当たるより早く、利用者のほうが待つのをやめてしまうのです。
一般的なモデルサイズ
ResNet50(画像分類):約100MB
YOLOv5(物体検出):50〜150MB(精度による)
BERT(テキスト):約500MB(フル版)
量子化による容量削減
量子化は、モデルの浮動小数点数(Float32)を整数形式(Int8)に変換し、メモリと演算量を削減する手法です。
Create ML での量子化設定
Create ML UIで、訓練後に "Quantization" セクションが表示されます。
- Full Precision (Float32): 最高精度、最大容量
- Float16: 精度を維持しながら容量 50% 削減
- Int8 (Quantized): 容量 75% 削減、精度低下は一般に1-3%
同梱したモデルのメタデータを確認するコードです。
// inspect.swift
import CoreML
// 生成クラス経由がいちばん確実です
// (defect-detection.mlmodel → defect_detection クラス)
let wrapper = try defect_detection ( configuration : MLModelConfiguration ())
print (wrapper.model.modelDescription)
// URL から読む場合、バンドルにあるのは .mlmodelc のほうです
guard let url = Bundle.main. url ( forResource : "defect-detection" , withExtension : "mlmodelc" ) else {
throw MLError.modelNotFound
}
let model = try MLModel ( contentsOf : url)
print (model.modelDescription)
ここは、私が最初に書いたときに withExtension: "mlmodel" として nil を掴み、! で落としていた箇所です。Xcode はビルド時に .mlmodel をコンパイルするので、アプリバンドルに入っているのは .mlmodelc というディレクトリ のほうでした。MLModel(contentsOf:) が受け取るのもコンパイル済みモデルだけです(init(contentsOf:) )。
Core ML Tools による Python 最適化
より詳細な最適化は、Pythonの coremltools ライブラリで実行できます。
# pip install coremltools tensorflow
import coremltools as ct
import coremltools.optimize as cto
import tensorflow as tf
tf_model = tf.keras.models.load_model( 'defect_model.h5' )
# 分類器は outputs ではなく classifier_config で宣言します
class_labels = [ "ok" , "scratch" , "dent" ]
mlmodel = ct.convert(
tf_model,
inputs = [ct.ImageType( name = "image" , shape = ( 1 , 224 , 224 , 3 ))],
classifier_config = ct.ClassifierConfig(class_labels),
convert_to = "mlprogram" ,
minimum_deployment_target = ct.target.iOS17,
compute_units = ct.ComputeUnit. CPU_AND_NE ,
)
# ML Program の重み量子化はこちらの API です
op_config = cto.coreml.OpLinearQuantizerConfig(
mode = "linear_symmetric" , weight_threshold = 512
)
config = cto.coreml.OptimizationConfig( global_config = op_config)
quantized_mlmodel = cto.coreml.linear_quantize_weights(mlmodel, config = config)
# 保存するのは量子化した側です(ML Program の拡張子は .mlpackage)
quantized_mlmodel.save( 'DefectDetection.mlpackage' )
この 20 行ほどのスクリプトで、私は二度つまずきました。
一度目は量子化の関数です。古い記事でよく見かける quantization_utils.quantize_weights が効くのは旧来の neural network 形式で、ct.convert が既定で書き出す ML Program には使えません。ML Program 側は coremltools.optimize.coreml.linear_quantize_weights に OptimizationConfig を渡します(API Overview )。分類ラベルを outputs= に渡そうとして弾かれたのも同じ日でした。正しくは classifier_config です(Classifiers )。
二度目は、もっと間の抜けた取り違えでした。量子化の戻り値を新しい変数で受けておきながら、保存するときに変換直後のモデルのほうを渡していたのです。エラーは出ません。ファイルは正しく書き出され、ただサイズだけが縮まないまま、Xcode で開くまで気づきませんでした。圧縮系の API はもとのオブジェクトを書き換えず、新しいモデルを返します。受け取った変数を保存し忘れると、何もしなかったのと同じ結果だけが残ります。
メモリフットプリント最適化
モデル読み込み時のメモリ消費を最小化するコツ:
// 1. MLModel を一度だけ読み込み、必要なときに手放せるようにしておく
actor MLModelCache {
static let shared = MLModelCache ()
private var model: MLModel ?
func model () throws -> MLModel {
if let model { return model }
guard let url = Bundle.main. url ( forResource : "defect-detection" ,
withExtension : "mlmodelc" ) else {
throw MLError.modelNotFound
}
let loaded = try MLModel ( contentsOf : url)
model = loaded
return loaded
}
/// メモリ警告を受けたら手放し、次の推論で読み直します
func release () {
model = nil
}
}
// 2. 推論時は autoreleasepool で一時メモリを解放
autoreleasepool {
let prediction = try model. prediction ( input : input)
// prediction を処理
}
try! を残しておくと、モデルの読み込みに失敗した瞬間にアプリが落ちます。同梱モデルは「必ずある」と信じたくなる対象ですが、ファイル名を変えた次のビルドで裏切られるのはたいていここでした。
Rork Max での Core ML モデル統合
Rork Max における ML モデルの管理
Rork Maxでは、Core MLモデルをアセットとして登録し、アプリから参照します。
統合フロー
モデル登録 :Rork Max の "Assets" セクションで .mlmodel ファイルをアップロード
自動最適化 :Rork がバックエンドで量子化・最適化を実行(オプション)
コード生成 :SwiftUI レイアウトで、カスタムロジック部分に CoreML モジュールをドロップ
デプロイ :App Store ビルドに自動含有
ノーコード統合の例:画像分類
Rork Max のビジュアルエディタで、以下を構築:
[Camera View] → [ML Model Input] → [Defect Classification] → [Alert UI]
具体的な実装
// Rork Max が生成するSwiftUIコード (自動)
import CoreML
import Vision
struct DefectDetectionView : View {
@State private var cameraImage: UIImage ?
@State private var detectionResult: String = ""
var body: some View {
VStack {
Image ( uiImage : cameraImage ?? UIImage ())
. resizable ()
. scaledToFit ()
Button ( "分析" ) {
detectDefect ()
}
Text (detectionResult)
. font (.headline)
. padding ()
}
}
private func detectDefect () {
guard let image = cameraImage else { return }
// Model ロード
guard let model = try? defect_detection ( configuration : MLModelConfiguration ()) else {
detectionResult = "モデル読み込み失敗"
return
}
// 入力準備
guard let buffer = image. pixelBuffer ( size : CGSize ( width : 224 , height : 224 )) else {
return
}
// 推論実行
let input = defect_detectionInput ( imageAt : buffer)
guard let output = try? model. prediction ( input : input) else {
detectionResult = "推論失敗"
return
}
// 結果表示
let confidence = output.classLabelProbs. max ( by : { $0 . value < $1 . value })
detectionResult = "判定: \( confidence ? . key ?? "不明" ) ( \( String ( format : "%.1f" , (confidence ? . value ?? 0 ) * 100 ) ) %)"
}
}
カスタムロジックの追加
Rork Max では、"Custom Swift" セクションを使い、より複雑な処理を実装できます。
// バッチ推論(複数フレームを連続処理)
func batchInference ( frames : [UIImage]) -> [ String ] {
let model = try! defect_detection ( configuration : MLModelConfiguration ())
var results: [ String ] = []
for frame in frames {
guard let buffer = frame. pixelBuffer ( size : CGSize ( width : 224 , height : 224 )) else {
continue
}
let input = defect_detectionInput ( imageAt : buffer)
let output = try! model. prediction ( input : input)
results. append (output.classLabel)
}
return results
}
Vision フレームワーク連携 — 画像認識・物体検出
Vision フレームワーク の役割
Vision フレームワークは、Core ML と組み合わせることで、画像前処理・検出結果の後処理を効率化します。
主な機能
顔検出・認識 :NSFW検出、顔の向き・表情分析
物体検出 :RPN(Region Proposal Network)による高速検出
バーコード・QRコード認識
テキスト認識(OCR)
ホモグラフィ推定 :遠近法補正
カメラ・音声・テキストを同じ画面で束ねる段階まで進めるなら、入力の受け口をどう設計するかが先に効いてきます。その整理は Rork × マルチモーダルAI:カメラ・音声・テキストを統合したインテリジェントアプリ設計パターン にまとめています。
実践:物体検出モデルの統合
YOLO等の物体検出モデルをCore MLに変換し、Vision で処理する例:
import Vision
import CoreML
class ObjectDetector {
let model: defect_yolo // Core ML モデル
init () throws {
self .model = try defect_yolo ( configuration : MLModelConfiguration ())
}
func detect ( in image: UIImage) -> [DetectionResult] {
guard let ciImage = CIImage ( image : image) else { return [] }
// Vision Request 生成
let request = VNCoreMLRequest ( model : try! model.model) { request, error in
if let error = error {
print ( "Detection error: \( error ) " )
return
}
guard let observations = request.results as? [VNRecognizedObjectObservation] else {
return
}
// 検出結果の処理
for observation in observations {
let boundingBox = observation.boundingBox
let confidence = observation.confidence
let label = observation.labels. first ? .identifier ?? "Unknown"
print ( "Detected: \( label ) at \( boundingBox ) ( \( confidence ) )" )
}
}
// リクエスト実行
let handler = VNImageRequestHandler ( ciImage : ciImage, options : [ : ])
try? handler. perform ([request])
return []
}
}
struct DetectionResult {
let label: String
let boundingBox: CGRect
let confidence: Float
}
Rork Max での Vision 統合
Rork Maxでは、"Vision Pipeline" コンポーネントを使い、以下を構築:
[Camera] → [Face Detection] → [Custom ML] → [Annotation Overlay]
// Rork が生成するコード
func overlayDetections ( on image: CGImage, results : [DetectionResult]) {
let size = CGSize ( width : image.width, height : image.height)
UIGraphicsBeginImageContextWithOptions (size, false , 1.0 )
UIImage ( cgImage : image). draw ( at : .zero)
let context = UIGraphicsGetCurrentContext () !
context. setStrokeColor (UIColor.red.cgColor)
context. setLineWidth ( 2.0 )
for result in results {
let rect = CGRect (
x : result.boundingBox.origin.x * size.width,
y : result.boundingBox.origin.y * size.height,
width : result.boundingBox.width * size.width,
height : result.boundingBox.height * size.height
)
context. stroke (rect)
// ラベル描画
let label = NSAttributedString (
string : " \( result. label ) \( String ( format : "%.0f%%" , result. confidence * 100 ) ) " ,
attributes : [.foregroundColor : UIColor.red, .font : UIFont. systemFont ( ofSize : 12 )]
)
label. draw ( at : CGPoint ( x : rect.minX, y : rect.minY - 20 ))
}
let annotatedImage = UIGraphicsGetImageFromCurrentImageContext ()
UIGraphicsEndImageContext ()
}
Natural Language フレームワーク — テキスト分類・感情分析
Natural Language フレームワーク の活用
Vision が画像処理に特化しているのに対し、Natural Language はテキスト処理に最適化されています。
主な機能
言語識別 :複数言語の自動判別
トークン化・品詞タグ付け :自然言語処理の前処理
固有表現抽出 :人名・地名・組織名の自動抽出
カスタムテキスト分類 :感情分析、カテゴリ分類
実践:感情分析モデルの構築と統合
Create MLでテキスト分類モデルを訓練:
training_data/
├── positive.txt
│ ├── この商品は最高です
│ ├── とても満足しました
│ └── ...
└── negative.txt
├── 期待外れだった
├── 品質が悪い
└── ...
Create ML で訓練→.mlmodel 生成→Rork Max に統合
import NaturalLanguage
import CoreML
class SentimentAnalyzer {
let model: sentiment_classifier // Core ML
init () throws {
self .model = try sentiment_classifier ( configuration : MLModelConfiguration ())
}
func analyze ( _ text: String ) -> SentimentResult {
// テキストの前処理
let language = NLLanguageRecognizer. dominantLanguage ( for : text) ?? .japanese
let tagger = NLTagger ( tagSchemes : [.tokenType])
tagger.string = text
var tokens: [ String ] = []
tagger. enumerateTags ( in : text. startIndex ..< text. endIndex , unit : .word, scheme : .tokenType) { tag, range in
let token = String (text[range]). lowercased ()
tokens. append (token)
return true
}
// モデル入力
let input = sentiment_classifierInput ( text : tokens. joined ( separator : " " ))
guard let output = try? model. prediction ( input : input) else {
return SentimentResult ( sentiment : "unknown" , confidence : 0.0 )
}
return SentimentResult (
sentiment : output.classLabel,
confidence : output.classLabelProbs[output.classLabel] ?? 0.0
)
}
}
struct SentimentResult {
let sentiment: String // "positive" / "negative"
let confidence: Double
}
Rork Max での NLP パイプライン
ユーザーレビュー画面での自動感情分析:
struct ReviewAnalysisView : View {
@State private var reviewText: String = ""
@State private var sentiment: SentimentResult ?
var body: some View {
VStack {
TextEditor ( text : $reviewText)
. border (Color.gray)
Button ( "分析" ) {
analyzeReview ()
}
if let result = sentiment {
HStack {
Image ( systemName : result.sentiment == "positive" ? "hand.thumbsup.fill" : "hand.thumbsdown.fill" )
. foregroundColor (result.sentiment == "positive" ? .green : .red)
Text (result.sentiment == "positive" ? "肯定的" : "否定的" )
Text ( String ( format : "%.0f%%" , result.confidence * 100 ))
}
. padding ()
. background (result.sentiment == "positive" ? Color.green. opacity ( 0.1 ) : Color.red. opacity ( 0.1 ))
. cornerRadius ( 8 )
}
}
}
private func analyzeReview () {
let analyzer = try! SentimentAnalyzer ()
sentiment = analyzer. analyze (reviewText)
}
}
リアルタイム推論のパフォーマンスチューニング
推論レイテンシの測定
リアルタイムアプリケーション(AR、ビデオ処理)では、推論の遅延が直結してUX が悪化します。
import os . log
class PerformanceMonitor {
let logger = os.log. OSLog ( subsystem : "com.rorklab.inference" , category : "performance" )
func measureInferenceTime < T >(
_ task: () throws -> T
) rethrows -> (result: T, timeMs: Double ) {
let startTime = CACurrentMediaTime ()
let result = try task ()
let endTime = CACurrentMediaTime ()
let timeMs = (endTime - startTime) * 1000
os_log (
"Inference time: %.1f ms" ,
log : logger,
type : .debug,
timeMs
)
return (result, timeMs)
}
}
// 使用例
let monitor = PerformanceMonitor ()
let (output, timeMs) = try monitor. measureInferenceTime {
try model. prediction ( input : modelInput)
}
print ( "推論: \( timeMs ) ms" )
バッチサイズの最適化
複数フレームをまとめて処理することで、スループットを向上させます。
// 1フレーム: 遅延は小さいが、GPU利用率が低い
for frame in frames {
let output = try model. prediction ( input : frameInput)
}
// バッチ処理: 遅延は大きいが、スループット向上
let batchInput = MLMultiArray ( shape : [frames. count , 224 , 224 , 3 ], dataType : .float32)
// ... フレームをbatchInputに詰める ...
let output = try model. prediction ( input : batchInput)
CPU vs GPU vs Neural Engine の選択
Core MLは自動選択しますが、明示的に指定することで微調整可能:
let config = MLModelConfiguration ()
// CPU のみ(精度最高、速度最低)
config.computeUnits = .cpuOnly
// GPU + CPU(バランス型)
config.computeUnits = .cpuAndGPU
// Neural Engine + GPU + CPU(最速)
config.computeUnits = .all
let model = try defect_detection ( configuration : config)
一般的なベンチマーク (iPhone 15 Pro での推定値)
CPU のみ:50-100ms
GPU + CPU:20-30ms
All(Neural Engine):5-10ms
メモリ効率化とスレッド管理
class InferenceQueue {
private let queue = DispatchQueue ( label : "com.rorklab.inference" , qos : .userInitiated)
private let model: defect_detection
init () throws {
self .model = try defect_detection ( configuration : MLModelConfiguration ())
}
func predict (
_ input: defect_detectionInput,
completion : @escaping (Result<defect_detectionOutput, Error >) -> Void
) {
queue. async {
do {
let output = try self .model. prediction ( input : input)
DispatchQueue.main. async {
completion (. success (output))
}
} catch {
DispatchQueue.main. async {
completion (. failure (error))
}
}
}
}
}
モデルのバージョン管理と OTA アップデート
段階的なモデル更新
本番環境では、より精度の高い新モデルリリース時に、既存ユーザーのアプリも更新する必要があります。
課題
App Store レビュー待機時間(1-3日)
全ユーザーの自動更新率は 50% 程度
なお、アプリ本体のほうに最低バージョンの線を引く場合、その判定は文字列比較で書くと 1.10.0 のような版で必ず事故ります。4実装を実測した結果は 1.10.0 の端末が強制アップデートに落ちる — 最小サポートバージョン判定を4実装で実測する に残しました。
解決策:OTA(Over-The-Air)アップデート
App Store のアプリ本体は据え置いたまま、モデルだけをサーバーから配ります。ただ、この節を最初に書いたときの実装は動きませんでした。ダウンロードした .mlmodel を、そのまま MLModel(contentsOf:) へ渡していたからです。
init(contentsOf:) が受け取るのはコンパイル済みモデルだけで、.mlmodel は入力になりません。端末上で MLModel.compileModel(at:) を通し、返ってきた .mlmodelc を読み込むのが正しい順番です。しかもその戻り値は一時的な場所に置かれ、システムの都合で消えます。Apple のガイドが恒久的な場所へ移すよう念を押しているのはそのためです(Downloading and Compiling a Model on the User's Device )。
移動の2行を書き足すまで、私のアプリは起動のたびにモデルを落とし直していました。通信量の請求書ではなく、機内モードでの再現テストで気づいたのは皮肉でした。
端末に降ろしたモデルは、コンパイルして、置き場所を決めるまでが1セットです。 ダウンロードが終わった時点で安心すると、動くには動くのに毎回通信するアプリができあがります。
import CoreML
enum ModelStoreError : Error {
case bundledModelMissing
case checksumMismatch
}
actor ModelStore {
private let bundledName = "defect-detection"
private var cached: MLModel ?
/// 常にどれかのモデルを返します。リモートが落ちていても推論は止めません。
func current ( remote : URL, version : String , sha256 : String ) async -> MLModel ? {
if let cached { return cached }
// 1) すでに導入済みならそれを使う
if let installed = try? load ( version : version) {
cached = installed
return installed
}
// 2) なければ取りに行く
if let fetched = try? await install ( from : remote, version : version, sha256 : sha256) {
cached = fetched
return fetched
}
// 3) 何かあれば同梱モデルへ退避する
cached = try? bundledModel ()
return cached
}
/// メモリ警告時に呼びます
func evict () { cached = nil }
private func install ( from remote: URL, version : String , sha256 : String ) async throws -> MLModel {
// data(from:) はモデル全体をメモリへ載せます。download(from:) を使います
let (downloaded, response) = try await URLSession.shared. download ( from : remote)
defer { try? FileManager.default. removeItem ( at : downloaded) }
guard (response as? HTTPURLResponse) ? .statusCode == 200 else {
throw URLError (.badServerResponse)
}
guard try digest ( of : downloaded) == sha256. lowercased () else {
throw ModelStoreError.checksumMismatch
}
// 端末上でコンパイル。戻り値は一時ディレクトリの .mlmodelc です
let compiled = try await MLModel. compileModel ( at : downloaded)
// 恒久的な場所へ移す。ここを飛ばすと次回起動時には消えています
let fm = FileManager.default
let destination = try modelDirectory (). appendingPathComponent ( " \( version ) .mlmodelc" )
if fm. fileExists ( atPath : destination.path) {
try fm. removeItem ( at : destination)
}
try fm. moveItem ( at : compiled, to : destination)
try? pruneOldVersions ( keeping : version)
return try MLModel ( contentsOf : destination)
}
private func load ( version : String ) throws -> MLModel {
let url = try modelDirectory (). appendingPathComponent ( " \( version ) .mlmodelc" )
guard FileManager.default. fileExists ( atPath : url.path) else {
throw CocoaError (.fileNoSuchFile)
}
return try MLModel ( contentsOf : url)
}
private func bundledModel () throws -> MLModel {
guard let url = Bundle.main. url ( forResource : bundledName, withExtension : "mlmodelc" ) else {
throw ModelStoreError.bundledModelMissing
}
return try MLModel ( contentsOf : url)
}
/// Caches ではなく Application Support に置きます。
/// Caches はシステムの判断で消され、機内で落ちる原因になりました。
private func modelDirectory () throws -> URL {
let fm = FileManager.default
let base = try fm. url ( for : .applicationSupportDirectory, in : .userDomainMask,
appropriateFor : nil , create : true )
let dir = base. appendingPathComponent ( "ml-models" , isDirectory : true )
try fm. createDirectory ( at : dir, withIntermediateDirectories : true )
return dir
}
private func pruneOldVersions ( keeping version: String ) throws {
let fm = FileManager.default
let dir = try modelDirectory ()
for item in try fm. contentsOfDirectory ( at : dir, includingPropertiesForKeys : nil )
where item.lastPathComponent != " \( version ) .mlmodelc" {
try? fm. removeItem ( at : item)
}
}
}
.mlmodelc はファイルではなくディレクトリです。コピーや削除を Data の読み書きで書いていると、ここで静かに食い違います。FileManager のディレクトリ操作で扱ってください。
配信物の検証
配るのが実行されるモデルである以上、途中で差し替えられていないことを確かめてから読み込みます。100 MB のファイルを丸ごとメモリへ載せずに済むよう、ストリーミングでハッシュを取ります。
import CryptoKit
extension ModelStore {
fileprivate func digest ( of url: URL) throws -> String {
let handle = try FileHandle ( forReadingFrom : url)
defer { try? handle. close () }
var hasher = SHA256 ()
while let chunk = try handle. read ( upToCount : 1 << 20 ), ! chunk. isEmpty {
hasher. update ( data : chunk)
}
return hasher. finalize (). map { String ( format : "%02x" , $0 ) }. joined ()
}
}
バージョン管理のベストプラクティス
配信側は、モデルそのものではなくマニフェストを先に返す形にしました。
struct ModelManifest : Codable {
let version: String // 例: "2026-08-11-a"
let url: URL
let sha256: String
let minimumAppVersion: String // 古いアプリへ新しいモデルを降ろさないため
}
運用で決めたことは4つです。
アプリ本体のバージョン比較を文字列で書くと 1.10.0 のような版で事故る話は、上に挙げた記事のほうにまとめてあります。マニフェストの minimumAppVersion を見るときも、同じ比較器を使い回すのが安全です。
プライバシーファーストのAI設計パターン
プライバシーポリシーの実装
オンデバイスAIの最大の利点は、プライバシー保護です。ただし、端末内で推論しているという事実だけでは何も守れません。私は次の3点を、実装のたびに確かめるようにしています。
確かめている3点
推論データの非永続化 :推論に使った画像・テキストをディスクへ残さないこと
バッファの即時解放 :使い終わったバッファをその場で手放すこと
端末内で完結 :利用者の同意なくサーバーへ送らないこと
コード側で送信していなくても、審査で問われるのは同梱した SDK が何を集めているかです。Privacy Manifest を SDK 連鎖まで洗う手順は Rork 生成 Expo アプリの Privacy Manifest を SDK 連鎖まで監査する にまとめてあります。端末内推論を売りにするなら、ここが食い違うと台無しになります。
実装例:セキュアなデータハンドリング
final class SecureMLProcessor {
private let model: defect_detection
init ( model : defect_detection) {
self .model = model
}
func classifyWithoutPersisting ( _ image: UIImage) throws -> String {
guard let buffer = image. pixelBuffer ( size : CGSize ( width : 224 , height : 224 )) else {
throw MLError.conversionFailed
}
// ロックとアンロックは対で書きます。
// ロックしていないバッファを unlock する呼び出しは、それ自体が誤りです
CVPixelBufferLockBaseAddress (buffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress (buffer, .readOnly) }
let output = try model. prediction ( input : defect_detectionInput ( imageAt : buffer))
// 結果だけを返し、入力はどこにも書き出しません
return output.classLabel
}
/// 自分が作ったサブディレクトリの中身だけを消します。
/// Caches そのものを removeItem すると、URLCache や画像ライブラリなど
/// 他の仕組みが使っている領域まで巻き込みます
func deleteInferenceCache () throws {
let fm = FileManager.default
let caches = try fm. url ( for : .cachesDirectory, in : .userDomainMask,
appropriateFor : nil , create : false )
let mine = caches. appendingPathComponent ( "inference" , isDirectory : true )
guard fm. fileExists ( atPath : mine.path) else { return }
for item in try fm. contentsOfDirectory ( at : mine, includingPropertiesForKeys : nil ) {
try fm. removeItem ( at : item)
}
}
}
enum MLError : Error {
case conversionFailed
case modelNotFound
case privacyPolicyViolation
}
deleteAllLocalData() として Caches ディレクトリごと消していた時期があります。推論のキャッシュは確かに消えました。同時に、画像ライブラリが握っていたサムネイルまで消えて、一覧が真っ白になる不具合を自分で作りました。消す範囲は、自分が作った場所に限るのが安全です。
ユーザー許可の管理
カメラやマイクを使用する場合、明示的なユーザー許可が必須:
import AVFoundation
class CameraPermissionManager {
static func requestCameraAccess ( completion : @escaping ( Bool ) -> Void ) {
AVCaptureDevice. requestAccess ( for : .video) { granted in
DispatchQueue.main. async {
completion (granted)
}
}
}
static func requestMicrophoneAccess ( completion : @escaping ( Bool ) -> Void ) {
AVCaptureDevice. requestAccess ( for : .audio) { granted in
DispatchQueue.main. async {
completion (granted)
}
}
}
}
// 使用前のチェック
CameraPermissionManager. requestCameraAccess { granted in
if granted {
startCameraSession ()
} else {
showPermissionAlert ()
}
}
ローカルオンリーポリシーの実装
struct PrivacyPolicyCompliance {
var allowDataSharing = false
var allowAnalytics = false // 推論データの分析は行わない
var dataRetentionDays = 0 // 推論データは保持しない
/// assert は Release ビルドで消えます。
/// 出荷後も効かせたい確認は guard で書き、throw で止めます
func validateBeforePrediction () throws {
guard ! allowDataSharing, ! allowAnalytics, dataRetentionDays == 0 else {
throw MLError.privacyPolicyViolation
}
}
}
ここは長いあいだ assert で書いていました。デバッグビルドでは狙いどおり止まり、テストも通ります。ところが assert は最適化時に取り除かれる仕組みで、出荷したアプリの中では 1 行も実行されていません。
Release ビルドで消える検査は、検査ではありません。 手元で止まってくれたことに満足して、いちばん止まってほしい場所を素通りさせていました。
どこから手を付けるか
この記事で一番効いた実装をひとつ選ぶなら、selectInferenceTarget() です。モデルの精度を上げる作業よりも先に、推論先の判断を1ファイルへ閉じ込めておく。それだけで後の変更がずっと軽くなります。フォールバックを 'core-ml' 側に倒してあるので、条件を足していくうちに情報が外へ漏れる、という壊れ方もしません。
量子化も OTA も、その土台が決まってから取りかかって遅くはありませんでした。
まずは localHitRate() だけをアプリへ仕込み、1週間の端末内処理率を見てみてください。数字が出た瞬間に、どのリクエストを端末へ引き戻すべきかが自分の手元のデータで見えてきます。
Core ML まわりは Apple の更新で前提が変わりやすい領域です。私自身まだ追いかけている途中ですので、新しい実測が取れたら書き足していきます。お読みいただきありがとうございました。