Table of Contents
- The problem
- Design decisions and alternatives
- A real bug: a model file that crashed the app
- Usage
- Limits
The problem
Core ML is good at running a model that someone already trained elsewhere. It does not help much
when an app developer has a table of numbers and wants to train a logistic regression or a random
forest on it, in Swift, on the device, and then measure how good the model is. Apple's own tool for that is Create ML: since iOS 15 its framework trains tabular classifiers and regressors on device as well
as on the Mac. The other usual answers are a Python toolchain with a conversion step, or a server.
SwiftAI is a Swift package for that narrower job: classification, regression, clustering and
dimensionality reduction on tables of numbers. You load data from CSV text or arrays, split it,
train a model with one function call, measure it, and save it as JSON to load in your app. The
package has no dependencies.
The current library is version 2.0.0, released on 14 October 2026. It is not an evolution of
1.0.0. The repository was opened in August 2025 as a broad "AI framework" with app-architecture
layers and Vision and NLP code. Version 1.0.0 was released on 2 February 2026; three days later the
app-architecture modules were deleted and a smaller machine learning core was written in their
place. In June 2026 that core was trimmed: the tests shrank to one placeholder file and a few small
stub layers were added. In October 2026 the library was rebuilt from scratch. The 2.0.0 changelog
entry opens with that: no 1.0.0 API remains, and dependencies such as Alamofire are gone.
Design decisions and alternatives
Plain Swift instead of Create ML. Create ML is the obvious Apple alternative. SwiftAI differs
in ways the repository shows: every algorithm is Swift source you can read; a
trained model is a Codable value saved as JSON and validated when decoded; every random step
takes a seed; the results are tested against scikit-learn 1.9.1; and the package supports tvOS 15
and watchOS 8, while Apple's documentation lists Create ML from tvOS 16 and not for watchOS.
One flat, validated matrix. Matrix stores rows, columns and a single row-major
[Double]. The alternative, [[Double]], is simpler to write but allocates every row separately
and cannot guarantee that all rows have the same length. A contiguous buffer can be handed straight
to vDSP_mmulD, and the shape is checked once, when data enters the package. A test checks that
the Accelerate product and the portable product agree on random matrices.
Trained models are immutable values made by static func fit. scikit-learn uses a mutable
object and model.fit(X, y). In Swift that would mean var everywhere and an "is this fitted?"
check in every prediction. With LogisticRegression.fit(data) returning a new value, a model you
hold is always trained, and as a value type it can be shared across threads.
Protocols carry the shared behaviour. Classifiers conform to Classifier (and
ProbabilisticClassifier), regressors to Regressor. accuracy(on:) and evaluate(on:) live in
protocol extensions, so cross-validation, Pipeline and the reports work with every model.
Throw at the boundary, trust inside. Every function that takes data or settings validates them
and throws a SwiftAIError that says what to change: NaN values, wrong feature counts, sums that
overflow, settings out of range, CSV lines that are not numbers. Crashing on a NaN is not acceptable in a mobile app; returning optionals everywhere would
lose the reason.
Least squares with Householder QR, not the normal equations. The normal equations work on XᵀX,
whose condition number is the square of the data's. QR works on the data itself, so nearly
collinear features keep more correct digits. Logistic regression uses L-BFGS on the same objective
as scikit-learn (cross-entropy with an L2 penalty), which makes the results comparable.
A reference without a Python dependency. The tests do not run scikit-learn. They contain
numbers taken from scikit-learn 1.9.1 as constants and compare against them: logistic regression
probabilities to 1e-5, PCA to 1e-10. CI stays pure Swift, and "is the arithmetic right?" still has
an outside answer.
Reproducible randomness. Splits, bootstrap samples, k-means++ seeding and network weights all
draw from SeededGenerator, a SplitMix64 generator. The package uses its own index sampling and
Fisher–Yates shuffle instead of the standard library's random(in:using:) and shuffle(using:),
so the sequence does not depend on the toolchain. The random forest draws one seed per tree in
order before training the trees in parallel with DispatchQueue.concurrentPerform, and a test
checks that the same seed gives the same forest.
Trees as arrays whose children come after their parent. A decision tree is stored as an array
of nodes, root first, and every child index is larger than its parent's. Because of that
invariant, walking from the root to a leaf
ends after at most nodes.count steps. A class-based tree would be easier to write, but it would
need extra work for Codable, Equatable and Sendable, and a loaded file could contain a cycle.
A real bug: a model file that crashed the app
Making every model Codable means a saved model can arrive from a file or from the network, so
decoding is a trust boundary. The first version of Matrix decoding compared values.count with
rows * columns. Commit
`e78b273` describes what went wrong: a
file with rows and columns of 2^32 each (or 2^62 and 4) made that product overflow, and integer
overflow traps in Swift. Reading a crafted model file crashed the app instead of throwing.
The fix is a small helper in Core/Validation.swift:
1func checkedProduct(_ lhs: Int, _ rhs: Int) -> Int? {2 guard lhs >= 0, rhs >= 0 else { return nil }3 let (product, overflow) = lhs.multipliedReportingOverflow(by: rhs)4 return overflow ? nil : product5}Decoding now throws DecodingError when checkedProduct(rows, columns) does not equal the value
count, and the initializers stop with a message that names the shape. A parameterized test feeds
the 2^32 × 2^32, 2^62 × 4 and negative shapes to JSONDecoder and expects an error, not a trap.
The same class of problem showed up elsewhere. The release notes say that tree decoding rejects
child links that point backwards, which could otherwise make prediction loop forever, and that
neural network layers that do not fit together are rejected; in the code, Perceptron.swift counts
the network's parameters with the same checkedProduct. A related fix,
`278f246`, made the regression metrics
(MSE, MAE and R²) throw on NaN and infinite values instead of returning a NaN score. The same
commit made logLoss, a classification metric, reject NaN probability rows, which used to pass
through its clipping because max(NaN, eps) is NaN.
Two smaller lessons followed. The old .gitignore was a generic template with
machine learning entries such as models/; on a case-insensitive file system that pattern also
matched Sources/SwiftAI/Models, so the model protocols could not be committed until the file was
replaced (`be68a92`). And in
PR #19 the iOS build failed because the
example used any Classifier<String>, a parameterized existential that needs the iOS 16 runtime
while the package supports iOS 15. A small wrapper with a prediction closure replaced it
(`17235ce`).
Usage
Add the package by its URL, https://github.com/muhittincamdali/SwiftAI.git, in Xcode or in
Package.swift with from: "2.0.0". The README's quick start trains a random forest on the 150 iris flowers that ship
with the package:
1import SwiftUI2import SwiftAI3 4struct ContentView: View {5 var body: some View {6 Text(irisSummary())7 .padding()8 }9 10 func irisSummary() -> String {11 do {12 // 1. Split the built-in iris flowers into training and test samples.13 let (training, test) = try Dataset.iris.stratifiedSplit(testFraction: 0.2, seed: 42)14 // 2. Train a random forest on the training samples.15 let model = try RandomForestClassifier.fit(training, seed: 42)16 // 3. Measure it on the test samples and classify a new flower.17 let accuracy = try model.accuracy(on: test)18 let species = try model.predict([6.3, 2.8, 5.1, 1.5])19 return "Test accuracy: \(accuracy.formatted(.percent))\nNew flower: \(species)"20 } catch {21 return error.localizedDescription22 }23 }24}When features need scaling, Pipeline fits the scaler on the training data only, so test data
does not leak into it, and scales every later sample the same way:
1let (training, test) = try Dataset.iris.stratifiedSplit(testFraction: 0.3, seed: 1)2let model = try Pipeline.fit(training, transformer: StandardScaler.fit) { scaled in3 try LogisticRegression.fit(scaled)4}5let report = try model.evaluate(on: test)6print(report) // precision, recall and F1 per class7print(report.confusionMatrix)The samples live in
Examples/ModelLab/ModelLab/ReadmeSamples.swift; the ModelLab example app compiles that file, and
the test target compiles it through a symlink and runs every sample, so a README sample that stops
compiling breaks the tests. At the v2.0.0 tag, swift test runs 133 tests in 20 suites.
Limits
- Numbers only.
Datasetholds[Double]features. There are no categorical columns, no
missing values (NaN is rejected) and no sparse matrices; in a CSV file every column except the
target must hold numbers.
- Small data. There is no published benchmark, and k-nearest neighbours is brute force. Large
datasets are not the target.
- Not deep learning.
NeuralNetworkClassifierandNeuralNetworkRegressorare small
multilayer perceptrons. Convolutional and recurrent
networks, and GPU training, are out of scope.
- Missing models. The README roadmap lists linear support vector machines, lasso and
elastic-net regression, a polynomial-features transformer, gradient-boosted trees and Core ML
export of linear models and trees. None of them exist yet.
- Structural checks, not signatures. Loading a model file checks shapes, values and structure.
It does not verify where the file came from.
- A recent toolchain. The package uses
swift-tools-version: 6.2, so consumers need Xcode 26
or later. The ModelLab example app needs iOS 17 or macOS 14.
Source code: github.com/muhittincamdali/SwiftAI
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

