Anomaly Detection
Overview
The package smile.anomaly provides unsupervised and semi-supervised anomaly detection. Scala and Kotlin expose package-level helpers (isolationforest / isolationForest, lof, ocsvm) with the same defaults as the Java Options constructors.
Isolation Forest (IsolationForest) — a tree-ensemble, unsupervised anomaly scorer for numeric tabular data.
Local Outlier Factor (LOF<T>) — density-based local outlier detection via reachability ratios.
One-Class SVM (SVM<T>) — kernel-based novelty detection that learns the support of a high-dimensional distribution.
| Class | Algorithm | Score direction |
|---|---|---|
IsolationForest | Random-partition tree ensemble | Higher → more anomalous |
LOF<T> | Local reachability density ratio | Higher (≫ 1) → more anomalous |
SVM<T> | One-class support vector machine | Lower (negative) → more anomalous |
All classes are Serializable and support round-trip persistence via smile.io.Write.object / smile.io.Read.object.
When to Use Which Method
Use Isolation Forest when the data is numeric tabular (double[][]), you need a fast scalable unsupervised baseline, you want intuitive scores in (0, 1] where values above roughly 0.5 flag anomalies, or you want to experiment with extended hyperplanes via extensionLevel.
Use LOF when anomalies are local — points that are sparse relative to a nearby denser neighborhood — or when clusters have very different densities so a global threshold fails.
Use One-Class SVM when you need flexible non-linear normality boundaries via custom kernels, the training data is uncontaminated (no outliers — the SVM learns a tight hypersphere around it), and the data size is moderate (kernel methods scale as O(n²) in memory and time).
Isolation Forest
Class: smile.anomaly.IsolationForest. Isolation Forest recursively partitions samples with random splits; anomalies tend to be isolated in fewer partitions and therefore receive shorter average path lengths (higher anomaly scores). Extended Isolation Forest replaces axis-aligned splits with random-slope hyperplanes controlled by extensionLevel.
Quick Start
import smile.anomaly.IsolationForest;
double[][] train = {
{0.0, 0.0}, {0.1, -0.1}, {-0.05, 0.05},
{0.05, 0.08}, {-0.08, -0.03}
};
// Fit with default options (100 trees, extensionLevel=0)
IsolationForest model = IsolationForest.fit(train);
// Score individual points — higher means more anomalous
double inlierScore = model.score(new double[] { 0.02, 0.01});
double outlierScore = model.score(new double[] { 6.0, -6.0});
System.out.printf("inlier score = %.4f%n", inlierScore);
System.out.printf("outlier score = %.4f%n", outlierScore);
inlier score ≈ 0.38
outlier score ≈ 0.82
import smile.anomaly._
val train = Array(
Array(0.0, 0.0), Array(0.1, -0.1), Array(-0.05, 0.05),
Array(0.05, 0.08), Array(-0.08, -0.03)
)
val model = isolationforest(train)
val inlierScore = model.score(Array(0.02, 0.01))
val outlierScore = model.score(Array(6.0, -6.0))
println(f"inlier score = $inlierScore%.4f")
println(f"outlier score = $outlierScore%.4f")
inlier score ≈ 0.38
outlier score ≈ 0.82
import smile.anomaly.isolationForest
val train = arrayOf(
doubleArrayOf(0.0, 0.0), doubleArrayOf(0.1, -0.1),
doubleArrayOf(-0.05, 0.05), doubleArrayOf(0.05, 0.08),
doubleArrayOf(-0.08, -0.03)
)
val model = isolationForest(train)
val inlierScore = model.score(doubleArrayOf(0.02, 0.01))
val outlierScore = model.score(doubleArrayOf(6.0, -6.0))
println("inlier score = %.4f".format(inlierScore))
println("outlier score = %.4f".format(outlierScore))
inlier score ≈ 0.38
outlier score ≈ 0.82
Options
Hyperparameters are fields of the nested IsolationForest.Options record. Pass an Options instance to fit, or a java.util.Properties map using the keys below (Options.toProperties() / Options.of(props) round-trip).
import smile.anomaly.IsolationForest;
import java.util.Properties;
double[][] train = { /* ... */ };
var options = new IsolationForest.Options(
200, // ntrees
0, // maxDepth (0 = auto)
0.7, // subsample
0 // extensionLevel
);
IsolationForest model = IsolationForest.fit(train, options);
Properties props = options.toProperties();
var loaded = IsolationForest.Options.of(props);
assert options.equals(loaded);
import smile.anomaly._
val train: Array[Array[Double]] = ??? // your data
val model = isolationforest(train, trees = 200, maxDepth = 0,
subsample = 0.7, extensionLevel = 0)
val options = new IsolationForest.Options(200, 0, 0.7, 0)
val props = options.toProperties
val loaded = IsolationForest.Options.of(props)
assert(options == loaded)
import smile.anomaly.isolationForest
import smile.anomaly.IsolationForest
val train: Array<DoubleArray> = TODO() // your data
val model = isolationForest(train, trees = 200, maxDepth = 0,
subsample = 0.7, extensionLevel = 0)
val options = IsolationForest.Options(200, 0, 0.7, 0)
val props = options.toProperties()
val loaded = IsolationForest.Options.of(props)
check(options == loaded)
Extension Level Semantics
The extensionLevel controls how many feature dimensions participate in the random splitting hyperplane:
| extensionLevel | Behaviour |
|---|---|
0 | Standard Isolation Forest — axis-aligned splits |
1 … p−2 | Intermediate — hyperplanes in an (extensionLevel+1)-dimensional subspace |
p−1 | Fully extended — hyperplanes with random slopes in all dimensions |
Valid range is [0, p−1], where p is input dimensionality. Setting extensionLevel ≥ p raises IllegalArgumentException. Read the fitted level back with model.extensionLevel().
import smile.anomaly.IsolationForest;
double[][] data = { /* p = 2 */ };
// Standard Isolation Forest (extensionLevel = 0)
var std = IsolationForest.fit(data, new IsolationForest.Options(100, 0, 0.7, 0));
System.out.println(std.extensionLevel()); // 0
// Extended Isolation Forest
var ext = IsolationForest.fit(data, new IsolationForest.Options(100, 0, 0.7, 1));
System.out.println(ext.extensionLevel()); // 1
0
1
import smile.anomaly._
val data: Array[Array[Double]] = ??? // p = 2
val std = isolationforest(data, extensionLevel = 0)
println(std.extensionLevel) // 0
val ext = isolationforest(data, extensionLevel = 1)
println(ext.extensionLevel) // 1
0
1
import smile.anomaly.isolationForest
val data: Array<DoubleArray> = TODO() // p = 2
val std = isolationForest(data, extensionLevel = 0)
println(std.extensionLevel()) // 0
val ext = isolationForest(data, extensionLevel = 1)
println(ext.extensionLevel()) // 1
0
1
Batch Scoring and Prediction
// Batch score — runs in parallel
double[] scores = model.score(batchData);
// One-step predict: true → anomaly
boolean isAnomaly = model.predict(x, 0.6); // threshold in (0.5, 1.0)
val scores = model.score(batchData)
val isAnomaly = model.predict(x, 0.6)
val scores = model.score(batchData)
val isAnomaly = model.predict(x, 0.6)
predict(double[] x, double threshold) returns true when score(x) > threshold. Typical thresholds are in (0.5, 0.8) depending on the expected contamination rate.
Persistence
import smile.io.Read;
import smile.io.Write;
import java.nio.file.Path;
Path path = Write.object(model);
IsolationForest loaded = (IsolationForest) Read.object(path);
import smile.io.{Read, Write}
val path = Write.object(model)
val loaded = Read.object(path).asInstanceOf[IsolationForest]
import smile.io.Read
import smile.io.Write
val path = Write.object(model)
val loaded = Read.object(path) as IsolationForest
Local Outlier Factor (LOF)
Class: smile.anomaly.LOF<T>. LOF compares the local reachability density of a point to that of its neighbors. Scores near 1 are typical inliers; scores much larger than 1 indicate local outliers. Scala/Kotlin: lof(x, k = 20).
import smile.anomaly.LOF;
double[][] train = {
{0.0, 0.0}, {0.1, -0.1}, {-0.05, 0.05},
{0.05, 0.08}, {-0.08, -0.03}, {5.0, 5.0}
};
LOF<double[]> model = LOF.fit(train, 4);
System.out.printf("inlier LOF = %.4f%n", model.score(train[0]));
System.out.printf("outlier LOF = %.4f%n", model.score(train[5]));
import smile.anomaly._
val train = Array(
Array(0.0, 0.0), Array(0.1, -0.1), Array(-0.05, 0.05),
Array(0.05, 0.08), Array(-0.08, -0.03), Array(5.0, 5.0))
val model = lof(train, k = 4)
println(f"inlier LOF = ${model.score(train(0))}%.4f")
println(f"outlier LOF = ${model.score(train(5))}%.4f")
import smile.anomaly.lof
val train = arrayOf(
doubleArrayOf(0.0, 0.0), doubleArrayOf(0.1, -0.1),
doubleArrayOf(-0.05, 0.05), doubleArrayOf(0.05, 0.08),
doubleArrayOf(-0.08, -0.03), doubleArrayOf(5.0, 5.0))
val model = lof(train, k = 4)
println("inlier LOF = %.4f".format(model.score(train[0])))
println("outlier LOF = %.4f".format(model.score(train[5])))
Optional metric and custom neighbor-search overloads: lof(x, distance, k) and lof(x, nns, k).
One-Class SVM
Class: smile.anomaly.SVM<T> (extends smile.model.svm.KernelMachine<T>). One-class SVM identifies a hypersphere that encloses the training distribution. It is sensitive to outliers — prefer clean training data for novelty detection.
Quick Start
import smile.anomaly.SVM;
import smile.math.kernel.GaussianKernel;
double[][] train = {
{0.0, 0.0}, {0.1, -0.1}, {-0.05, 0.05},
{0.05, 0.08}, {-0.08, -0.03}
};
SVM<double[]> model = SVM.fit(
train,
new GaussianKernel(1.0),
new SVM.Options(0.2, 1E-3)
);
// Positive → inlier, Negative → anomaly
double inlierScore = model.score(new double[] { 0.02, 0.01});
double outlierScore = model.score(new double[] { 4.0, -4.0});
System.out.printf("inlier score = %.4f%n", inlierScore);
System.out.printf("outlier score = %.4f%n", outlierScore);
inlier score ≈ 0.45
outlier score ≈ −0.72
import smile.anomaly._
import smile.math.kernel.GaussianKernel
val train = Array(
Array(0.0, 0.0), Array(0.1, -0.1), Array(-0.05, 0.05),
Array(0.05, 0.08), Array(-0.08, -0.03)
)
val model = ocsvm(train, new GaussianKernel(1.0), nu = 0.2, tol = 1E-3)
val inlierScore = model.score(Array(0.02, 0.01))
val outlierScore = model.score(Array(4.0, -4.0))
println(f"inlier score = $inlierScore%.4f")
println(f"outlier score = $outlierScore%.4f")
inlier score ≈ 0.45
outlier score ≈ −0.72
import smile.anomaly.ocsvm
import smile.math.kernel.GaussianKernel
val train = arrayOf(
doubleArrayOf(0.0, 0.0), doubleArrayOf(0.1, -0.1),
doubleArrayOf(-0.05, 0.05), doubleArrayOf(0.05, 0.08),
doubleArrayOf(-0.08, -0.03)
)
val model = ocsvm(train, GaussianKernel(1.0), nu = 0.2, tol = 1E-3)
val inlierScore = model.score(doubleArrayOf(0.02, 0.01))
val outlierScore = model.score(doubleArrayOf(4.0, -4.0))
println("inlier score = %.4f".format(inlierScore))
println("outlier score = %.4f".format(outlierScore))
inlier score ≈ 0.45
outlier score ≈ −0.72
Score convention: Unlike IsolationForest, SVM.score() returns the raw decision function value: positive = inlier, negative = anomaly. Any smile.math.kernel.MercerKernel<T> is accepted — commonly GaussianKernel(sigma), optionally PolynomialKernel or LinearKernel.
Options
Hyperparameters are fields of the nested SVM.Options record. Pass an Options instance to fit, or a java.util.Properties map using the keys below (Options.toProperties() / Options.of(props) round-trip). Java: SVM.fit(x, kernel[, options]). Scala/Kotlin: ocsvm(x, kernel, nu = 0.5, tol = 1E-3).
import smile.anomaly.SVM;
import java.util.Properties;
var opts = new SVM.Options(0.1, 1E-4); // 10% contamination budget
Properties props = opts.toProperties();
SVM.Options loaded = SVM.Options.of(props);
assert opts.equals(loaded);
import smile.anomaly._
import smile.math.kernel.GaussianKernel
val model = ocsvm(train, new GaussianKernel(1.0), nu = 0.1, tol = 1E-4)
val opts = new SVM.Options(0.1, 1E-4)
val loaded = SVM.Options.of(opts.toProperties)
assert(opts == loaded)
import smile.anomaly.ocsvm
import smile.anomaly.SVM
import smile.math.kernel.GaussianKernel
val model = ocsvm(train, GaussianKernel(1.0), nu = 0.1, tol = 1E-4)
val opts = SVM.Options(0.1, 1E-4)
val loaded = SVM.Options.of(opts.toProperties())
check(opts == loaded)
Batch Scoring and Prediction
// Batch score — runs in parallel
double[] scores = model.score(batchSamples);
// One-step predict: true → anomaly
// threshold = 0.0 uses the natural SVM decision boundary
boolean isAnomaly = model.predict(x, 0.0);
val scores = model.score(batchSamples)
val isAnomaly = model.predict(x, 0.0)
val scores = model.score(batchSamples)
val isAnomaly = model.predict(x, 0.0)
predict(T x, double threshold) returns true when score(x) < threshold. Use 0.0 as the natural decision boundary; lower (negative) thresholds tolerate borderline cases.
Persistence
import smile.io.Read;
import smile.io.Write;
import java.nio.file.Path;
Path path = Write.object(model);
@SuppressWarnings("unchecked")
SVM<double[]> loaded = (SVM<double[]>) Read.object(path);
import smile.io.{Read, Write}
import smile.anomaly.SVM
val path = Write.object(model)
val loaded = Read.object(path).asInstanceOf[SVM[Array[Double]]]
import smile.io.Read
import smile.io.Write
import smile.anomaly.SVM
val path = Write.object(model)
@Suppress("UNCHECKED_CAST")
val loaded = Read.object(path) as SVM<DoubleArray>
Score Conventions and Thresholding
| Model | score() return | Anomaly direction |
|---|---|---|
IsolationForest | (0, 1] | Higher → anomaly |
SVM | any real (f(x) − b) | Lower (negative) → anomaly |
Data-Driven Threshold
When no labelled data is available, select a threshold from training scores using the expected contamination rate:
import smile.anomaly.IsolationForest;
import java.util.Arrays;
// IsolationForest — top `contamination` fraction flagged
double[] scores = model.score(train);
Arrays.sort(scores);
double contamination = 0.05; // 5% outliers
int idx = (int) ((1.0 - contamination) * (scores.length - 1));
double threshold = scores[idx];
boolean flag = model.predict(xNew, threshold);
import smile.anomaly.IsolationForest
import java.util.Arrays
val scores = model.score(train)
Arrays.sort(scores)
val contamination = 0.05
val idx = ((1.0 - contamination) * (scores.length - 1)).toInt
val threshold = scores(idx)
val flag = model.predict(xNew, threshold)
import smile.anomaly.IsolationForest
import java.util.Arrays
val scores = model.score(train)
Arrays.sort(scores)
val contamination = 0.05
val idx = ((1.0 - contamination) * (scores.size - 1)).toInt()
val threshold = scores[idx]
val flag = model.predict(xNew, threshold)
For SVM, sort in ascending order and take the contamination-th percentile from the bottom (most-negative end), then use score(x) < threshold.
Validation and Error Handling
IsolationForest.fit
| Condition | Exception |
|---|---|
data == null or data.length < 2 | IllegalArgumentException |
Any row is null | IllegalArgumentException |
| Rows have inconsistent length | IllegalArgumentException |
extensionLevel ≥ p | IllegalArgumentException |
subsample not in (0, 1) | IllegalArgumentException (from Options) |
IsolationForest.score
| Condition | Exception |
|---|---|
x == null | IllegalArgumentException |
x.length != p | IllegalArgumentException — message: "Invalid input dimension: expected <p>, actual <n>" |
SVM.fit
| Condition | Exception |
|---|---|
x == null or x.length == 0 | IllegalArgumentException |
kernel == null | IllegalArgumentException |
options == null | IllegalArgumentException |
nu not in (0, 1] | IllegalArgumentException (from Options) |
tol ≤ 0 | IllegalArgumentException (from Options) |
End-to-End Examples
Isolation Forest with Extended Splits
import smile.anomaly.IsolationForest;
import smile.math.MathEx;
import java.util.Arrays;
MathEx.setSeed(42L);
double[][] train = loadNumericData(); // your 3-D data
// Extended Isolation Forest (p = 3 → extensionLevel up to 2)
var options = new IsolationForest.Options(256, 0, 0.6, 2);
IsolationForest forest = IsolationForest.fit(train, options);
System.out.println("trees : " + forest.size());
System.out.println("extension level: " + forest.extensionLevel());
double[] scores = forest.score(train);
long anomalyCount = Arrays.stream(scores).filter(s -> s > 0.6).count();
System.out.printf("anomalies (threshold 0.6): %d / %d%n",
anomalyCount, train.length);
import smile.anomaly._
import smile.math.MathEx
MathEx.setSeed(42L)
val train = loadNumericData() // your 3-D data
val forest = isolationforest(train, trees = 256, subsample = 0.6,
extensionLevel = 2)
println("trees : " + forest.size)
println("extension level: " + forest.extensionLevel)
val scores = forest.score(train)
val anomalyCount = scores.count(_ > 0.6)
println(s"anomalies (threshold 0.6): $anomalyCount / ${train.length}")
import smile.anomaly.isolationForest
import smile.math.MathEx
MathEx.setSeed(42L)
val train = loadNumericData() // your 3-D data
val forest = isolationForest(train, trees = 256, subsample = 0.6,
extensionLevel = 2)
println("trees : ${forest.size()}")
println("extension level: ${forest.extensionLevel()}")
val scores = forest.score(train)
val anomalyCount = scores.count { it > 0.6 }
println("anomalies (threshold 0.6): $anomalyCount / ${train.size}")
One-Class SVM with a Gaussian Kernel
import smile.anomaly.SVM;
import smile.math.kernel.GaussianKernel;
double[][] train = loadCleanData(); // no outliers in training set
SVM<double[]> ocsvm = SVM.fit(
train,
new GaussianKernel(0.5),
new SVM.Options(0.1, 1E-3)
);
double[] testPoint = {1.2, -0.7, 0.3};
boolean anomaly = ocsvm.predict(testPoint, 0.0);
System.out.printf("anomaly: %b (score = %.4f)%n",
anomaly, ocsvm.score(testPoint));
import smile.anomaly._
import smile.math.kernel.GaussianKernel
val train = loadCleanData() // no outliers in training set
val model = ocsvm(train, new GaussianKernel(0.5), nu = 0.1)
val testPoint = Array(1.2, -0.7, 0.3)
val anomaly = model.predict(testPoint, 0.0)
println(f"anomaly: $anomaly (score = ${model.score(testPoint)}%.4f)")
import smile.anomaly.ocsvm
import smile.math.kernel.GaussianKernel
val train = loadCleanData() // no outliers in training set
val model = ocsvm(train, GaussianKernel(0.5), nu = 0.1)
val testPoint = doubleArrayOf(1.2, -0.7, 0.3)
val anomaly = model.predict(testPoint, 0.0)
println("anomaly: $anomaly (score = %.4f)".format(model.score(testPoint)))
Unsupervised Threshold Selection
import smile.anomaly.IsolationForest;
import java.util.Arrays;
double[][] data = loadData();
IsolationForest model = IsolationForest.fit(data);
double[] trainScores = model.score(data);
Arrays.sort(trainScores);
double contamination = 0.02; // 2% expected anomalies
int cutIdx = (int) ((1.0 - contamination) * (trainScores.length - 1));
double threshold = trainScores[cutIdx];
double[][] newBatch = loadNewData();
for (double[] x : newBatch) {
if (model.predict(x, threshold)) {
System.out.println("ANOMALY detected: " + Arrays.toString(x));
}
}
import smile.anomaly._
import java.util.Arrays
val data = loadData()
val model = isolationforest(data)
val trainScores = model.score(data)
Arrays.sort(trainScores)
val contamination = 0.02
val cutIdx = ((1.0 - contamination) * (trainScores.length - 1)).toInt
val threshold = trainScores(cutIdx)
for (x <- loadNewData()) {
if (model.predict(x, threshold))
println("ANOMALY detected: " + Arrays.toString(x))
}
import smile.anomaly.isolationForest
import java.util.Arrays
val data = loadData()
val model = isolationForest(data)
val trainScores = model.score(data)
Arrays.sort(trainScores)
val contamination = 0.02
val cutIdx = ((1.0 - contamination) * (trainScores.size - 1)).toInt()
val threshold = trainScores[cutIdx]
for (x in loadNewData()) {
if (model.predict(x, threshold))
println("ANOMALY detected: ${Arrays.toString(x)}")
}
API Quick Reference
IsolationForest
| Operation | Signature |
|---|---|
| Train (defaults) | IsolationForest.fit(double[][] data) |
| Train (options) | IsolationForest.fit(double[][] data, Options options) |
| Options | new Options(ntrees, maxDepth, subsample, extensionLevel) |
| Properties | Options.of(props) / options.toProperties() |
| Tree count | model.size() |
| Trees (copy) | model.trees() |
| Extension level | model.extensionLevel() |
| Score (single) | model.score(double[] x) |
| Score (batch) | model.score(double[][] x) |
| Predict | model.predict(double[] x, double threshold) (true = anomaly when score > threshold) |
SVM (One-Class)
| Operation | Signature |
|---|---|
| Train (defaults) | SVM.fit(T[] x, MercerKernel<T> kernel) |
| Train (options) | SVM.fit(T[] x, MercerKernel<T> kernel, Options options) |
| Options | new Options(nu, tol) (defaults 0.5, 1E-3) |
| Properties | Options.of(props) / options.toProperties() |
| Score (single) | model.score(T x) (positive = inlier) |
| Score (batch) | model.score(T[] x) |
| Predict | model.predict(T x, double threshold) (true = anomaly when score < threshold) |