WIP: feature/emd #521
@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2018-2024 KMath contributors.
|
||||||
|
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package space.kscience.kmath.series
|
||||||
|
|
||||||
|
import space.kscience.kmath.operations.algebra
|
||||||
|
import space.kscience.kmath.operations.bufferAlgebra
|
||||||
|
import space.kscience.kmath.structures.*
|
||||||
|
import space.kscience.kmath.operations.invoke
|
||||||
|
import space.kscience.plotly.*
|
||||||
|
import space.kscience.plotly.models.Scatter
|
||||||
|
|||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
private val customAlgebra = (Double.algebra.bufferAlgebra) { SeriesAlgebra(this) { it.toDouble() } }
|
||||||
|
|
||||||
|
fun main(): Unit = (customAlgebra) {
|
||||||
|
val signal = DoubleArray(800) {
|
||||||
|
sin(it.toDouble() / 10.0) + 3.5 * sin(it.toDouble() / 60.0)
|
||||||
|
}.asBuffer().moveTo(0)
|
||||||
|
val emd = empiricalModeDecomposition(
|
||||||
|
sConditionThreshold = 1,
|
||||||
|
maxSiftIterations = 15,
|
||||||
|
siftingDelta = 1e-2,
|
||||||
|
nModes = 4
|
||||||
|
).decompose(signal)
|
||||||
|
println("EMD: ${emd.modes.size} modes extracted, terminated because ${emd.terminatedBecause}")
|
||||||
|
|
||||||
|
fun Plot.series(name: String, buffer: Buffer<Double>, block: Scatter.() -> Unit = {}) {
|
||||||
|
this.scatter {
|
||||||
|
this.name = name
|
||||||
|
this.x.numbers = buffer.labels
|
||||||
|
this.y.doubles = buffer.toDoubleArray()
|
||||||
|
block()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Plotly.plot {
|
||||||
|
series("Signal", signal)
|
||||||
|
emd.modes.forEachIndexed { index, it ->
|
||||||
|
series("Mode ${index+1}", it)
|
||||||
|
}
|
||||||
|
}.makeFile(resourceLocation = ResourceLocation.REMOTE)
|
||||||
|
}
|
@ -0,0 +1,85 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2018-2024 KMath contributors.
|
||||||
|
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package space.kscience.kmath.series
|
||||||
|
|
||||||
|
import space.kscience.kmath.operations.*
|
||||||
|
import space.kscience.kmath.structures.*
|
||||||
|
import space.kscience.plotly.*
|
||||||
|
import space.kscience.plotly.models.Scatter
|
||||||
|
import space.kscience.plotly.models.ScatterMode
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
private val customAlgebra = (Double.algebra.bufferAlgebra) { SeriesAlgebra(this) { it * 50.0 / 599.0 } }
|
||||||
|
|
||||||
|
fun main(): Unit = (customAlgebra) {
|
||||||
|
/*
|
||||||
|
val signal = DoubleArray(600) {
|
||||||
|
val x = it * 50.0 / 599
|
||||||
|
(3.0 * sin(x) + 0.5 * cos(7.0 * x)).coerceIn(-3.0 .. 3.0)
|
||||||
|
}.asBuffer().moveTo(0)
|
||||||
|
val peaks = signal.peaks()
|
||||||
|
val troughs = signal.troughs()
|
||||||
|
println(peaks)
|
||||||
|
println(troughs)
|
||||||
|
|
||||||
|
fun Plot.series(name: String, buffer: Buffer<Double>, block: Scatter.() -> Unit = {}) {
|
||||||
|
scatter {
|
||||||
|
this.name = name
|
||||||
|
this.x.numbers = buffer.labels
|
||||||
|
this.y.doubles = buffer.toDoubleArray()
|
||||||
|
block()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Plotly.plot {
|
||||||
|
series("Signal", signal)
|
||||||
|
scatter {
|
||||||
|
name = "Peaks"
|
||||||
|
mode = ScatterMode.markers
|
||||||
|
x.doubles = peaks.map { signal.labels[it] }.toDoubleArray()
|
||||||
|
y.doubles = peaks.map { signal[it] }.toDoubleArray()
|
||||||
|
}
|
||||||
|
scatter {
|
||||||
|
name = "Troughs"
|
||||||
|
mode = ScatterMode.markers
|
||||||
|
x.doubles = troughs.map { signal.labels[it] }.toDoubleArray()
|
||||||
|
y.doubles = troughs.map { signal[it] }.toDoubleArray()
|
||||||
|
}
|
||||||
|
}.makeFile(resourceLocation = ResourceLocation.REMOTE)
|
||||||
|
*/
|
||||||
|
val nSamples = 600
|
||||||
|
val signal = DoubleArray(nSamples) {
|
||||||
|
val x = it * 12.0 / (nSamples - 1)
|
||||||
|
(3.5 * sin(x)).coerceIn(-3.0 .. 3.0)
|
||||||
|
}.asBuffer().moveTo(0)
|
||||||
|
val peaks = signal.peaks(PlateauEdgePolicy.KEEP_ALL_EDGES)
|
||||||
|
val troughs = signal.troughs(PlateauEdgePolicy.KEEP_ALL_EDGES)
|
||||||
|
println(peaks)
|
||||||
|
println(troughs)
|
||||||
|
|
||||||
|
fun Plot.series(name: String, buffer: Buffer<Double>, block: Scatter.() -> Unit = {}) {
|
||||||
|
scatter {
|
||||||
|
this.name = name
|
||||||
|
this.x.numbers = buffer.labels
|
||||||
|
this.y.doubles = buffer.toDoubleArray()
|
||||||
|
block()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Plotly.plot {
|
||||||
|
series("Signal", signal)
|
||||||
|
scatter {
|
||||||
|
name = "Peaks"
|
||||||
|
mode = ScatterMode.markers
|
||||||
|
x.doubles = peaks.map { signal.labels[it] }.toDoubleArray()
|
||||||
|
y.doubles = peaks.map { signal[it] }.toDoubleArray()
|
||||||
|
}
|
||||||
|
scatter {
|
||||||
|
name = "Troughs"
|
||||||
|
mode = ScatterMode.markers
|
||||||
|
x.doubles = troughs.map { signal.labels[it] }.toDoubleArray()
|
||||||
|
y.doubles = troughs.map { signal[it] }.toDoubleArray()
|
||||||
|
}
|
||||||
|
}.makeFile(resourceLocation = ResourceLocation.REMOTE)
|
||||||
|
}
|
@ -14,6 +14,7 @@ kotlin.sourceSets {
|
|||||||
dependencies {
|
dependencies {
|
||||||
api(projects.kmathCoroutines)
|
api(projects.kmathCoroutines)
|
||||||
//implementation(spclibs.atomicfu)
|
//implementation(spclibs.atomicfu)
|
||||||
|
api(project(":kmath-functions"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,288 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2018-2024 KMath contributors.
|
||||||
|
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package space.kscience.kmath.series
|
||||||
|
|
||||||
|
import space.kscience.kmath.interpolation.SplineInterpolator
|
||||||
|
import space.kscience.kmath.interpolation.interpolate
|
||||||
|
import space.kscience.kmath.operations.*
|
||||||
|
import space.kscience.kmath.structures.Buffer
|
||||||
|
import space.kscience.kmath.structures.last
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empirical mode decomposition of a signal represented as a [Series].
|
||||||
|
*
|
||||||
|
* @param seriesAlgebra context to perform operations in.
|
||||||
|
* @param maxSiftIterations number of iterations in the mode extraction (sifting) process after which
|
||||||
|
* the result is returned even if no other conditions are met.
|
||||||
|
* @param sConditionThreshold one of the possible criteria for sifting process termination:
|
||||||
|
* how many times in a row should a proto-mode satisfy the s-condition (the number of zeros differs from
|
||||||
|
* the number of extrema no more than by 1) to be considered an empirical mode.
|
||||||
|
* @param siftingDelta one of the possible criteria for sifting process termination: if relative difference of
|
||||||
|
* two proto-modes obtained in a sequence is less that this number, the last proto-mode is considered
|
||||||
|
* an empirical mode and returned.
|
||||||
|
* @param nModes how many modes should be extracted at most. The algorithm may return fewer modes if it was not
|
||||||
|
* possible to extract more modes from the signal.
|
||||||
|
*/
|
||||||
|
public class EmpiricalModeDecomposition<T: Comparable<T>, A: Field<T>, BA, L: T> (
|
||||||
|
private val seriesAlgebra: SeriesAlgebra<T, A, BA, L>,
|
||||||
|
private val sConditionThreshold: Int = 15,
|
||||||
|
private val maxSiftIterations: Int = 20,
|
||||||
|
private val siftingDelta: T,
|
||||||
|
private val nModes: Int = 6
|
||||||
|
) where BA: BufferAlgebra<T, A>, BA: FieldOps<Buffer<T>> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a signal, construct an upper and a lower envelopes, find the mean value of two,
|
||||||
|
* represented as a series.
|
||||||
lounres
commented
Please, move the only type argument
Please, move the only type argument `L`'s bound to `L`'s declaration cite:
```kotlin
public class EmpiricalModeDecomposition<BA, L: Number> (
private val seriesAlgebra: SeriesAlgebra<Double, *, BA, L>,
private val sConditionThreshold: Int = 15,
private val maxSiftIterations: Int = 20,
private val siftingDelta: Double = 1e-2,
private val nModes: Int = 6
) where BA: BufferAlgebra<Double, *>, BA: RingOps<Buffer<Double>> {
```
lounres
commented
By the way, in general you'll have to use
or there is no function to map By the way, in general you'll have to use `T` as `L`. Otherwise, either `interpolate` can not be resolved in snippet
```kotlin
interpolator.interpolate(
Buffer(extrema.size) { signal.labels[extrema[it]] },
Buffer(extrema.size) { signal[extrema[it]] }
)
```
or there is no function to map `L` values to `T`.
|
|||||||
|
* @param signal Signal to compute on
|
||||||
|
*
|
||||||
|
* @return mean [Series] or `null`. `null` is returned in case
|
||||||
lounres
commented
Possible typos fix suggestion:
Possible typos fix suggestion:
```
* Take a signal, construct an upper and a lower envelopes, find the mean value of the two,
```
|
|||||||
|
* the signal does not have enough extrema to construct envelopes.
|
||||||
|
*/
|
||||||
|
private fun findMean(signal: Series<T>): Series<T>? = (seriesAlgebra) {
|
||||||
|
val interpolator = SplineInterpolator(elementAlgebra)
|
||||||
lounres
commented
I suggest adding a sentence that describes what is actually returned. And possible typos fix as well.
I suggest adding a sentence that describes what is actually returned. And possible typos fix as well.
```
* @return The mean series or `null`. `null` is returned if the signal does not have enough extrema to construct envelopes.
```
|
|||||||
|
val makeBuffer = elementAlgebra.bufferFactory
|
||||||
|
fun generateEnvelope(extrema: List<Int>, paddedExtremeValues: Buffer<T>): Series<T> {
|
||||||
lounres
commented
Just this is enough. Because there is ```kotlin
private fun findMean(signal: Series<Double>): Series<Double>? = seriesAlgebra {
```
Just this is enough. Because there is `invoke` extension operator implemented that is imported here.
|
|||||||
|
val envelopeFunction = interpolator.interpolate(
|
||||||
|
makeBuffer(extrema.size) { signal.labels[extrema[it]] },
|
||||||
|
paddedExtremeValues
|
||||||
|
)
|
||||||
|
return signal.mapWithLabel { _, label ->
|
||||||
|
// For some reason PolynomialInterpolator is exclusive and the right boundary
|
||||||
|
// TODO Notify interpolator authors
|
||||||
|
envelopeFunction(label) ?: paddedExtremeValues.last()
|
||||||
|
// need to make the interpolator yield values outside boundaries?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Extrema padding (experimental) TODO padding needs a dedicated function
|
||||||
|
val maxima = listOf(0) + signal.peaks() + (signal.size - 1)
|
||||||
|
val maxValues = makeBuffer(maxima.size) { signal[maxima[it]] }
|
||||||
|
if (maxValues[0] < maxValues[1]) {
|
||||||
|
maxValues[0] = maxValues[1]
|
||||||
|
}
|
||||||
|
if (maxValues.last() < maxValues[maxValues.size - 2]) {
|
||||||
|
maxValues[maxValues.size - 1] = maxValues[maxValues.size - 2]
|
||||||
lounres
commented
```kotlin
return upperEnvelope.zip(lowerEnvelope) { left, right -> (left + right) / 2 }
```
|
|||||||
|
}
|
||||||
|
|
||||||
|
val minima = listOf(0) + signal.troughs() + (signal.size - 1)
|
||||||
|
val minValues = makeBuffer(minima.size) { signal[minima[it]] }
|
||||||
|
if (minValues[0] > minValues[1]) {
|
||||||
|
minValues[0] = minValues[1]
|
||||||
|
}
|
||||||
|
if (minValues.last() > minValues[minValues.size - 2]) {
|
||||||
|
minValues[minValues.size - 1] = minValues[minValues.size - 2]
|
||||||
|
}
|
||||||
|
return if (maxima.size < 3 || minima.size < 3) null else { // maybe make an early return?
|
||||||
|
val upperEnvelope = generateEnvelope(maxima, maxValues)
|
||||||
lounres
commented
As well as I understand, whole body of the function can be replaced with just
As well as I understand, whole body of the function can be replaced with just
```kotlin
private fun sift(signal: Series<Double>): SiftingResult = siftInner(signal, 1, 0)
```
teldufalsari
commented
Also Also `siftInner` should be marked `tailrec`, shouldn't it?
lounres
commented
Yeah, it is better if the function is marked Yeah, it is better if the function is marked `tailrec`. But I am not sure if compiler understands the case. So I need a bit of time for a small test.
teldufalsari
commented
It works. With It works. With `tailrec` it does not produce stack overflow when I run sifting with 5000 iterations per mode
|
|||||||
|
val lowerEnvelope = generateEnvelope(minima, minValues)
|
||||||
|
return (upperEnvelope + lowerEnvelope).map { it * 0.5 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a single empirical mode from a signal. This process is called sifting, hence the name.
|
||||||
|
* @param signal Signal to extract a mode from. The first mode is extracted from the initial signal,
|
||||||
|
* subsequent modes are extracted from the residuals between the signal and all previous modes.
|
||||||
|
*
|
||||||
|
* @return [SiftingResult.NotEnoughExtrema] is returned if the signal has too few extrema to extract a mode.
|
||||||
|
* Success of an appropriate type (See [SiftingResult.Success] class) is returned otherwise.
|
||||||
|
*/
|
||||||
|
private fun sift(signal: Series<T>): SiftingResult = siftInner(signal, 1, 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a single iteration of the sifting process.
|
||||||
|
*/
|
||||||
|
private tailrec fun siftInner(
|
||||||
|
prevMode: Series<T>,
|
||||||
|
iterationNumber: Int,
|
||||||
|
sNumber: Int
|
||||||
lounres
commented
Gosh. Use
It's idiom, and it's clearer to read. Gosh. Use `when` instead of long if-else sequence, please:
```kotlin
return when {
iterationNumber >= maxSiftIterations -> SiftingResult.MaxIterationsReached(mode)
sNumber >= sConditionThreshold -> SiftingResult.SNumberReached(mode)
relativeDifference(prevMode, mode) < siftingDelta * mode.size -> SiftingResult.DeltaReached(mode)
else -> siftInner(mode, iterationNumber + 1, newSNumber)
}
```
It's idiom, and it's clearer to read.
|
|||||||
|
): SiftingResult = (seriesAlgebra) {
|
||||||
|
val mean = findMean(prevMode) ?:
|
||||||
lounres
commented
Is it intended that previous mode is assigned to I would use explicit parameters' assignation:
Is it intended that previous mode is assigned to `current` parameter of `relativeDifference` and current (new) mode is assigned to `previous` parameter of `relativeDifference`? The parameters names say that there is a swap of parameters.
I would use explicit parameters' assignation:
```kotlin
relativeDifference(previous = prevMode, current = mode)
```
|
|||||||
|
return if (iterationNumber == 1) SiftingResult.NotEnoughExtrema
|
||||||
|
else SiftingResult.SignalFlattened(prevMode)
|
||||||
|
val mode = prevMode.zip(mean) { p, m -> p - m }
|
||||||
|
val newSNumber = if (sCondition(mode)) sNumber + 1 else sNumber
|
||||||
|
return when {
|
||||||
|
iterationNumber >= maxSiftIterations -> SiftingResult.MaxIterationsReached(mode)
|
||||||
|
sNumber >= sConditionThreshold -> SiftingResult.SNumberReached(mode)
|
||||||
|
relativeDifference(mode, prevMode) < (elementAlgebra) { siftingDelta * mode.size } ->
|
||||||
|
SiftingResult.DeltaReached(mode)
|
||||||
|
else -> siftInner(mode, iterationNumber + 1, newSNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
lounres
commented
The whole function can be rewritten in such way:
It's shorter but as readable as the previous version. The whole function can be rewritten in such way:
```kotlin
public fun decompose(signal: Series<Double>): EMDecompositionResult = with(seriesAlgebra) {
val modes = mutableListOf<Series<Double>>()
var residual = signal
repeat(nModes) {
val nextMode = when(val r = sift(residual)) {
SiftingResult.NotEnoughExtrema ->
return EMDecompositionResult(
if (it == 0) EMDTerminationReason.SIGNAL_TOO_FLAT
else EMDTerminationReason.ALL_POSSIBLE_MODES_EXTRACTED,
modes
)
is SiftingResult.Success -> r.result
}
modes.add(nextMode)
residual = residual.zip(nextMode) { l, r -> l - r }
}
return EMDecompositionResult(EMDTerminationReason.MAX_MODES_REACHED, modes)
}
```
It's shorter but as readable as the previous version.
|
|||||||
|
|
||||||
|
/**
|
||||||
|
* Extract [nModes] empirical modes from a signal represented by a time series.
|
||||||
|
* @param signal Signal to decompose.
|
||||||
|
* @return [EMDecompositionResult] with an appropriate reason for algorithm termination
|
||||||
|
* (see [EMDTerminationReason] for possible reasons).
|
||||||
|
* Modes returned in a list which contains as many modes as it was possible
|
||||||
|
* to extract before triggering one of the termination conditions.
|
||||||
|
*/
|
||||||
lounres
commented
You should use You should use `SeriesAlgebra`'s `elementAlgebra` in getting difference of two `Double` values instead of `l - r`. And in 8 strings below too.
|
|||||||
|
public fun decompose(signal: Series<T>): EMDecompositionResult<T> = (seriesAlgebra) {
|
||||||
|
val modes = mutableListOf<Series<T>>()
|
||||||
|
var residual = signal
|
||||||
|
repeat(nModes) {
|
||||||
|
val nextMode = when(val r = sift(residual)) {
|
||||||
|
SiftingResult.NotEnoughExtrema ->
|
||||||
|
return EMDecompositionResult(
|
||||||
|
if (it == 0) EMDTerminationReason.SIGNAL_TOO_FLAT
|
||||||
|
else EMDTerminationReason.ALL_POSSIBLE_MODES_EXTRACTED,
|
||||||
|
modes,
|
||||||
|
residual
|
||||||
|
)
|
||||||
|
is SiftingResult.Success<*> -> r.result
|
||||||
|
}
|
||||||
|
modes.add(nextMode as Series<T>) // TODO remove unchecked cast
|
||||||
|
residual = residual.zip(nextMode) { l, r -> l - r }
|
||||||
|
}
|
||||||
|
return EMDecompositionResult(EMDTerminationReason.MAX_MODES_REACHED, modes, residual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortcut to retrieve a decomposition factory from a [SeriesAlgebra] scope.
|
||||||
|
* @receiver scope to perform operations in.
|
||||||
|
* @param maxSiftIterations number of iterations in the mode extraction (sifting) process after which
|
||||||
|
* the result is returned even if no other conditions are met.
|
||||||
|
* @param sConditionThreshold one of the possible criteria for sifting process termination:
|
||||||
|
* how many times in a row should a proto-mode satisfy the s-condition (the number of zeros differs from
|
||||||
|
* the number of extrema no more than by 1) to be considered an empirical mode.
|
||||||
|
* @param siftingDelta one of the possible criteria for sifting process termination: if relative difference of
|
||||||
|
* two proto-modes obtained in a sequence is less that this number, the last proto-mode is considered
|
||||||
|
* an empirical mode and returned.
|
||||||
|
* @param nModes how many modes should be extracted at most. The algorithm may return fewer modes if it was not
|
||||||
|
* possible to extract more modes from the signal.
|
||||||
|
*/
|
||||||
|
public fun <T: Comparable<T>, L: T, A: Field<T>, BA> SeriesAlgebra<T, A, BA, L>.empiricalModeDecomposition(
|
||||||
|
sConditionThreshold: Int = 15,
|
||||||
|
maxSiftIterations: Int = 20,
|
||||||
|
siftingDelta: T,
|
||||||
|
nModes: Int = 3
|
||||||
|
): EmpiricalModeDecomposition<T, A, BA, L>
|
||||||
|
where BA: BufferAlgebra<T, A>, BA: FieldOps<Buffer<T>> = EmpiricalModeDecomposition(
|
||||||
|
seriesAlgebra = this,
|
||||||
|
sConditionThreshold = sConditionThreshold,
|
||||||
|
maxSiftIterations = maxSiftIterations,
|
||||||
|
siftingDelta = siftingDelta,
|
||||||
|
nModes = nModes
|
||||||
lounres
commented
Do not use Do not use `Pair`! It's non-idiomatic in Kotlin. It is really hard to always keep in mind what `first` and `second` fields hold. Instead, define and use custom data class with understandable name and understandable parameters' names.
teldufalsari
commented
Is making a one-line data class declaration above considered idiomatic?
Is making a one-line data class declaration above considered idiomatic?
```kotlin
data class SignCounter(val prevSign: Double, val zeroCount: Int)
return fold(SignCounter(sign(get(0)), 0)) { acc: SignCounter, it: Double ->
```
lounres
commented
Yeah, that's the idiomatic way. But place it outside the function. Otherwise, you won't able to access it :) Yeah, that's the idiomatic way. But place it outside the function. Otherwise, you won't able to access it :)
|
|||||||
|
)
|
||||||
lounres
commented
It's better to store sign instead of the value which sign is compared, to not compute it each iteration. It's better to store sign instead of the value which sign is compared, to not compute it each iteration.
|
|||||||
|
|
||||||
|
/**
|
||||||
|
* Brute force count all zeros in the series.
|
||||||
|
*/
|
||||||
|
internal fun <T: Comparable<T>, A: Ring<T>, BA> SeriesAlgebra<T, A, BA, *>.countZeros(
|
||||||
|
signal: Series<T>
|
||||||
|
): Int where BA: BufferAlgebra<T, A>, BA: FieldOps<Buffer<T>> {
|
||||||
|
require(signal.size >= 2) { "Expected series with at least 2 elements, but got ${signal.size} elements" }
|
||||||
|
data class SignCounter(val prevSign: Int, val zeroCount: Int)
|
||||||
|
fun strictSign(arg: T): Int = if (arg > elementAlgebra.zero) 1 else -1
|
||||||
lounres
commented
Use
Also:
Use `=` notation for inline bodies, please:
```kotlin
private fun <A: Ring<Double>, BA> SeriesAlgebra<Double, A, BA, *>.relativeDifference(
current: Series<Double>,
previous: Series<Double>
):Double where BA: BufferAlgebra<Double, A>, BA: RingOps<Buffer<Double>> =
(current - previous).pow(2)
.div(previous pow 2)
.fold(0.0) { acc, d -> elementAlgebra.add(acc, d) }
```
Also:
1. `L` is not used, so I removed it.
2. Default algebra used when `SeriesAlgebra`'s `elementAlgebra` is needed. So I replaced it. It will also help with refactoring from `Double` "algebras" to general algebras.
3. No, `fold` uses `Double` as a type parameter, so boxing is not avoided. I would replace `.fold(0.0) { acc, d -> acc + d }` with `.sum(elementAlgebra)` but there is no such operation for some reason.
teldufalsari
commented
I also wondered why there is no I also wondered why there is no `.sum()` method. I could implement it for a 1-d series, but doing it for a general buffer seems a bit too much if there is need for arbitrary axis like in NumPy
lounres
commented
@altavir There is a need for a function @altavir There is a need for a function `Buffer<T>.sum(elementAlgebra: Group<T>)`. Where should we place it?
|
|||||||
|
|
||||||
|
return signal.fold(SignCounter(strictSign(signal[0]), 0)) { acc, it ->
|
||||||
|
val currentSign = strictSign(it)
|
||||||
|
if (acc.prevSign != currentSign) SignCounter(currentSign, acc.zeroCount + 1)
|
||||||
|
else SignCounter(currentSign, acc.zeroCount)
|
||||||
|
}.zeroCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute relative difference of two series.
|
||||||
|
*/
|
||||||
|
private fun <T, A: Ring<T>, BA> SeriesAlgebra<T, A, BA, *>.relativeDifference(
|
||||||
|
current: Series<T>,
|
||||||
|
previous: Series<T>
|
||||||
lounres
commented
I would recommend writing
I would recommend writing
```kotlin
return (1 .. size - 2).count { isExtreme(this[it-1], this[it], this[it+1]) }
```
|
|||||||
|
): T where BA: BufferAlgebra<T, A>, BA: FieldOps<Buffer<T>> = (bufferAlgebra) {
|
||||||
|
((current - previous) * (current - previous))
|
||||||
|
.div(previous * previous)
|
||||||
|
.fold(elementAlgebra.zero) { acc, it -> acc + it}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brute force count all extrema of a series.
|
||||||
|
*/
|
||||||
|
internal fun <T: Comparable<T>> Series<T>.countExtrema(): Int {
|
||||||
lounres
commented
```kotlin
private fun <T : Comparable<T>> Series<T>.maxima(): List<Int> {
```
|
|||||||
|
require(size >= 3) { "Expected series with at least 3 elements, but got $size elements" }
|
||||||
|
return peaks().size + troughs().size
|
||||||
|
}
|
||||||
lounres
commented
I would recommend rewriting it with old good plain loop on indices:
or using
I would recommend rewriting it with old good plain loop on indices:
```kotlin
for (index in 1 .. size - 2) {
val left = this[index-1]
val middle = this[index]
val right = this[index+1]
if (middle > left && middle > right) maxima.add(index)
}
```
or using
```kotlin
return (1 .. size - 2).filter { (this[it] > this[it-1] && this[it] > this[it+1]) || it == 0 || it == size - 1 }
```
lounres
commented
Also, is it intended that the spline will ignore double extrema? I mean for series Also, is it intended that the spline will ignore double extrema?
I mean for series `[0.0, -1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 0.0]` there will be no maxima and minima points but the end points.
teldufalsari
commented
No, I'm planning on improving this function and making it > Also, is it intended that the spline will ignore double extrema?
No, I'm planning on improving this function and making it `public` placed in `SeriesExtensions.kt`
|
|||||||
|
|
||||||
lounres
commented
Could you comment what question Could you comment what question ` // weird offset, is there a way to do it better?` means in more detail?
|
|||||||
|
/**
|
||||||
|
* Check whether the numbers of zeroes and extrema of a series differ by no more than 1.
|
||||||
|
* This is a necessary condition of an empirical mode.
|
||||||
|
*/
|
||||||
|
private fun <T: Comparable<T>, A: Ring<T>, BA> SeriesAlgebra<T, A, BA, *>.sCondition(
|
||||||
|
signal: Series<T>
|
||||||
|
): Boolean where BA: BufferAlgebra<T, A>, BA: FieldOps<Buffer<T>> =
|
||||||
|
(signal.countExtrema() - countZeros(signal)) in -1..1
|
||||||
|
|
||||||
|
internal sealed interface SiftingResult {
|
||||||
|
|
||||||
|
/**
|
||||||
lounres
commented
```kotlin
private fun <T : Comparable<T>> Series<T>.minima(): List<Int> {
```
|
|||||||
|
* Represents a condition when a mode has been successfully
|
||||||
|
* extracted in a sifting process.
|
||||||
|
*/
|
||||||
lounres
commented
I would recommend rewriting it with old good plain loop on indices:
or using
I would recommend rewriting it with old good plain loop on indices:
```kotlin
for (index in 1 .. size - 2) {
val left = this[index-1]
val middle = this[index]
val right = this[index+1]
if (middle < left && middle < right) maxima.add(index)
}
```
or using
```kotlin
return (1 .. size - 2).filter { (this[it] < this[it-1] && this[it] < this[it+1]) || it == 0 || it == size - 1 }
```
lounres
commented
Also, similar question to this one. Also, similar question to [this one](https://git.sciprog.center/kscience/kmath/pulls/521/files#issuecomment-1868).
|
|||||||
|
open class Success<T>(val result: Series<T>): SiftingResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned when no termination condition was reached and the proto-mode
|
||||||
|
* has become too flat (with not enough extrema to build envelopes)
|
||||||
|
* after several sifting iterations.
|
||||||
|
*/
|
||||||
|
class SignalFlattened<T>(result: Series<T>) : Success<T>(result)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned when sifting process has been terminated due to the
|
||||||
|
* S-number condition being reached.
|
||||||
|
*/
|
||||||
|
class SNumberReached<T>(result: Series<T>) : Success<T>(result)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned when sifting process has been terminated due to the
|
||||||
|
* delta condition (Cauchy criterion) being reached.
|
||||||
|
*/
|
||||||
|
class DeltaReached<T>(result: Series<T>) : Success<T>(result)
|
||||||
|
|
||||||
lounres
commented
I don't understand what the I don't understand what the `Success` inheritors are for. I mean, they are all instantiated but never distinguished. All of them can be used only internally, but are cast to `Success` anyway.
|
|||||||
|
/**
|
||||||
|
* Returned when sifting process has been terminated after
|
||||||
|
* executing the maximum number of iterations (specified when creating an instance
|
||||||
|
* of [EmpiricalModeDecomposition]).
|
||||||
|
*/
|
||||||
|
class MaxIterationsReached<T>(result: Series<T>): Success<T>(result)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned when the submitted signal has not enough extrema to build envelopes,
|
||||||
|
* i.e. when [SignalFlattened] condition has already been reached before the first sifting iteration.
|
||||||
|
*/
|
||||||
|
data object NotEnoughExtrema : SiftingResult
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum class EMDTerminationReason {
|
||||||
|
/**
|
||||||
|
* Returned when the signal is too flat, i.e. there are too few extrema
|
||||||
|
* to build envelopes necessary to extract modes.
|
||||||
|
*/
|
||||||
|
SIGNAL_TOO_FLAT,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned when there has been extracted as many modes as
|
||||||
|
* specified when creating the instance of [EmpiricalModeDecomposition]
|
||||||
|
*/
|
||||||
|
MAX_MODES_REACHED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned when the algorithm terminated after finding impossible
|
||||||
|
* to extract more modes from the signal and the maximum number
|
||||||
|
* of modes (specified when creating an instance of [EmpiricalModeDecomposition])
|
||||||
|
* has not yet been reached.
|
||||||
|
*/
|
||||||
|
ALL_POSSIBLE_MODES_EXTRACTED
|
||||||
|
}
|
||||||
|
|
||||||
|
public data class EMDecompositionResult<T>(
|
||||||
|
val terminatedBecause: EMDTerminationReason,
|
||||||
|
val modes: List<Series<T>>,
|
||||||
|
val residual: Series<T>
|
||||||
|
)
|
@ -169,7 +169,7 @@ public open class SeriesAlgebra<T, out A : Ring<T>, out BA : BufferAlgebra<T, A>
|
|||||||
public inline fun Buffer<T>.mapWithLabel(crossinline transform: A.(arg: T, label: L) -> T): Series<T> {
|
public inline fun Buffer<T>.mapWithLabel(crossinline transform: A.(arg: T, label: L) -> T): Series<T> {
|
||||||
val labels = labels
|
val labels = labels
|
||||||
val buf = elementAlgebra.bufferFactory(size) {
|
val buf = elementAlgebra.bufferFactory(size) {
|
||||||
elementAlgebra.transform(getByOffset(it), labels[it])
|
elementAlgebra.transform(get(it), labels[it])
|
||||||
}
|
}
|
||||||
return buf.moveTo(offsetIndices.first)
|
return buf.moveTo(offsetIndices.first)
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2018-2024 KMath contributors.
|
||||||
|
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package space.kscience.kmath.series
|
||||||
|
|
||||||
|
|
||||||
|
public enum class PlateauEdgePolicy {
|
||||||
|
/**
|
||||||
|
* Midpoints of plateau are returned, edges not belonging to a plateau are ignored.
|
||||||
|
*
|
||||||
|
* A midpoint is the index closest to the average of indices of the left and right edges
|
||||||
|
* of the plateau:
|
||||||
|
*
|
||||||
|
* `val midpoint = ((leftEdge + rightEdge) / 2).toInt`
|
||||||
|
*/
|
||||||
|
AVERAGE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both left and right edges are returned.
|
||||||
|
*/
|
||||||
|
KEEP_ALL_EDGES,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only right edges are returned.
|
||||||
|
*/
|
||||||
|
KEEP_RIGHT_EDGES,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only left edges are returned.
|
||||||
|
*/
|
||||||
|
KEEP_LEFT_EDGES,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ignore plateau, only peaks (troughs) with values strictly greater (less)
|
||||||
|
* than values of the adjacent points are returned.
|
||||||
|
*/
|
||||||
|
IGNORE
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public fun <T: Comparable<T>> Series<T>.peaks(
|
||||||
|
plateauEdgePolicy: PlateauEdgePolicy = PlateauEdgePolicy.AVERAGE
|
||||||
|
): List<Int> = findPeaks(plateauEdgePolicy, { other -> this > other }, { other -> this >= other })
|
||||||
|
|
||||||
|
public fun <T: Comparable<T>> Series<T>.troughs(
|
||||||
|
plateauEdgePolicy: PlateauEdgePolicy = PlateauEdgePolicy.AVERAGE
|
||||||
|
): List<Int> = findPeaks(plateauEdgePolicy, { other -> this < other }, { other -> this <= other })
|
||||||
|
|
||||||
|
|
||||||
|
private fun <T: Comparable<T>> Series<T>.findPeaks(
|
||||||
|
plateauPolicy: PlateauEdgePolicy = PlateauEdgePolicy.AVERAGE,
|
||||||
|
cmpStrong: T.(T) -> Boolean,
|
||||||
|
cmpWeak: T.(T) -> Boolean
|
||||||
|
): List<Int> {
|
||||||
|
require(size >= 3) { "Expected series with at least 3 elements, but got $size elements" }
|
||||||
|
if (plateauPolicy == PlateauEdgePolicy.AVERAGE) return peaksWithPlateau(cmpStrong)
|
||||||
|
fun peakCriterion(left: T, middle: T, right: T): Boolean = when(plateauPolicy) {
|
||||||
|
PlateauEdgePolicy.KEEP_LEFT_EDGES -> middle.cmpStrong(left) && middle.cmpWeak(right)
|
||||||
|
PlateauEdgePolicy.KEEP_RIGHT_EDGES -> middle.cmpWeak(left) && middle.cmpStrong(right)
|
||||||
|
PlateauEdgePolicy.KEEP_ALL_EDGES ->
|
||||||
|
(middle.cmpStrong(left) && middle.cmpWeak(right)) || (middle.cmpWeak(left) && middle.cmpStrong(right))
|
||||||
|
else -> middle.cmpStrong(right) && middle.cmpStrong(left)
|
||||||
|
}
|
||||||
|
val indices = mutableListOf<Int>()
|
||||||
|
for (index in 1 .. size - 2) {
|
||||||
|
val left = this[index - 1]
|
||||||
|
val middle = this[index]
|
||||||
|
val right = this[index + 1]
|
||||||
|
if (peakCriterion(left, middle, right)) indices.add(index)
|
||||||
|
}
|
||||||
|
return indices
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun <T: Comparable<T>> Series<T>.peaksWithPlateau(cmpStrong: T.(T) -> Boolean): List<Int> {
|
||||||
|
val peaks = mutableListOf<Int>()
|
||||||
|
tailrec fun peaksPlateauInner(index: Int) {
|
||||||
|
val nextUnequal = (index + 1 ..< size).firstOrNull { this[it] != this[index] } ?: (size - 1)
|
||||||
|
val newIndex = if (this[index].cmpStrong(this[index - 1]) && this[index].cmpStrong(this[nextUnequal])) {
|
||||||
|
peaks.add((index + nextUnequal) / 2)
|
||||||
|
nextUnequal
|
||||||
|
} else index + 1
|
||||||
|
if (newIndex < size - 1) peaksPlateauInner(newIndex)
|
||||||
|
}
|
||||||
|
peaksPlateauInner(1)
|
||||||
|
return peaks
|
||||||
|
}
|
@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2018-2024 KMath contributors.
|
||||||
|
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package space.kscience.kmath.series
|
||||||
|
|
||||||
|
import space.kscience.kmath.operations.algebra
|
||||||
|
import space.kscience.kmath.operations.bufferAlgebra
|
||||||
|
import space.kscience.kmath.operations.invoke
|
||||||
|
import space.kscience.kmath.structures.asBuffer
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmd {
|
||||||
|
companion object{
|
||||||
|
val testAlgebra = (Double.algebra.bufferAlgebra) { SeriesAlgebra(this) { it.toDouble() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBasic() = (testAlgebra) {
|
||||||
|
val signal = DoubleArray(800) {
|
||||||
|
sin(it.toDouble() / 10.0) + 3.5 * sin(it.toDouble() / 60.0)
|
||||||
|
}.asBuffer().moveTo(0)
|
||||||
|
val emd = empiricalModeDecomposition(
|
||||||
|
sConditionThreshold = 1,
|
||||||
|
maxSiftIterations = 15,
|
||||||
|
siftingDelta = 1e-2,
|
||||||
|
nModes = 4
|
||||||
|
).decompose(signal)
|
||||||
|
|
||||||
|
assertEquals(emd.modes.size, 3)
|
||||||
|
emd.modes.forEach { imf ->
|
||||||
|
assertTrue(imf.peaks().size - imf.troughs().size in -1..1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testNoiseFiltering() = (testAlgebra) {
|
||||||
|
val signal = DoubleArray(800) {
|
||||||
|
sin(it.toDouble() / 30.0) + 2.0 * (Random.nextDouble() - 0.5)
|
||||||
|
}.asBuffer().moveTo(0)
|
||||||
|
val emd = empiricalModeDecomposition(
|
||||||
|
sConditionThreshold = 10,
|
||||||
|
maxSiftIterations = 15,
|
||||||
|
siftingDelta = 1e-2,
|
||||||
|
nModes = 10
|
||||||
|
).decompose(signal)
|
||||||
|
// Check whether the signal with the expected frequency is present
|
||||||
|
assertEquals(emd.modes.count { it.countExtrema() in 7..9 }, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testZeros() = (testAlgebra) {
|
||||||
|
val nSamples = 200
|
||||||
|
// sin(10*x) where x in [0, 1)
|
||||||
|
val signal = DoubleArray(nSamples) {
|
||||||
|
sin(it * 10.0 / (nSamples - 1))
|
||||||
|
}.asBuffer().moveTo(0)
|
||||||
|
assertEquals(countZeros(signal), 4)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2018-2024 KMath contributors.
|
||||||
|
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package space.kscience.kmath.series
|
||||||
|
|
||||||
|
import space.kscience.kmath.operations.algebra
|
||||||
|
import space.kscience.kmath.operations.bufferAlgebra
|
||||||
|
import space.kscience.kmath.operations.invoke
|
||||||
|
import space.kscience.kmath.structures.asBuffer
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class TestPeakFinding {
|
||||||
|
companion object {
|
||||||
|
val testAlgebra = (Double.algebra.bufferAlgebra) { SeriesAlgebra(this) { it.toDouble() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testPeakFinding() = (testAlgebra) {
|
||||||
|
val nSamples = 600
|
||||||
|
val signal = DoubleArray(nSamples) {
|
||||||
|
val x = it * 12.0 / (nSamples - 1)
|
||||||
|
(3.5 * sin(x)).coerceIn(-3.0 .. 3.0)
|
||||||
|
}.asBuffer().moveTo(0)
|
||||||
|
|
||||||
|
val peaksAvg = signal.peaks(PlateauEdgePolicy.AVERAGE)
|
||||||
|
val troughsAvg = signal.troughs(PlateauEdgePolicy.AVERAGE)
|
||||||
|
assertEquals(peaksAvg.size, 2)
|
||||||
|
assertEquals(troughsAvg.size, 2)
|
||||||
|
|
||||||
|
val peaksBoth = signal.peaks(PlateauEdgePolicy.KEEP_ALL_EDGES)
|
||||||
|
val troughsBoth = signal.peaks(PlateauEdgePolicy.KEEP_ALL_EDGES)
|
||||||
|
assertEquals(peaksBoth.size, 4)
|
||||||
|
assertEquals(troughsBoth.size, 4)
|
||||||
|
}
|
||||||
|
}
|
is enough if you import
import space.kscience.kmath.operations.invoke
.