Core
ML is how apps on Apple devices run machine-learning models. A Core
ML model file (.mlmodel) is one Protocol Buffers message,
Model, defined in Model.proto in Apple’s coremltools repository.
The schema covers neural networks, tree ensembles, linear models,
support vector machines and pipelines of them.
This article opens a small Core ML classifier, a decision tree for
R’s iris data, and rebuilds its tree as a data frame. With
the tree in hand, R can make the same predictions Core ML does, with no
Apple software at all.
The output below comes from a run on 27 October 2026. The code is not run when the site is built.
The schema
Model.proto imports one file per kind of model, 33 files
in all. This helper downloads them from coremltools, following the
imports, at one release.
fetch_coreml_protos <- function(dir, ref = "9.0") {
base <- paste0(
"https://raw.githubusercontent.com/apple/coremltools/",
ref,
"/mlmodel/format/"
)
dir.create(dir, recursive = TRUE, showWarnings = FALSE)
todo <- "Model.proto"
done <- character()
while (length(todo) > 0) {
f <- todo[[1]]
todo <- todo[-1]
if (f %in% done) {
next
}
download.file(paste0(base, f), file.path(dir, f), quiet = TRUE)
imports <- grep("^import ", readLines(file.path(dir, f)), value = TRUE)
todo <- c(todo, sub('^import (public )?"(.*)";.*$', "\\2", imports))
done <- c(done, f)
}
done
}
dir <- tempfile("coreml-")
protos <- fetch_coreml_protos(file.path(dir, "protos"))
length(protos)
#> [1] 33protoc, the protobuf compiler, turns them into one
descriptor set. zuproto itself does not need protoc: on
macOS, brew install protobuf; on Debian and Ubuntu,
apt install protobuf-compiler.
The model
The model was made in Python, by training a scikit-learn decision
tree on iris and converting it with coremltools. R wrote
the data first, with
write.csv(iris, "iris.csv", row.names = FALSE):
import sys
import numpy as np
import coremltools as ct
from sklearn.tree import DecisionTreeClassifier
cols = ["Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width"]
x = np.genfromtxt(sys.argv[1], delimiter=",", skip_header=1, usecols=range(4))
y = np.genfromtxt(sys.argv[1], delimiter=",", skip_header=1, usecols=4, dtype=str)
y = np.char.strip(y, '"')
tree = DecisionTreeClassifier(max_depth=3, random_state=1).fit(x, y)
model = ct.converters.sklearn.convert(tree, cols, "Species")
model.short_description = "A decision tree for iris species"
model.save(sys.argv[2])The file is under a kilobyte.
file.size("iris-tree.mlmodel")
#> [1] 912
m <- proto_decode(
"iris-tree.mlmodel",
"CoreML.Specification.Model",
pool,
defaults = FALSE
)
names(m)
#> [1] "specificationVersion" "description" "pipelineClassifier"A Model holds a description of its inputs and outputs,
and exactly one kind of model, a oneof in the schema. This
one is a pipelineClassifier. The description is what Xcode
shows for a model.
str(m$description$metadata)
#> List of 2
#> $ shortDescription: chr "A decision tree for iris species"
#> $ userDefined : Named chr [1:2] "scikit-learn==1.5.1" "9.0"
#> ..- attr(*, "names")= chr [1:2] "com.github.apple.coremltools.source" "com.github.apple.coremltools.version"
vapply(m$description$input, \(f) f$name, "")
#> [1] "Sepal.Length" "Sepal.Width" "Petal.Length" "Petal.Width"
vapply(m$description$output, \(f) names(f$type), "")
#> [1] "stringType" "dictionaryType"The pipeline
The pipeline has two models: a featureVectorizer that
puts the four inputs into one vector, and the tree itself, a
treeEnsembleClassifier.
steps <- m$pipelineClassifier$pipeline$models
vapply(steps, \(s) setdiff(names(s), c("specificationVersion", "description")), "")
#> [1] "featureVectorizer" "treeEnsembleClassifier"
classifier <- steps[[2]]$treeEnsembleClassifier
classes <- classifier$stringClassLabels$vector
classes
#> [1] "setosa" "versicolor" "virginica"The tree as a table
The tree is a list of nodes. Decoding again with the defaults kept
fills in every field, so each node has the same shape and the list
becomes a data frame. nodeBehavior is an enum, so it
decodes to a factor.
full <- proto_decode("iris-tree.mlmodel", "CoreML.Specification.Model", pool)
nodes <- full$pipelineClassifier$pipeline$models[[2]]$
treeEnsembleClassifier$treeEnsemble$nodes
tree <- data.frame(
id = vapply(nodes, \(n) n$nodeId, 0),
behavior = vapply(nodes, \(n) as.character(n$nodeBehavior), ""),
feature = vapply(nodes, \(n) n$branchFeatureIndex, 0) + 1,
threshold = vapply(nodes, \(n) n$branchFeatureValue, 0),
if_true = vapply(nodes, \(n) n$trueChildNodeId, 0),
if_false = vapply(nodes, \(n) n$falseChildNodeId, 0)
)
inputs <- vapply(m$description$input, \(f) f$name, "")
tree$feature <- inputs[tree$feature]
leaf <- tree$behavior == "LeafNode"
tree[leaf, c("feature", "threshold", "if_true", "if_false")] <- NA
tree
#> id behavior feature threshold if_true if_false
#> 1 0 BranchOnValueLessThanEqual Petal.Width 0.80 1 2
#> 2 1 LeafNode <NA> NA NA NA
#> 3 2 BranchOnValueLessThanEqual Petal.Width 1.75 3 6
#> 4 3 BranchOnValueLessThanEqual Petal.Length 4.95 4 5
#> 5 4 LeafNode <NA> NA NA NA
#> 6 5 LeafNode <NA> NA NA NA
#> 7 6 BranchOnValueLessThanEqual Petal.Length 4.85 7 8
#> 8 7 LeafNode <NA> NA NA NA
#> 9 8 LeafNode <NA> NA NA NAA leaf’s evaluationInfo gives the probability of each
class there.
leaf_probs <- t(vapply(
Filter(\(n) n$nodeBehavior == "LeafNode", nodes),
\(n) {
p <- numeric(length(classes))
for (e in n$evaluationInfo) p[e$evaluationIndex + 1] <- e$evaluationValue
p
},
numeric(length(classes))
))
dimnames(leaf_probs) <- list(tree$id[leaf], classes)
round(leaf_probs, 3)
#> setosa versicolor virginica
#> 1 1 0.000 0.000
#> 4 0 0.979 0.021
#> 5 0 0.333 0.667
#> 7 0 0.333 0.667
#> 8 0 0.000 1.000Predicting in R
Walking the tree from its root, taking the true child whenever a flower’s measurement is at or below the node’s threshold, gives Core ML’s answer.
predict_tree <- function(x) {
node <- tree[tree$id == 0, ]
while (node$behavior != "LeafNode") {
go_true <- x[[node$feature]] <= node$threshold
node <- tree[tree$id == if (go_true) node$if_true else node$if_false, ]
}
classes[which.max(leaf_probs[as.character(node$id), ])]
}
predicted <- vapply(seq_len(nrow(iris)), \(i) predict_tree(iris[i, ]), "")
table(predicted = predicted, actual = iris$Species)
#> actual
#> predicted setosa versicolor virginica
#> setosa 50 0 0
#> versicolor 0 47 1
#> virginica 0 3 49The tree gets 146 of the 150 flowers right. These predictions match
what Core ML returns for the same model, checked on a Mac with
MLModel.predict() in coremltools.
Writing a model back
proto_encode() writes the message back. Here the model
gets an author and a licence in its metadata, the fields Xcode shows,
and is saved as a new model file. Core ML loads it and makes the same
predictions.
m$description$metadata$author <- "zuproto article"
m$description$metadata$license <- "CC0"
out <- file.path(dir, "iris-tree-annotated.mlmodel")
writeBin(proto_encode(m, "CoreML.Specification.Model", pool), out)
proto_decode(out, "CoreML.Specification.Model", pool)$description$metadata[
c("author", "license")
]
#> $author
#> [1] "zuproto article"
#>
#> $license
#> [1] "CC0"