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.

ClassAlgorithmScore direction
IsolationForestRandom-partition tree ensembleHigher → more anomalous
LOF<T>Local reachability density ratioHigher (≫ 1) → more anomalous
SVM<T>One-class support vector machineLower (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
      

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).

ntrees
smile.isolation_forest.trees
Number of isolation trees (default 100; must be ≥ 1).
maxDepth
smile.isolation_forest.max_depth
Maximum tree depth. 0 (default) means auto — log₂ of the subsample size.
subsample
smile.isolation_forest.sampling_rate
Fraction of rows used to train each tree (default 0.7; must be in (0, 1)).
extensionLevel
smile.isolation_forest.extension_level
How many feature dimensions participate in the random splitting hyperplane (default 0 = standard Isolation Forest). Valid range [0, p−1].
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);

Extension Level Semantics

The extensionLevel controls how many feature dimensions participate in the random splitting hyperplane:

extensionLevelBehaviour
0Standard Isolation Forest — axis-aligned splits
1 … p−2Intermediate — hyperplanes in an (extensionLevel+1)-dimensional subspace
p−1Fully 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
      

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)

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);

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]));

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
      

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).

nu
smile.svm.nu
Upper bound on outlier fraction and lower bound on support-vector fraction. Range (0, 1]; default 0.5.
tol
smile.svm.tolerance
Solver convergence tolerance (must be > 0; default 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);

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);

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);

Score Conventions and Thresholding

Modelscore() returnAnomaly direction
IsolationForest(0, 1]Higher → anomaly
SVMany 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);

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

ConditionException
data == null or data.length < 2IllegalArgumentException
Any row is nullIllegalArgumentException
Rows have inconsistent lengthIllegalArgumentException
extensionLevel ≥ pIllegalArgumentException
subsample not in (0, 1)IllegalArgumentException (from Options)

IsolationForest.score

ConditionException
x == nullIllegalArgumentException
x.length != pIllegalArgumentException — message: "Invalid input dimension: expected <p>, actual <n>"

SVM.fit

ConditionException
x == null or x.length == 0IllegalArgumentException
kernel == nullIllegalArgumentException
options == nullIllegalArgumentException
nu not in (0, 1]IllegalArgumentException (from Options)
tol ≤ 0IllegalArgumentException (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);

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));

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));
    }
}

API Quick Reference

IsolationForest

OperationSignature
Train (defaults)IsolationForest.fit(double[][] data)
Train (options)IsolationForest.fit(double[][] data, Options options)
Optionsnew Options(ntrees, maxDepth, subsample, extensionLevel)
PropertiesOptions.of(props) / options.toProperties()
Tree countmodel.size()
Trees (copy)model.trees()
Extension levelmodel.extensionLevel()
Score (single)model.score(double[] x)
Score (batch)model.score(double[][] x)
Predictmodel.predict(double[] x, double threshold) (true = anomaly when score > threshold)

SVM (One-Class)

OperationSignature
Train (defaults)SVM.fit(T[] x, MercerKernel<T> kernel)
Train (options)SVM.fit(T[] x, MercerKernel<T> kernel, Options options)
Optionsnew Options(nu, tol) (defaults 0.5, 1E-3)
PropertiesOptions.of(props) / options.toProperties()
Score (single)model.score(T x) (positive = inlier)
Score (batch)model.score(T[] x)
Predictmodel.predict(T x, double threshold) (true = anomaly when score < threshold)