All Articles
CategoryAI
Reading Time
9 min read
Published
2026-10-15
Word Count
1,624words

Grab a coffee — this one is a deep dive!

SwiftAI: classical machine learning in pure Swift, with model files you can trust

Summary

How SwiftAI 2.0 trains, evaluates and saves models on device without dependencies, and why decoding a model file is treated as untrusted input.

  • Core ML is good at running a model that someone already trained elsewhere.
  • Create ML is the obvious Apple alternative.
  • Add the package by its URL, https://github.com/muhittincamdali/SwiftAI.git, in Xcode or in Package.swift with from: "2.0.0".
  • Source code: [github.com/muhittincamdali/SwiftAI](https://github.com/muhittincamdali/SwiftAI)
SwiftAI: classical machine learning in pure Swift, with model files you can trust

Table of Contents


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:

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 : product
5}

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:

swift
1import SwiftUI
2import SwiftAI
3 
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.localizedDescription
22 }
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:

swift
1let (training, test) = try Dataset.iris.stratifiedSplit(testFraction: 0.3, seed: 1)
2let model = try Pipeline.fit(training, transformer: StandardScaler.fit) { scaled in
3 try LogisticRegression.fit(scaled)
4}
5let report = try model.evaluate(on: test)
6print(report) // precision, recall and F1 per class
7print(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. Dataset holds [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. NeuralNetworkClassifier and NeuralNetworkRegressor are 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

#swift#machine-learning#swift-package#on-device#codable
Muhittin Çamdalı

Muhittin Çamdalı

Senior Mobile Engineer

Senior Mobile Engineer with 12+ years of experience. I build mobile products with iOS, Flutter and React Native, working full-stack and AI-native.

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

Share