correlations.kt
src/jvmMain/kotlin/afield/math/statistics/correlations.kt · 1001 lines · cited by Measuring dependence between two signals
package and imports — 61 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Math")
package afield.math.statistics
import afield.animation.keyframes.h
import afield.data.collections.get
import afield.data.collections.invoke
import afield.data.collections.times
import afield.document.context.why
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.clear
import afield.document.workspace.file
import afield.geometry.mesh.normalized
import afield.geometry.spatial.center
import afield.graphics.interaction.pointer
import afield.graphics.render.rangeTo
import afield.lang.stacksl.h
import afield.math.autodiff.clamp
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.product
import afield.math.autodiff.sign
import afield.math.autodiff.sum
import afield.math.autodiff.times
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.normalized
import afield.runtime.execution.cell
import afield.runtime.lifecycle.Once
import afield.study.ui.numbers
import afield.ui.output.h
import afield.audio.synth.*
import afield.data.collections.m
import afield.data.collections.r
import afield.data.collections.s
import afield.document.properties.compareTo
import afield.math.autodiff.abs
import afield.math.autodiff.compareTo
import afield.math.autodiff.floor
import afield.math.autodiff.ln
import afield.math.autodiff.max
import afield.math.autodiff.mean
import afield.math.autodiff.min
import afield.math.autodiff.norm
import afield.math.autodiff.pow
import afield.math.autodiff.sqrt
import afield.math.linalg.d
import afield.math.linalg.i
import afield.math.linalg.l
import afield.math.linalg.x
import afield.runtime.execution.end
import kotlin.math.*
Measures of statistical dependence between two equal-length DoubleArrays, batch and rolling.
StatsDependence carries the batch versions: pearson, spearman (Pearson on average-tie
ranks), kendallTauB, chatterjeeXi (aliased as xi), distanceCorrelation and
micTicNaive. Kendall's tau-b and distance correlation are both O(n²) in time, and distance
correlation additionally materialises two n×n matrices, so neither suits long series.
RollingDistanceCorrelation holds a fixed window and updates its maintained row and grand sums
in O(n) per push; RollingXiCorrelation only keeps the window and recomputes xi from
scratch. All of it is pure Kotlin with no external dependency.
micTicNaive is an equipartition-grid approximation rather than either published algorithm: it
bins x and y by rank into equal counts, scans every (xBins, yBins) whose product stays inside a
budget that defaults to n^0.6, and takes the largest normalised mutual information it finds
(the sum over the same grids is its TIC). That is MIC- and TIC-shaped without being either
MICe/TICe or the MINE dynamic program, so its values will not match a reference
implementation's. Use it for intuition and prototyping.
StatsDependence.kt Single-file Kotlin implementation of: - Pearson correlation - Spearman rank correlation (with average-rank ties) - Kendall's tau-b (tie-adjusted, O(n^2)) - Chatterjee's xi rank dependence coefficient - Distance correlation (Székely–Rizzo; O(n^2)) - A naive MIC/TIC-style grid mutual-information scan using equipartition bins
WHAT THE MIC/TIC SCAN HERE IS AND IS NOT: - The "MIC" here is NOT the published MICe (e.g., Reshef et al. AoAS 2018) and not the original dynamic-programming MINE approximation either. It’s a simple, self-contained approximation: * uses equipartition bins for x and y * scans all (xBins, yBins) with xBins*yBins <= B(n) * computes normalized mutual information on that grid and takes the maximum (MIC-like) - The "TIC" here is likewise a simple sum over those normalized scores (TIC-like). - These are useful for intuition / prototyping, but do not expect MICe/TICe-identical values.
If you want something closer to MICe/TICe, you’ll likely want to port a reference implementation (e.g., minepy or minerva / Reshef’s AoAS code) which is substantially more complex.
object StatsDependence
The batch dependence coefficients: hand any of these two equal-length DoubleArrays and get one
number back per notion of "related" — pearson for linear association, spearman and
kendallTauB for monotone association on ranks, chatterjeeXi for "is y a function of x",
distanceCorrelation for dependence of any shape, micTicNaive for a grid mutual-information scan.
Every entry point runs the same validation first: the two arrays must be the same length and at
least two long, or it throws IllegalArgumentException. Nothing here filters non-finite input, so a
single NaN in either array propagates straight into the result. Variances and covariances are the
population (divide by n) forms, which cancels out of a correlation anyway.
The rank-based members sort by value, and each has its own answer for ties. ranksAverageTies hands
tied values their average rank; the private helper inside micTicNaive uses plain order ranks, which
is all its equipartition binning needs; chatterjeeXi resolves ties among the x values by taking the
expectation over all their orderings, so — unlike the other two — its answer does not depend on the
order the pairs were presented in.
object StatsDependence {
Validation / helpers
private fun requireSameSize(x: DoubleArray, y: DoubleArray) {
require(x.size == y.size) { "x and y must have same length (x=${x.size}, y=${y.size})" }
require(x.size >= 2) { "Need at least 2 points" }
}
private fun mean(a: DoubleArray): Double {
var s = 0.0
for (v in a) s += v
return s / a.size
}
private fun variance(a: DoubleArray, mu: Double): Double {
var s = 0.0
for (v in a) {
val d = v - mu
s += d * d
}
// population variance (divide by n); for correlation it cancels anyway
return s / a.size
}
private fun covariance(x: DoubleArray, y: DoubleArray, mx: Double, my: Double): Double {
var s = 0.0
for (i in x.indices) s += (x[i] - mx) * (y[i] - my)
// population covariance (divide by n)
return s / x.size
}
Average-rank ties: returns ranks in [1..n] with average ranks for tied values.
fun ranksAverageTies(values: DoubleArray): DoubleArray
Ranks in 1..n, smallest value first, with tied values sharing the average of the positions they
occupy: 3, 1, 4, 1, 5 comes back as 3.0, 1.5, 4.0, 1.5, 5.0, since the two 1s sit at positions
1 and 2. The result therefore always sums to n(n+1)/2 whatever the input looks like.
Ties are broken by original index, so the ordering is stable and the ranks do not depend on the
sort's internals. Equality is exact Double comparison — values a hair apart count as distinct
and get consecutive ranks rather than a shared average. O(n log n); spearman is built on it.
fun ranksAverageTies(values: DoubleArray): DoubleArray {
val n = values.size
// sortedWith returns a new list; index through that, never through the unsorted 0..n-1.
val order = (0 until n).sortedWith { i, j ->
val a = values[i]
val b = values[j]
when {
a < b -> -1
a > b -> 1
else -> i - j // stable tie-break
}
}
val ranks = DoubleArray(n)
var pos = 0
while (pos < n) {
var end = pos
val v = values[order[pos]]
while (end + 1 < n && values[order[end + 1]] == v) end++
// positions are 0-based; rank is 1-based
val avgRank = ((pos + 1) + (end + 1)) / 2.0
for (k in pos..end) {
ranks[order[k]] = avgRank
}
pos = end + 1
}
return ranks
}
Pearson
fun pearson(x: DoubleArray, y: DoubleArray): Double
Pearson's product-moment correlation, in -1..1: 1 is a perfect increasing straight line, -1 a perfect decreasing one, and 0 means no linear relationship — which is emphatically not the same as no relationship, as the symmetric parabola in main demonstrates.
Returns NaN, not 0, when either array has zero variance (every value identical), because the coefficient is genuinely undefined there; note that distanceCorrelation answers 0.0 in the same situation. Throws if the arrays differ in length or hold fewer than two points. O(n), one pass each for the means and one for the covariance.
fun pearson(x: DoubleArray, y: DoubleArray): Double {
requireSameSize(x, y)
val mx = mean(x)
val my = mean(y)
val vx = variance(x, mx)
val vy = variance(y, my)
if (vx == 0.0 || vy == 0.0) return Double.NaN
val cov = covariance(x, y, mx, my)
return cov / sqrt(vx * vy)
}
Spearman (Pearson on ranks)
fun spearman(x: DoubleArray, y: DoubleArray): Double
Spearman's rank correlation — pearson applied to each array's ranksAverageTies, which puts a monotone-but-curved relationship at ±1 where Pearson reads less: exactly +1 for any strictly increasing pair, exactly -1 for any strictly decreasing one, whatever curve connects them.
Monotone is the whole of what it sees, so a symmetric parabola reads 0 here just as it does under pearson — reach for chatterjeeXi or distanceCorrelation for non-monotone shapes. Ties pull the magnitude in from ±1 by way of the shared average ranks, and NaN comes back when either array is constant, since every rank is then identical and pearson has no variance to divide by.
fun spearman(x: DoubleArray, y: DoubleArray): Double {
requireSameSize(x, y)
val rx = ranksAverageTies(x)
val ry = ranksAverageTies(y)
return pearson(rx, ry)
}
Kendall tau-b (O(n^2)) tau-b = (C - D) / sqrt((C + D + Tx) * (C + D + Ty)) where Tx is number of pairs tied only in x; Ty tied only in y.
This handles ties (tau-b). If all x or all y are identical -> NaN.
fun kendallTauB(x: DoubleArray, y: DoubleArray): Double
Kendall's tau-b, in -1..1: concordant minus discordant pairs over all pairs, corrected for ties in either variable. Like spearman it measures monotone rather than linear association, and the two agree in sign throughout; tau-b is the more conservative of the pair in magnitude.
Returns NaN when the tie-adjusted denominator collapses — all x identical, or all y identical. Pairs tied in both variables are counted apart and excluded from the correction, which is what makes this tau-b rather than tau-a or tau-c. O(n²) time with no allocation, so it is fine for a few thousand points and hopeless for a long signal.
fun kendallTauB(x: DoubleArray, y: DoubleArray): Double {
requireSameSize(x, y)
val n = x.size
var concordant = 0L
var discordant = 0L
var tiesXOnly = 0L
var tiesYOnly = 0L
var tiesBoth = 0L
for (i in 0 until n - 1) {
for (j in i + 1 until n) {
val dx = x[i].compareTo(x[j]) // -1,0,1
val dy = y[i].compareTo(y[j])
when {
dx == 0 && dy == 0 -> tiesBoth++
dx == 0 && dy != 0 -> tiesXOnly++
dx != 0 && dy == 0 -> tiesYOnly++
else -> {
// both non-zero
if (dx == dy) concordant++ else discordant++
}
}
}
}
val num = (concordant - discordant).toDouble()
val denomX = (concordant + discordant + tiesXOnly).toDouble()
val denomY = (concordant + discordant + tiesYOnly).toDouble()
val denom = sqrt(denomX * denomY)
if (denom == 0.0) return Double.NaN
return num / denom
}
Chatterjee's xi
Tie-aware finite-sample form: xi_n = 1 - n * sum_{i=1}^{n-1} |R_{i+1} - R_i| / (2 * sum_i L_i (n - L_i)) where: - observations are ordered by x - R_i = #{j : y_j <= y_i} - L_i = #{j : y_j >= y_i}
For continuous y (no ties), this reduces to: 1 - 3 * sum |rank(y_{i+1}) - rank(y_i)| / (n^2 - 1)
Chatterjee (2021) defines the estimator for tied x by breaking those ties uniformly at random,
which makes xi_n a random variable. Rather than pick a seed we evaluate its expectation in closed
form. The denominator does not depend on the tie-breaking at all, so
Exi_n = 1 - n * E[sum |R_{i+1} - R_i|] / (2 * sum_i L_i (n - L_i))
exactly, and only the numerator needs an expectation. Group the x-order into runs of equal x:
inside a run of size m a uniformly random permutation makes each of the m-1 adjacent pairs a
uniformly chosen unordered pair of distinct members, and across the boundary between runs g and h
the adjacent pair is one uniform member of each. So
E[intra(g)] = (2/m) * sum_{a<b in g} |R_a - R_b|
E[cross(g,h)] = (1/(|g|*|h|)) * sum_{a in g, b in h} |R_a - R_b|
Both are computable in O(m log m) from the run's sorted R values, so the whole function stays
O(n log n). For distinct x every run has m = 1: intra vanishes and cross degenerates to the plain
|R_a - R_b|, the same arithmetic in the same order as the untied form, so untied inputs are
bit-identical to the simple sum.
Breaking x-ties by input index instead — which this did until 2026-07-30 — silently scores how nearly y is a function of arrival order: a constant predictor read 0.9702970297029703 at n=100 and a two-valued one read 0.9702330025889176 at n=300, for predictors carrying no information.
fun chatterjeeXi(x: DoubleArray, y: DoubleArray): Double
Chatterjee's xi, which is asymmetric: it measures how nearly y is a function of
x, so swapping the arguments asks a different question and returns a different number. Near 0 for
independence, near 1 when y is a noiseless function of x — including a non-monotone one such as a
parabola, where pearson reads 0 and kendallTauB not much more.
Values are asymptotically in 0..1, but the finite-sample form used here can come out slightly negative for small n, so do not clamp it and call it a probability. When every y is identical the denominator vanishes and it returns 0.0. O(n log n) — the cheap one to reach for on a long series.
Ties. Chatterjee's estimator is only defined up to a random breaking of the ties among the
x values, so on tied x this returns the exact expectation of xi over all such breakings —
deterministic, reproducible, and invariant to the order you present the pairs in. Two consequences
worth knowing: a constant predictor reads 0 — the closed form is exactly zero for every n, and in
Double it lands within one ulp of that (over n = 2..400, 54 values of n come back non-zero, the
largest being 2.2e-16) — and quantised or categorical x is answered correctly rather than being
scored on arrival order. Inputs with all-distinct x are unaffected, bit for bit.
Recorded xi values over tied x from before 2026-07-30 are wrong; see
dev_docs/stdlib_behaviour_changes.md.
body — 66 lines, from line 345
fun chatterjeeXi(x: DoubleArray, y: DoubleArray): Double {
requireSameSize(x, y)
val n = x.size
val orderByX = (0 until n).sortedWith(
compareBy<Int>({ x[it] }, { it })
)
val orderByY = (0 until n).sortedWith(
compareBy<Int>({ y[it] }, { it })
)
// R_i = #<=, L_i = #>= (tie-aware)
val r = IntArray(n)
val l = IntArray(n)
var pos = 0
while (pos < n) {
var end = pos
val v = y[orderByY[pos]]
while (end + 1 < n && y[orderByY[end + 1]] == v) end++
val lessOrEqual = end + 1
val greaterOrEqual = n - pos
for (k in pos..end) {
val idx = orderByY[k]
r[idx] = lessOrEqual
l[idx] = greaterOrEqual
}
pos = end + 1
}
var denomTerm = 0L
for (i in 0 until n) {
val li = l[i].toLong()
denomTerm += li * (n - li)
}
val denominator = 2.0 * denomTerm.toDouble()
if (denominator == 0.0) return 0.0 // e.g. all y identical
// E[sum |R_{i+1} - R_i|] over uniformly random breakings of the x-ties. See the note above for
// why the expectation of the ratio is the ratio with the expectation, and why m = 1 everywhere
// (all-distinct x) reproduces the plain sum exactly.
var expectedSumAbsDiff = 0.0
var previousRun: DoubleArray? = null
var runStart = 0
while (runStart < n) {
var runEnd = runStart
val xv = x[orderByX[runStart]]
while (runEnd + 1 < n && x[orderByX[runEnd + 1]] == xv) runEnd++
val m = runEnd - runStart + 1
val run = DoubleArray(m) { r[orderByX[runStart + it]].toDouble() }
run.sort()
if (m >= 2) expectedSumAbsDiff += 2.0 / m * pairwiseAbsSum(run)
previousRun?.let { p ->
expectedSumAbsDiff += crossAbsSum(p, run) / (p.size.toDouble() * m.toDouble())
}
previousRun = run
runStart = runEnd + 1
}
val numerator = n.toDouble() * expectedSumAbsDiff
return 1.0 - (numerator / denominator)
}
sum_{a<b} |v_a - v_b| over an ascending-sorted array, in one pass: each element is the larger
of the k pairs below it and the smaller of the m-1-k above, so it contributes with weight
2k - m + 1. O(m) given the sort.
private fun pairwiseAbsSum(sortedValues: DoubleArray): Double {
val m = sortedValues.size
var total = 0.0
for (k in 0 until m) total += (2 * k - m + 1) * sortedValues[k]
return total
}
sum_{a in A, b in B} |A_a - B_b| over two ascending-sorted arrays, by walking a pointer into
b and keeping its running prefix sum, so each a splits b into the part below it and the part
above. O(|A| + |B|) rather than the naive product.
private fun crossAbsSum(a: DoubleArray, b: DoubleArray): Double {
var sumB = 0.0
for (v in b) sumB += v
var total = 0.0
var j = 0
var prefix = 0.0 // sum of b[0 until j], i.e. the entries known to be <= the current a
for (va in a) {
while (j < b.size && b[j] <= va) {
prefix += b[j]
j++
}
// below: j entries, each contributing va - b; above: b.size - j entries, contributing b - va
total += va * j - prefix + (sumB - prefix) - va * (b.size - j)
}
return total
}
fun xi(x: DoubleArray, y: DoubleArray): Double = chatterjeeXi(x, y)
Short alias for chatterjeeXi, for when the formula rather than the surname is what you have in mind. Same asymmetry: the first argument is the predictor.
Distance correlation (Székely–Rizzo)
Steps: 1) Compute pairwise distances a_ij = |x_i - x_j|, b_ij = |y_i - y_j| 2) Double-center each distance matrix: A_ij = a_ij - a_i. - a_.j + a_.. 3) dCov^2 = mean_ij (A_ij * B_ij) dVarX^2 = mean_ij (A_ij * A_ij), dVarY^2 similar 4) dCor = dCov / sqrt(dVarX * dVarY)
Complexity O(n^2) memory O(n^2). For large n, you'd want streaming or blocks.
fun distanceCorrelation(x: DoubleArray, y: DoubleArray): Double
Székely–Rizzo distance correlation, in 0..1 — and unlike pearson it is 0 only for genuine independence, which is what makes it the one to reach for when you do not want to guess the shape of the relationship first. It is symmetric in its arguments and never negative, so it tells you how much dependence there is and nothing at all about direction.
Returns 0.0 rather than NaN when either array is constant, and the result is clamped into 0..1 to absorb floating-point overshoot. Scalar inputs only here, though the coefficient is defined for vectors. O(n²) in time and, more painfully, in memory: it materialises two full n×n distance matrices, which puts the practical ceiling at a few thousand points. RollingDistanceCorrelation is the streaming alternative.
body — 81 lines, from line 478
fun distanceCorrelation(x: DoubleArray, y: DoubleArray): Double {
requireSameSize(x, y)
val n = x.size
// Build distance matrices
val a = Array(n) { DoubleArray(n) }
val b = Array(n) { DoubleArray(n) }
for (i in 0 until n) {
a[i][i] = 0.0
b[i][i] = 0.0
for (j in i + 1 until n) {
val da = abs(x[i] - x[j])
val db = abs(y[i] - y[j])
a[i][j] = da
a[j][i] = da
b[i][j] = db
b[j][i] = db
}
}
fun doubleCenter(m: Array<DoubleArray>): Array<DoubleArray> {
val rowMean = DoubleArray(n)
val colMean = DoubleArray(n)
var grand = 0.0
// row means + grand
for (i in 0 until n) {
var s = 0.0
for (j in 0 until n) s += m[i][j]
rowMean[i] = s / n
grand += rowMean[i]
}
grand /= n
// col means
for (j in 0 until n) {
var s = 0.0
for (i in 0 until n) s += m[i][j]
colMean[j] = s / n
}
val centered = Array(n) { DoubleArray(n) }
for (i in 0 until n) {
for (j in 0 until n) {
centered[i][j] = m[i][j] - rowMean[i] - colMean[j] + grand
}
}
return centered
}
val A = doubleCenter(a)
val B = doubleCenter(b)
var dcov2 = 0.0
var dvarx2 = 0.0
var dvary2 = 0.0
val denom = (n.toDouble() * n.toDouble())
for (i in 0 until n) {
for (j in 0 until n) {
val aij = A[i][j]
val bij = B[i][j]
dcov2 += aij * bij
dvarx2 += aij * aij
dvary2 += bij * bij
}
}
dcov2 /= denom
dvarx2 /= denom
dvary2 /= denom
if (dvarx2 <= 0.0 || dvary2 <= 0.0) return 0.0
val dcov = sqrt(max(0.0, dcov2))
val dvarx = sqrt(max(0.0, dvarx2))
val dvary = sqrt(max(0.0, dvary2))
val dcor = dcov / sqrt(dvarx * dvary)
// numeric guard
return min(1.0, max(0.0, dcor))
}
Naive MIC/TIC-style scan using equipartition bins
B(n) default: floor(n^0.6) (common in MIC discussions). You can override.
Binning approach: - Sort indices by x. Assign xBin = floor(rank * xBins / n), rank in [0..n-1] - Same for y. This is a crude equipartition by order, not by value thresholds.
Mutual information: MI = sum_{i,j} p_ij * ln(p_ij / (p_i * p_j)) Normalized: MI / ln(min(xBins, yBins)) (0 if minBins < 2)
MIC-like score = max normalized MI across grids with xBins*yBins <= B TIC-like score = sum normalized MI across those grids
data class MineLikeResult
What micTicNaive reports: the best (mic) and the summed (tic) normalized mutual-information
score across every grid it scanned, plus the bestXBins × bestYBins grid that produced the
maximum. Read the caveat on micTicNaive before trusting any of the four.
data class MineLikeResult(
val mic: Double,
val tic: Double,
val bestXBins: Int,
val bestYBins: Int
)
fun micTicNaive
A MIC/TIC-style scan: bin x and y onto every grid whose cell count fits a budget, score normalized mutual information on each, and report the maximum and the sum. Unlike the correlation coefficients above it is blind to the shape of the relationship: a parabola or a sine scores about as high as a straight line.
It is not MICe/TICe. This is an equipartition-by-order grid approximation, good for intuition,
and it will not reproduce published numbers. Small samples read high because the mutual-information estimate is biased upward on a
sparse grid, so compare scores at one n rather than across sizes. bMax caps the product of the
two bin counts, defaulting to floor(n^0.6) and never less than 4.
body — 90 lines, from line 599
fun micTicNaive(
x: DoubleArray,
y: DoubleArray,
bMax: Int? = null
): MineLikeResult {
requireSameSize(x, y)
val n = x.size
val B = bMax ?: floor(Math.pow(n.toDouble(), 0.6)).toInt().coerceAtLeast(4)
// Choose max bins to scan; a practical cap.
val maxBins = min(n, B)
// ranks by order (0..n-1) without averaging ties, for binning convenience
fun orderRanks(values: DoubleArray): IntArray {
// as in ranksAverageTies: use the sorted result, don't discard it
val order = (0 until n).sortedWith { i, j ->
val a = values[i]
val b = values[j]
when {
a < b -> -1
a > b -> 1
else -> i - j
}
}
val rank = IntArray(n)
for (r in 0 until n) rank[order[r]] = r
return rank
}
val rx = orderRanks(x)
val ry = orderRanks(y)
fun normalizedMI(xBins: Int, yBins: Int): Double {
if (xBins < 2 || yBins < 2) return 0.0
val counts = Array(xBins) { IntArray(yBins) }
val row = IntArray(xBins)
val col = IntArray(yBins)
for (i in 0 until n) {
val xb = ((rx[i] * xBins) / n).coerceIn(0, xBins - 1)
val yb = ((ry[i] * yBins) / n).coerceIn(0, yBins - 1)
counts[xb][yb]++
row[xb]++
col[yb]++
}
var mi = 0.0
val invN = 1.0 / n.toDouble()
for (i in 0 until xBins) {
val pi = row[i] * invN
if (pi == 0.0) continue
for (j in 0 until yBins) {
val cij = counts[i][j]
if (cij == 0) continue
val pij = cij * invN
val pj = col[j] * invN
// pij * ln(pij / (pi * pj))
mi += pij * ln(pij / (pi * pj))
}
}
val norm = ln(min(xBins, yBins).toDouble())
if (norm <= 0.0) return 0.0
val nmi = mi / norm
// Clip to [0,1] loosely (estimation can slightly exceed)
return min(1.0, max(0.0, nmi))
}
var best = -1.0
var bestX = 2
var bestY = 2
var tic = 0.0
for (xBins in 2..maxBins) {
for (yBins in 2..maxBins) {
if (xBins * yBins > B) continue
val score = normalizedMI(xBins, yBins)
tic += score
if (score > best) {
best = score
bestX = xBins
bestY = yBins
}
}
}
val mic = if (best < 0.0) 0.0 else best
return MineLikeResult(mic = mic, tic = tic, bestXBins = bestX, bestYBins = bestY)
}
}
fun main()
A scratch demo rather than part of the API: prints every coefficient in this file for y = x² over an
x grid symmetric about zero — the textbook case where pearson reads about 0 while the dependence is
in fact total, so StatsDependence.chatterjeeXi and StatsDependence.distanceCorrelation read high.
The two monotone rank coefficients, StatsDependence.spearman and StatsDependence.kendallTauB, side
with Pearson here and print about 0: the parabola is a function but not a monotone one, and monotone is
all either of them can see. chatterjeeXi ranks too but is not looking for monotonicity, so it reads
high with dCor — as does the MIC-like score, which bins rather than ranks.
fun main() {
val n = 400
val x = DoubleArray(n) { i ->
// symmetric about 0
(i - n / 2).toDouble() / (n / 2).toDouble()
}
val yParabola = DoubleArray(n) { i -> x[i] * x[i] }
println("Parabola y=x^2 (symmetric x)")
println("Pearson: " + StatsDependence.pearson(x, yParabola))
println("Spearman: " + StatsDependence.spearman(x, yParabola))
println("Kendall: " + StatsDependence.kendallTauB(x, yParabola))
println("Xi: " + StatsDependence.chatterjeeXi(x, yParabola))
println("dCor: " + StatsDependence.distanceCorrelation(x, yParabola))
val mine = StatsDependence.micTicNaive(x, yParabola)
println("MIC~: " + mine.mic + " (best grid ${mine.bestXBins}x${mine.bestYBins})")
println("TIC~: " + mine.tic)
}
class RollingDistanceCorrelation(private val windowSize: Int)
Distance correlation over a sliding window, maintained incrementally: push a sample pair, get the
current 0..1 coefficient back, at O(w) work per sample for a window of w rather than the O(w²) rebuild
StatsDependence.distanceCorrelation would charge you.
There is no warm-up gate. The first push returns null, and from the second onwards you get a real
coefficient over however few samples are in the window so far — genuine values, but noisy until the
window fills, so count pushes yourself if you want to ignore them. windowSize must be at least 2 or
construction throws. Once full, each push evicts the oldest pair. There is no reset: build a new
instance, since unlike RollingXiCorrelation this has no clear.
The maintained row sums, grand sums and cross sums are only ever added to and subtracted from, never recomputed, so a very long run accumulates floating-point drift that a batch call would not have.
init
init {
require(windowSize >= 2) { "windowSize must be >= 2" }
}
private val xBuf = DoubleArray(windowSize)
private val yBuf = DoubleArray(windowSize)
Row sums sA_i = sum_j |x_i - x_j| over current active points (ordered sum including j=i term = 0)
private val sA = DoubleArray(windowSize)
private val sB = DoubleArray(windowSize)
Active count and circular indices
private var n = 0
private var head = 0 // points to oldest element
private var tail = 0 // points to next write position
Grand sums over ordered pairs (i,j): SA = sum_{i,j} a_ij, SB = sum_{i,j} b_ij
private var SA = 0.0
private var SB = 0.0
Cross sums over ordered pairs: CAB = sum_{i,j} a_ij * b_ij CAA = sum_{i,j} a_ij^2 CBB = sum_{i,j} b_ij^2
private var CAB = 0.0
private var CAA = 0.0
private var CBB = 0.0
private fun idxAt(offset: Int): Int = (head + offset) % windowSize
private inline fun forEachActive(exceptIndex: Int = -1, block: (slot: Int) -> Unit) {
for (k in 0 until n) {
val slot = idxAt(k)
if (slot != exceptIndex) block(slot)
}
}
fun push(x: Double, y: Double): Double?
Push one (x,y) sample. If the window is full, evicts the oldest.
@return current distance correlation for the window, or null if fewer than 2 points.
fun push(x: Double, y: Double): Double? {
if (n == windowSize) {
evictOldest()
}
insertNewest(x, y)
return current()
}
fun current(): Double?
Current distance correlation (no mutation).
Null until the window holds two samples; otherwise a value in 0..1, computed from the maintained sums rather than from a rebuilt distance matrix. Returns 0.0, as the batch version does, when either variable is constant across the window, and the result is clamped into 0..1 to absorb the numerical error the incremental sums accumulate.
fun current(): Double? {
if (n < 2) return null
val nn = n.toDouble()
val n2 = nn * nn
val n3 = n2 * nn
val n4 = n2 * n2
var sumSA_SB = 0.0
var sumSA2 = 0.0
var sumSB2 = 0.0
forEachActive { slot ->
val a = sA[slot]
val b = sB[slot]
sumSA_SB += a * b
sumSA2 += a * a
sumSB2 += b * b
}
val dcov2 = (CAB / n2) - (2.0 * sumSA_SB / n3) + (SA * SB / n4)
val dvarX2 = (CAA / n2) - (2.0 * sumSA2 / n3) + (SA * SA / n4)
val dvarY2 = (CBB / n2) - (2.0 * sumSB2 / n3) + (SB * SB / n4)
val dcov = sqrt(max(0.0, dcov2))
val dvarX = sqrt(max(0.0, dvarX2))
val dvarY = sqrt(max(0.0, dvarY2))
if (dvarX == 0.0 || dvarY == 0.0) return 0.0
val denom = sqrt(dvarX * dvarY)
val dcor = dcov / denom
// numerical guard
return min(1.0, max(0.0, dcor))
}
Evict oldest element at head. Updates all maintained sums in O(n).
source — 49 lines, from line 832
private fun evictOldest() {
val evict = head
val ex = xBuf[evict]
val ey = yBuf[evict]
// For each other active point k, subtract its pair distances with evict from:
// - row sums of k
// - grand sums and cross sums (ordered pairs contribute twice: (evict,k) and (k,evict))
var sumDx = 0.0
var sumDy = 0.0
var sumDxDy = 0.0
var sumDx2 = 0.0
var sumDy2 = 0.0
forEachActive(exceptIndex = evict) { k ->
val dx = abs(ex - xBuf[k])
val dy = abs(ey - yBuf[k])
// update other rows
sA[k] -= dx
sB[k] -= dy
sumDx += dx
sumDy += dy
sumDxDy += dx * dy
sumDx2 += dx * dx
sumDy2 += dy * dy
}
// Remove evict's own row sums from totals by subtracting its contributions:
// It had sA[evict] == sumDx, sB[evict] == sumDy (for the window before eviction).
// We'll just clear them.
sA[evict] = 0.0
sB[evict] = 0.0
// Update global sums: ordered pairs => factor 2 for pair contributions
SA -= 2.0 * sumDx
SB -= 2.0 * sumDy
CAB -= 2.0 * sumDxDy
CAA -= 2.0 * sumDx2
CBB -= 2.0 * sumDy2
// Advance head, reduce n
head = (head + 1) % windowSize
n -= 1
// tail always points to next write position
tail = (head + n) % windowSize
}
Insert new element at tail. Updates all maintained sums in O(n).
source — 43 lines, from line 885
private fun insertNewest(x: Double, y: Double) {
val slot = tail
xBuf[slot] = x
yBuf[slot] = y
var sumDx = 0.0
var sumDy = 0.0
var sumDxDy = 0.0
var sumDx2 = 0.0
var sumDy2 = 0.0
// Distances to all existing active points
forEachActive { k ->
val dx = abs(x - xBuf[k])
val dy = abs(y - yBuf[k])
// Update existing row sums
sA[k] += dx
sB[k] += dy
sumDx += dx
sumDy += dy
sumDxDy += dx * dy
sumDx2 += dx * dx
sumDy2 += dy * dy
}
// New row sums
sA[slot] = sumDx
sB[slot] = sumDy
// Update global sums: ordered pair contributions double
SA += 2.0 * sumDx
SB += 2.0 * sumDy
CAB += 2.0 * sumDxDy
CAA += 2.0 * sumDx2
CBB += 2.0 * sumDy2
// Advance tail, increase n
n += 1
tail = (tail + 1) % windowSize
}
}
class RollingXiCorrelation(private val windowSize: Int)
Chatterjee's xi over a sliding window, recomputed from scratch on every push — no incremental trickery, just StatsDependence.chatterjeeXi over the window's current contents, which costs O(w log w) per sample and is exactly as trustworthy as the batch answer.
It carries the batch function's asymmetry: the first argument of push is the predictor, so pushing
(x, y) answers "how nearly is y a function of x". windowSize must be at least 2 or construction
throws; the first push returns null, and thereafter you get xi over however many samples have arrived,
the window filling before it starts evicting the oldest. clear empties it.
init
init {
require(windowSize >= 2) { "windowSize must be >= 2" }
}
private val xBuf = DoubleArray(windowSize)
private val yBuf = DoubleArray(windowSize)
private var n = 0
private var head = 0
private var tail = 0
fun push(x: Double, y: Double): Double?
Push one (x, y) sample — evicting the oldest if the window is already full — and return xi over the window as it now stands, or null while fewer than two samples have arrived.
This recomputes from scratch rather than updating anything, so the cost is O(w log w) in the window size on every call, not amortised away.
fun push(x: Double, y: Double): Double? {
if (n == windowSize) {
head = (head + 1) % windowSize
n -= 1
}
xBuf[tail] = x
yBuf[tail] = y
tail = (tail + 1) % windowSize
n += 1
return current()
}
fun current(): Double?
Xi over the window's current contents without pushing anything, or null while fewer than two samples have arrived. Copies the window out in arrival order first, though that order does not reach the answer: StatsDependence.chatterjeeXi sorts by x itself and resolves x-ties by expectation rather than by position, so the result depends only on which pairs are in the window and not on where in the ring buffer they happen to sit.
fun current(): Double? {
if (n < 2) return null
val x = DoubleArray(n)
val y = DoubleArray(n)
for (i in 0 until n) {
val slot = (head + i) % windowSize
x[i] = xBuf[slot]
y[i] = yBuf[slot]
}
return StatsDependence.chatterjeeXi(x, y)
}
fun clear()
Forget every buffered sample. The next push starts a fresh window and returns null again, exactly as it did on a new instance.
fun clear() {
n = 0
head = 0
tail = 0
}
}