MultiDimensional.kt
src/jvmMain/kotlin/afield/math/matrixprofile/MultiDimensional.kt · 276 lines
@file:afield.runtime.completion.CompletionCategory("Math")
package afield.math.matrixprofile
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
Matrix profiles across several channels and between two classes of examples, built on the single-series MatrixProfile.
multiMatrixProfile finds motifs that appear in only some channels of a multichannel series
and reports which channels share them (MultiMatrixProfile, MultiMotif). contrastProfile
finds shapes that repeat in a set of positive examples and are absent from a set of negative
ones (ContrastProfile, Plato). Tests: MatrixProfileTest.
class MultiMatrixProfile internal constructor
The multidimensional matrix profile of a d-channel series (mSTAMP, Yeh, Kavantzas & Keogh,
2017). A motif in many channels is rarely in all of them: a gesture shows up in the wrist's
x and z but not y, a fault in two of eight sensors. So instead of one profile there are d:
profile k (1-based, profile) is, for every subsequence, the smallest mean distance to a
neighbour over the best k channels, choosing the channels per pair.
Comparing profiles across k is how you choose a motif's dimensionality: the k-channel
value stays low while the channels that share the motif are added, then jumps. subspace
says which channels those were.
class MultiMatrixProfile internal constructor(
/** The subsequence length. */
val m: Int,
/** The channels, each the same length. */
val channels: List<DoubleArray>,
private val dist: Array<DoubleArray>,
private val idx: Array<IntArray>,
/** Half-width of the trivial-match exclusion zone used. */
val exclusion: Int,
) {
val dimensions: Int get() = channels.size
Number of channels.
val size: Int get() = dist[0].size
Number of subsequences.
fun distances(k: Int): DoubleArray = dist[checkK(k) - 1]
Distances of the k-channel profile (1..dimensions).
fun indices(k: Int): IntArray = idx[checkK(k) - 1]
Neighbour indices of the k-channel profile.
fun profile(k: Int): List<Double> = distances(k).map { if (it.isFinite()) it else Double.NaN }
The k-channel profile as a plot-ready list (infinities become NaN).
fun asMatrixProfile(k: Int, channel: Int = 0): MatrixProfile
The k-channel profile as a one-dimensional MatrixProfile over channel channel, so
MatrixProfile.motifs-style questions can be asked of it — the distances are the
k-channel ones.
fun asMatrixProfile(k: Int, channel: Int = 0): MatrixProfile {
val e = DoubleArray(0); val ne = IntArray(0)
return MatrixProfile(m, distances(k), indices(k), e, ne, e, ne, exclusion, channels[channel], channels[channel], 1.0)
}
fun subspace(k: Int, i: Int): IntArray
The k channels, most similar first, in which subsequence i matches its k-channel neighbour.
fun subspace(k: Int, i: Int): IntArray {
val j = indices(k)[i]
if (j < 0) return IntArray(0)
val per = DoubleArray(dimensions) { c -> zDistance(channels[c], i, j, m) }
return per.indices.sortedBy { per[it] }.take(k).toIntArray()
}
fun motifs(k: Int, count: Int = 3, radius: Double = 2.0, maxMembers: Int = 10): List<MultiMotif>
The count best k-channel motifs. Each grows from the closest remaining pair; its
members are subsequences within radius × that pair's distance, measured as the mean
distance over the pair's own subspace.
fun motifs(k: Int, count: Int = 3, radius: Double = 2.0, maxMembers: Int = 10): List<MultiMotif> {
val d = distances(k)
val ix = indices(k)
val taken = BooleanArray(size)
val out = ArrayList<MultiMotif>()
while (out.size < count) {
var best = -1
for (i in 0 until size) if (!taken[i] && d[i].isFinite() && (best < 0 || d[i] < d[best])) best = i
if (best < 0) break
val sub = subspace(k, best)
val mean = DoubleArray(size)
for (c in sub) {
val p = distanceProfile(channels[c].copyOfRange(best, best + m), channels[c])
for (j in 0 until size) mean[j] += p[j] / sub.size
}
for (j in 0 until size) if (taken[j]) mean[j] = Double.POSITIVE_INFINITY
val members = pickMembers(mean, max(d[best] * radius, 1e-9), maxMembers, exclusion, listOf(best, ix[best]).filter { it >= 0 })
out += MultiMotif(members.map { it.first }, members.map { it.second }, d[best], sub.toList())
members.forEach { (j, _) -> exclude(taken, j, exclusion) }
exclude(taken, best, exclusion)
}
return out
}
fun discords(k: Int, count: Int = 3): List<Discord> = asMatrixProfile(k).discords(count)
The count most unusual k-channel subsequences.
private fun checkK(k: Int): Int {
require(k in 1..dimensions) { "k must be in 1..$dimensions (got $k)" }
return k
}
override fun toString(): String = "MultiMatrixProfile(m=$m, n=$size, d=$dimensions)"
data class MultiMotif(val indices: List<Int>, val distances: List<Double>, val pairDistance: Double, val channels: List<Int>)
A motif in a subset of channels: occurrences, their distances, the seed pair's distance and the channels it lives in.
data class MultiMotif(val indices: List<Int>, val distances: List<Double>, val pairDistance: Double, val channels: List<Int>)
internal fun zDistance(x: DoubleArray, i: Int, j: Int, m: Int): Double
internal fun zDistance(x: DoubleArray, i: Int, j: Int, m: Int): Double {
var ma = 0.0; var mb = 0.0
for (k in 0 until m) { ma += x[i + k]; mb += x[j + k] }
ma /= m; mb /= m
var sab = 0.0; var saa = 0.0; var sbb = 0.0
for (k in 0 until m) {
val a = x[i + k] - ma; val b = x[j + k] - mb
sab += a * b; saa += a * a; sbb += b * b
}
if (saa.isNaN() || sbb.isNaN()) return Double.POSITIVE_INFINITY
val r = if (saa <= 1e-12 || sbb <= 1e-12) 0.0 else sab / sqrt(saa * sbb)
return corrToDistance(r, m)
}
private class MultiBest(d: Int, n: Int) {
val dist = Array(d) { DoubleArray(n) { Double.POSITIVE_INFINITY } }
val idx = Array(d) { IntArray(n) { -1 } }
val cov = DoubleArray(d)
val per = DoubleArray(d)
}
fun multiMatrixProfile(channels: List<DoubleArray>, m: Int, exclusion: Int = defaultExclusion(m)): MultiMatrixProfile
The multidimensional self-join matrix profile of channels (all the same length) with
subsequence length m. Exact, O(d · n² + d log d · n²) — the per-channel covariance
recurrence of matrixProfile run in lock-step over every channel, with the channels sorted
per cell.
body — 45 lines, from line 143
fun multiMatrixProfile(channels: List<DoubleArray>, m: Int, exclusion: Int = defaultExclusion(m)): MultiMatrixProfile {
require(channels.isNotEmpty()) { "need at least one channel" }
require(channels.all { it.size == channels[0].size }) { "all channels must be the same length" }
val ws = channels.map { Windows(it, m) }
val d = ws.size
val n = ws[0].n
val ks = diagonals(true, n, n, exclusion, 1.0, 0)
val threads = threadCount(n.toLong() * n * d / 2)
val states = parallelOver(ks, threads, { MultiBest(d, n) }) { s, k ->
for (c in 0 until d) s.cov[c] = ws[c].cov(0, ws[c], k)
var i = 0
var j = k
while (true) {
for (c in 0 until d) {
val w = ws[c]
s.per[c] = corrToDistance(s.cov[c] * w.invNorm[i] * w.invNorm[j], m)
}
// Insertion sort: d is small, and this is the whole of mSTAMP's per-cell cost.
for (a in 1 until d) {
val v = s.per[a]; var b = a - 1
while (b >= 0 && s.per[b] > v) { s.per[b + 1] = s.per[b]; b-- }
s.per[b + 1] = v
}
var sum = 0.0
for (q in 0 until d) {
sum += s.per[q]
val mean = sum / (q + 1)
if (mean < s.dist[q][i]) { s.dist[q][i] = mean; s.idx[q][i] = j }
if (mean < s.dist[q][j]) { s.dist[q][j] = mean; s.idx[q][j] = i }
}
i++; j++
if (j >= n) break
for (c in 0 until d) {
val w = ws[c]
s.cov[c] += w.df[i] * w.dg[j] + w.df[j] * w.dg[i]
}
}
}
val dist = Array(d) { DoubleArray(n) { Double.POSITIVE_INFINITY } }
val idx = Array(d) { IntArray(n) { -1 } }
for (s in states) for (q in 0 until d) for (i in 0 until n) if (s.dist[q][i] < dist[q][i]) {
dist[q][i] = s.dist[q][i]; idx[q][i] = s.idx[q][i]
}
return MultiMatrixProfile(m, channels, dist, idx, exclusion)
}
fun multiMatrixProfile(channels: List<List<Number>>, m: Int, exclusion: Int = defaultExclusion(m)): MultiMatrixProfile
multiMatrixProfile for channels given as lists.
@JvmName("multiMatrixProfileLists")
fun multiMatrixProfile(channels: List<List<Number>>, m: Int, exclusion: Int = defaultExclusion(m)): MultiMatrixProfile =
multiMatrixProfile(channels.map { c -> DoubleArray(c.size) { c[it].toDouble() } }, m, exclusion)
class ContrastProfile internal constructor
The contrast profile (Mercer, Alaee, Abdoli, Singh, Murillo, Keogh, 2021): which shapes occur in
the positive examples and not in the negative ones.
contrast[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) = (join[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) − self[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240)) / √(2m) where join is the distance from positive
subsequence i to its nearest neighbour in the negatives and self the distance to its nearest
neighbour elsewhere in the positives, clamped to [-1, 1]. High means "repeated here, absent there". The best
candidate is called a plato (platos).
class ContrastProfile internal constructor(
/** The subsequence length. */
val m: Int,
/** Contrast per subsequence of the concatenated positives; NaN where undefined. */
val contrast: DoubleArray,
/** Self-join of the concatenated positives. */
val self: MatrixProfile,
/** Join of the concatenated positives against the concatenated negatives. */
val join: MatrixProfile,
/** Start of each positive example within the concatenation (examples are separated by one NaN). */
val segmentStarts: IntArray,
) {
val profile: List<Double> get() = contrast.toList()
contrast as a plot-ready list.
val positive: DoubleArray get() = self.series
The concatenated positives that contrast indexes.
fun platos(count: Int = 1): List<Plato>
The count highest-contrast subsequences, best first, none trivially overlapping.
fun platos(count: Int = 1): List<Plato> {
val taken = BooleanArray(contrast.size)
val out = ArrayList<Plato>()
while (out.size < count) {
var best = -1
for (i in contrast.indices) if (!taken[i] && !contrast[i].isNaN() && (best < 0 || contrast[i] > contrast[best])) best = i
if (best < 0) break
val seg = segmentStarts.indexOfLast { it <= best }
out += Plato(best, contrast[best], seg, best - segmentStarts[seg], self.indices[best], join.indices[best])
exclude(taken, best, self.exclusion)
}
return out
}
fun subsequence(i: Int): DoubleArray = positive.copyOfRange(i, i + m)
The positive subsequence starting at i.
data class Plato(val index: Int, val contrast: Double, val example: Int, val offset: Int, val positiveNeighbour: Int, val negativeNeighbour: Int)
A high-contrast subsequence: where it starts in the concatenated positives, its contrast, which positive example that is and where within it, and its nearest neighbours in the positives and the negatives.
data class Plato(val index: Int, val contrast: Double, val example: Int, val offset: Int, val positiveNeighbour: Int, val negativeNeighbour: Int)
internal fun concatWithGaps(parts: List<DoubleArray>): Pair<DoubleArray, IntArray>
internal fun concatWithGaps(parts: List<DoubleArray>): Pair<DoubleArray, IntArray> {
val starts = IntArray(parts.size)
val out = ArrayList<Double>()
parts.forEachIndexed { p, a ->
if (p > 0) out += Double.NaN
starts[p] = out.size
a.forEach { out += it }
}
return out.toDoubleArray() to starts
}
fun contrastProfile(positive: List<DoubleArray>, negative: List<DoubleArray>, m: Int, exclusion: Int = defaultExclusion(m)): ContrastProfile
The ContrastProfile of the positive examples against the negative ones.
fun contrastProfile(positive: List<DoubleArray>, negative: List<DoubleArray>, m: Int, exclusion: Int = defaultExclusion(m)): ContrastProfile {
require(positive.isNotEmpty() && negative.isNotEmpty()) { "need at least one positive and one negative example" }
val (pos, starts) = concatWithGaps(positive)
val (neg, _) = concatWithGaps(negative)
val self = matrixProfile(pos, m, exclusion)
val join = matrixProfile(pos, neg, m)
val norm = sqrt(2.0 * m)
val c = DoubleArray(self.size) {
val a = self.distances[it]; val b = join.distances[it]
if (a.isFinite() && b.isFinite()) min(1.0, max(-1.0, (b - a) / norm)) else Double.NaN
}
return ContrastProfile(m, c, self, join, starts)
}
fun contrastProfile(positive: DoubleArray, negative: DoubleArray, m: Int, exclusion: Int = defaultExclusion(m)): ContrastProfile
contrastProfile for one positive and one negative series.
fun contrastProfile(positive: DoubleArray, negative: DoubleArray, m: Int, exclusion: Int = defaultExclusion(m)): ContrastProfile =
contrastProfile(listOf(positive), listOf(negative), m, exclusion)