MatrixProfile.kt
src/jvmMain/kotlin/afield/math/matrixprofile/MatrixProfile.kt · 386 lines
@file:afield.runtime.completion.CompletionCategory("Math")
package afield.math.matrixprofile
import afield.audio.analysis.FFT
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
Matrix profiles of a single series: for every subsequence, the distance to its nearest neighbour. The result, MatrixProfile, yields motifs (repeated shapes) and discords (shapes that occur once).
matrixProfile computes a self-join or, given two series, an AB-join; joinBothWays returns
both directions of a join, and distanceProfile the distances from one query to every
subsequence of a series. Its motifs and discords return Motifs and Discords. The
multichannel and contrast variants are in MultiDimensional.kt. Tests: MatrixProfileTest.
class MatrixProfile internal constructor
The matrix profile of a series (Yeh, Keogh et al., 2016): for every subsequence of length m,
the z-normalized Euclidean distance to its nearest neighbour somewhere else in the series (or,
for a join, in another series) and where that neighbour is.
Low values are motifs — shapes that happen at least twice. High values are discords — shapes that happen nowhere else. motifs and discords enumerate them.
Distances run from 0 (identical after z-normalization) to 2 √m (perfectly anti-correlated).
A subsequence with no valid neighbour (one containing NaN, or a join with nothing to join) has
distance +∞ and index -1. A constant subsequence has zero correlation with everything, so
every distance to or from it is √(2m).
class MatrixProfile internal constructor(
/** The subsequence length. */
val m: Int,
/** Nearest-neighbour distance per subsequence start, length `n - m + 1`. */
val distances: DoubleArray,
/** Start of the nearest neighbour per subsequence, or `-1`. */
val indices: IntArray,
/** Nearest neighbour that starts *earlier* (self-joins only; empty for a join). */
val leftDistances: DoubleArray,
/** Index of [leftDistances]' neighbour, or `-1`. */
val leftIndices: IntArray,
/** Nearest neighbour that starts *later* (self-joins only; empty for a join). */
val rightDistances: DoubleArray,
/** Index of [rightDistances]' neighbour, or `-1`. */
val rightIndices: IntArray,
/** Half-width of the trivial-match exclusion zone used. */
val exclusion: Int,
/** The series the subsequences come from. */
val series: DoubleArray,
/** For a join, the series the neighbours come from; for a self-join, [series]. */
val other: DoubleArray,
/** Fraction of the distance matrix's diagonals evaluated (1.0 = exact). */
val fraction: Double,
) {
val size: Int get() = distances.size
Number of subsequences.
val isSelfJoin: Boolean get() = series === other
True for a self-join.
val profile: List<Double> get() = distances.map { if (it.isFinite()) it else Double.NaN }
distances as a list, ready for showSignals("mp" to mp.profile). Infinite values become NaN.
fun motifs
The count best motifs, best first. Each starts from the closest remaining pair in the
profile; its members are every subsequence within radius × pair distance of the first
(at most maxMembers, no two trivially overlapping). Members of a motif, and their exclusion
zones, are not used again.
Stops early when the next pair is further apart than maxDistance.
For a join, a motif's members are positions in other: the shapes there that resemble the
subsequence of series at Motif.seed.
fun motifs(
count: Int = 3,
radius: Double = 2.0,
maxMembers: Int = 10,
maxDistance: Double = Double.POSITIVE_INFINITY,
): List<Motif> {
val taken = BooleanArray(size)
val out = ArrayList<Motif>()
while (out.size < count) {
var best = -1
for (i in 0 until size) if (!taken[i] && distances[i].isFinite() && (best < 0 || distances[i] < distances[best])) best = i
if (best < 0 || distances[best] > maxDistance) break
val d0 = distances[best]
val profile = distanceProfile(series.copyOfRange(best, best + m), other)
if (isSelfJoin) taken.let { for (j in 0 until size) if (it[j]) profile[j] = Double.POSITIVE_INFINITY }
val members = pickMembers(profile, max(d0 * radius, 1e-9), maxMembers, exclusion, forced = (if (isSelfJoin) listOf(best, indices[best]) else listOf(indices[best])).filter { it >= 0 })
out += Motif(members.map { it.first }, members.map { it.second }, d0, best)
if (isSelfJoin) members.forEach { (j, _) -> exclude(taken, j, exclusion) }
exclude(taken, best, exclusion)
indices[best].takeIf { it >= 0 && isSelfJoin }?.let { exclude(taken, it, exclusion) }
}
return out
}
fun discords(count: Int = 3): List<Discord>
The count most unusual subsequences, most unusual first, none trivially overlapping.
fun discords(count: Int = 3): List<Discord> {
val taken = BooleanArray(size)
val out = ArrayList<Discord>()
while (out.size < count) {
var best = -1
for (i in 0 until size) if (!taken[i] && distances[i].isFinite() && (best < 0 || distances[i] > distances[best])) best = i
if (best < 0) break
out += Discord(best, distances[best], indices[best])
exclude(taken, best, exclusion)
}
return out
}
fun subsequence(i: Int): DoubleArray = series.copyOfRange(i, i + m)
The subsequence starting at i.
override fun toString(): String
override fun toString(): String =
"MatrixProfile(m=$m, n=$size, ${if (isSelfJoin) "self-join" else "join"}, fraction=$fraction)"
}
data class Motif(val indices: List<Int>, val distances: List<Double>, val pairDistance: Double, val seed: Int = indices.first())
A repeated shape: where it occurs (closest first), each occurrence's distance from seed, the
distance of the pair it was found from, and the subsequence it was grown from.
data class Motif(val indices: List<Int>, val distances: List<Double>, val pairDistance: Double, val seed: Int = indices.first())
data class Discord(val index: Int, val distance: Double, val neighbour: Int)
An unusual shape: where it starts, how far it is from its nearest neighbour, and where that is.
internal fun exclude(taken: BooleanArray, at: Int, zone: Int)
internal fun exclude(taken: BooleanArray, at: Int, zone: Int) {
for (j in max(0, at - zone)..min(taken.size - 1, at + zone)) taken[j] = true
}
internal fun pickMembers(profile: DoubleArray, limit: Double, maxMembers: Int, zone: Int, forced: List<Int>): List<Pair<Int, Double>>
internal fun pickMembers(profile: DoubleArray, limit: Double, maxMembers: Int, zone: Int, forced: List<Int>): List<Pair<Int, Double>> {
val chosen = ArrayList<Pair<Int, Double>>()
val blocked = BooleanArray(profile.size)
for (f in forced.distinct()) if (f < profile.size && !blocked[f]) {
chosen += f to profile[f].let { if (it.isFinite()) it else 0.0 }
exclude(blocked, f, zone)
}
val order = profile.indices.filter { profile[it] <= limit }.sortedBy { profile[it] }
for (j in order) {
if (chosen.size >= maxMembers) break
if (blocked[j]) continue
chosen += j to profile[j]
exclude(blocked, j, zone)
}
return chosen.sortedBy { it.second }
}
fun defaultExclusion(m: Int): Int = ceil(m / 4.0).toInt()
Default trivial-match exclusion zone: a quarter of the window, as in STUMPY and SCAMP.
internal class Windows(raw: DoubleArray, val m: Int)
Per-window statistics for the SCAMP covariance recurrence. NaN samples are replaced by 0 in x and their windows are marked invalid by a NaN invNorm, so a NaN never enters a running covariance and an invalid window never wins a comparison.
val n = raw.size - m + 1
val x = DoubleArray(raw.size) { if (raw[it].isNaN()) 0.0 else raw[it] }
val mu = DoubleArray(max(n, 0))
val invNorm = DoubleArray(max(n, 0))
val df = DoubleArray(max(n, 0))
val dg = DoubleArray(max(n, 0))
init
init {
require(m >= 3) { "subsequence length m must be at least 3 (got $m)" }
require(n >= 1) { "series of length ${raw.size} is shorter than m = $m" }
var bad = 0
for (k in 0 until m) if (raw[k].isNaN()) bad++
for (i in 0 until n) {
if (i > 0) {
if (raw[i - 1].isNaN()) bad--
if (raw[i + m - 1].isNaN()) bad++
}
// Mean and norm per window are computed exactly (no running sums), which costs n·m
// once and keeps long series free of drift.
var s = 0.0
for (k in 0 until m) s += x[i + k]
val mean = s / m
var ss = 0.0
for (k in 0 until m) { val d = x[i + k] - mean; ss += d * d }
mu[i] = mean
invNorm[i] = when {
bad > 0 -> Double.NaN
ss <= 1e-12 * m * (1.0 + mean * mean) -> 0.0
else -> 1.0 / sqrt(ss)
}
}
for (i in 1 until n) {
df[i] = (x[i + m - 1] - x[i - 1]) / 2.0
dg[i] = (x[i + m - 1] - mu[i]) + (x[i - 1] - mu[i - 1])
}
}
fun cov(i: Int, o: Windows, j: Int): Double
Exact mean-centred covariance of window i here with window j of o.
fun cov(i: Int, o: Windows, j: Int): Double {
var c = 0.0
for (k in 0 until m) c += (x[i + k] - mu[i]) * (o.x[j + k] - o.mu[j])
return c
}
}
internal fun corrToDistance(c: Double, m: Int): Double
internal fun corrToDistance(c: Double, m: Int): Double =
if (c.isNaN() || c == Double.NEGATIVE_INFINITY) Double.POSITIVE_INFINITY else sqrt(max(0.0, 2.0 * m * (1.0 - min(c, 1.0))))
internal fun threadCount(work: Long): Int
internal fun threadCount(work: Long): Int =
if (work < 2_000_000L) 1 else Runtime.getRuntime().availableProcessors().coerceIn(1, 64)
internal fun diagonals(selfJoin: Boolean, na: Int, nb: Int, zone: Int, fraction: Double, seed: Long): IntArray
The diagonals to walk, as (start in a, start in b) pairs, in the order to walk them. For a
partial (fraction < 1) profile they are shuffled first — SCRIMP's anytime ordering — so any
prefix is an unbiased sample of the whole matrix.
internal fun diagonals(selfJoin: Boolean, na: Int, nb: Int, zone: Int, fraction: Double, seed: Long): IntArray {
val ks = if (selfJoin) (zone + 1 until na).toList().toIntArray() else (-(nb - 1) until na).toList().toIntArray()
if (fraction >= 1.0) return ks
val rnd = java.util.Random(seed)
for (i in ks.size - 1 downTo 1) { val j = rnd.nextInt(i + 1); val t = ks[i]; ks[i] = ks[j]; ks[j] = t }
return ks.copyOf(max(1, ceil(ks.size * fraction.coerceIn(0.0, 1.0)).toInt()).coerceAtMost(ks.size))
}
internal fun <S> parallelOver(items: IntArray, threads: Int, local: () -> S, body: (S, Int) -> Unit): List<S> {
Runs body over items on threads workers, each with its own state from local; returns the states.
internal fun <S> parallelOver(items: IntArray, threads: Int, local: () -> S, body: (S, Int) -> Unit): List<S> {
val states = List(threads) { local() }
if (threads == 1) { items.forEach { body(states[0], it) }; return states }
val next = AtomicInteger(0)
val chunk = max(1, items.size / (threads * 16))
val workers = states.map { s ->
Thread {
while (true) {
val from = next.getAndAdd(chunk)
if (from >= items.size) break
for (q in from until min(items.size, from + chunk)) body(s, items[q])
}
}.apply { isDaemon = true; start() }
}
workers.forEach { it.join() }
return states
}
private class Best(n: Int, nb: Int) {
val corr = DoubleArray(n) { Double.NEGATIVE_INFINITY }
val idx = IntArray(n) { -1 }
val corrL = DoubleArray(nb) { Double.NEGATIVE_INFINITY }
val idxL = IntArray(nb) { -1 }
}
fun matrixProfile
The self-join matrix profile of series with subsequence length m.
Exact and O(n²): it walks every diagonal of the distance matrix with SCAMP's mean-centred covariance update, a handful of flops per cell, split across cores: n = 10 000 takes about 20 ms on ten cores, n = 100 000 about 1.5 s.
fraction below 1 evaluates that share of the diagonals in a random order (SCRIMP's anytime
mode): every value is an upper bound on the exact one, and the motifs and discords usually
settle long before the profile does. exclusion is the half-width of the zone around each
subsequence whose neighbours are trivial matches.
fun matrixProfile(
series: DoubleArray,
m: Int,
exclusion: Int = defaultExclusion(m),
fraction: Double = 1.0,
seed: Long = 0,
): MatrixProfile {
val w = Windows(series, m)
val n = w.n
val ks = diagonals(true, n, n, exclusion, fraction, seed)
val threads = threadCount(n.toLong() * n / 2)
val states = parallelOver(ks, threads, { Best(n, n) }) { s, k ->
// Diagonal k: pairs (i, i + k). corr = right neighbour of i, corrL = left neighbour of j.
var c = w.cov(0, w, k)
var i = 0
var j = k
while (true) {
val r = c * w.invNorm[i] * w.invNorm[j]
if (r > s.corr[i]) { s.corr[i] = r; s.idx[i] = j }
if (r > s.corrL[j]) { s.corrL[j] = r; s.idxL[j] = i }
i++; j++
if (j >= n) break
c += w.df[i] * w.dg[j] + w.df[j] * w.dg[i]
}
}
val rc = DoubleArray(n) { Double.NEGATIVE_INFINITY }; val ri = IntArray(n) { -1 }
val lc = DoubleArray(n) { Double.NEGATIVE_INFINITY }; val li = IntArray(n) { -1 }
for (s in states) for (i in 0 until n) {
if (s.corr[i] > rc[i]) { rc[i] = s.corr[i]; ri[i] = s.idx[i] }
if (s.corrL[i] > lc[i]) { lc[i] = s.corrL[i]; li[i] = s.idxL[i] }
}
val right = DoubleArray(n) { corrToDistance(rc[it], m) }
val left = DoubleArray(n) { corrToDistance(lc[it], m) }
val d = DoubleArray(n) { min(left[it], right[it]) }
val idx = IntArray(n) { if (left[it] <= right[it]) li[it] else ri[it] }
return MatrixProfile(m, d, idx, left, li, right, ri, exclusion, series, series, fraction.coerceIn(0.0, 1.0))
}
fun matrixProfile(a: DoubleArray, b: DoubleArray, m: Int, fraction: Double = 1.0, seed: Long = 0): MatrixProfile
The join matrix profile: for every subsequence of a, its nearest neighbour in b. There is no
exclusion zone, since the two series are different. The profile of b against a comes out of
the same pass; ask for it with joinBothWays.
fun matrixProfile(a: DoubleArray, b: DoubleArray, m: Int, fraction: Double = 1.0, seed: Long = 0): MatrixProfile =
joinBothWays(a, b, m, fraction, seed).first
fun joinBothWays(a: DoubleArray, b: DoubleArray, m: Int, fraction: Double = 1.0, seed: Long = 0): Pair<MatrixProfile, MatrixProfile>
The join profiles of a against b and of b against a, from one pass over the distance matrix.
fun joinBothWays(a: DoubleArray, b: DoubleArray, m: Int, fraction: Double = 1.0, seed: Long = 0): Pair<MatrixProfile, MatrixProfile> {
val wa = Windows(a, m)
val wb = Windows(b, m)
val na = wa.n
val nb = wb.n
val ks = diagonals(false, na, nb, 0, fraction, seed)
val threads = threadCount(na.toLong() * nb)
val states = parallelOver(ks, threads, { Best(na, nb) }) { s, k ->
// Diagonal k: pairs (i, j) with i - j = k.
var i = max(k, 0)
var j = i - k
var c = wa.cov(i, wb, j)
while (true) {
val r = c * wa.invNorm[i] * wb.invNorm[j]
if (r > s.corr[i]) { s.corr[i] = r; s.idx[i] = j }
if (r > s.corrL[j]) { s.corrL[j] = r; s.idxL[j] = i }
i++; j++
if (i >= na || j >= nb) break
c += wa.df[i] * wb.dg[j] + wb.df[j] * wa.dg[i]
}
}
val ac = DoubleArray(na) { Double.NEGATIVE_INFINITY }; val ai = IntArray(na) { -1 }
val bc = DoubleArray(nb) { Double.NEGATIVE_INFINITY }; val bi = IntArray(nb) { -1 }
for (s in states) {
for (i in 0 until na) if (s.corr[i] > ac[i]) { ac[i] = s.corr[i]; ai[i] = s.idx[i] }
for (j in 0 until nb) if (s.corrL[j] > bc[j]) { bc[j] = s.corrL[j]; bi[j] = s.idxL[j] }
}
val f = fraction.coerceIn(0.0, 1.0)
val empty = DoubleArray(0); val none = IntArray(0)
val ab = MatrixProfile(m, DoubleArray(na) { corrToDistance(ac[it], m) }, ai, empty, none, empty, none, defaultExclusion(m), a, b, f)
val ba = MatrixProfile(m, DoubleArray(nb) { corrToDistance(bc[it], m) }, bi, empty, none, empty, none, defaultExclusion(m), b, a, f)
return ab to ba
}
fun distanceProfile(query: DoubleArray, series: DoubleArray): DoubleArray
The z-normalized Euclidean distance from query to every subsequence of series of the same
length, by Mueen's MASS: one FFT convolution for all the dot products, so O(n log n).
fun distanceProfile(query: DoubleArray, series: DoubleArray): DoubleArray {
val m = query.size
val w = Windows(series, m)
val n = w.n
var qm = 0.0
for (v in query) qm += v
qm /= m
var qss = 0.0
for (v in query) qss += (v - qm) * (v - qm)
val qInv = if (query.any { it.isNaN() }) Double.NaN else if (qss <= 1e-12 * m * (1.0 + qm * qm)) 0.0 else 1.0 / sqrt(qss)
val size = FFT.nextPowerOfTwo(series.size + m)
val xr = DoubleArray(size); val xi = DoubleArray(size)
val yr = DoubleArray(size); val yi = DoubleArray(size)
for (k in series.indices) xr[k] = w.x[k]
for (k in 0 until m) yr[k] = if (query[m - 1 - k].isNaN()) 0.0 else query[m - 1 - k] - qm
FFT.transform(xr, xi)
FFT.transform(yr, yi)
for (k in 0 until size) {
val r = xr[k] * yr[k] - xi[k] * yi[k]
val im = xr[k] * yi[k] + xi[k] * yr[k]
xr[k] = r; xi[k] = im
}
FFT.inverseTransform(xr, xi)
// The query is mean-centred, so Σ q̃·x over a window equals Σ q̃·(x − μ): its covariance.
return DoubleArray(n) { corrToDistance(xr[it + m - 1] * qInv * w.invNorm[it], m) }
}
fun matrixProfile(series: List<Number>, m: Int, exclusion: Int = defaultExclusion(m), fraction: Double = 1.0, seed: Long = 0): MatrixProfile
fun matrixProfile(series: List<Number>, m: Int, exclusion: Int = defaultExclusion(m), fraction: Double = 1.0, seed: Long = 0): MatrixProfile =
matrixProfile(DoubleArray(series.size) { series[it].toDouble() }, m, exclusion, fraction, seed)
fun matrixProfile(series: FloatArray, m: Int, exclusion: Int = defaultExclusion(m), fraction: Double = 1.0, seed: Long = 0): MatrixProfile
fun matrixProfile(series: FloatArray, m: Int, exclusion: Int = defaultExclusion(m), fraction: Double = 1.0, seed: Long = 0): MatrixProfile =
matrixProfile(DoubleArray(series.size) { series[it].toDouble() }, m, exclusion, fraction, seed)
fun matrixProfile(a: List<Number>, b: List<Number>, m: Int, fraction: Double = 1.0, seed: Long = 0): MatrixProfile
fun matrixProfile(a: List<Number>, b: List<Number>, m: Int, fraction: Double = 1.0, seed: Long = 0): MatrixProfile =
matrixProfile(DoubleArray(a.size) { a[it].toDouble() }, DoubleArray(b.size) { b[it].toDouble() }, m, fraction, seed)