浏览代码

feat(indicator): SpearmanUtil 秩相关+Fisher z 聚合(纯函数,含并列秩)

iwt 2 天之前
父节点
当前提交
28fee8d765

+ 87 - 0
cfc-backend/src/main/java/com/etotem/cfc/util/SpearmanUtil.java

@@ -0,0 +1,87 @@
+package com.etotem.cfc.util;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 秩相关计算(纯函数,无外部依赖)。
+ * 菌群丰度偏态分布,Spearman 比 Pearson 稳健;Java 8 无内置实现,此处手工实现,约 40 行。
+ */
+public final class SpearmanUtil {
+
+    private SpearmanUtil() {
+    }
+
+    /** 计算 Spearman 秩相关系数;配对点 < 3 返回 0(样本不足不误导)。 */
+    public static double spearmanRho(List<BigDecimal> x, List<BigDecimal> y) {
+        if (x == null || y == null || x.size() != y.size() || x.size() < 3) {
+            return 0.0;
+        }
+        double[] rankX = rank(x);
+        double[] rankY = rank(y);
+        double sumD2 = 0;
+        int n = x.size();
+        for (int i = 0; i < n; i++) {
+            double d = rankX[i] - rankY[i];
+            sumD2 += d * d;
+        }
+        double denom = (double) n * (n * n - 1);
+        if (denom == 0) {
+            return 0.0;
+        }
+        double rho = 1 - (6 * sumD2) / denom;
+        if (rho > 1) rho = 1;
+        if (rho < -1) rho = -1;
+        return rho;
+    }
+
+    /** 平均秩;并列值取平均(tie handling)。 */
+    private static double[] rank(List<BigDecimal> values) {
+        int n = values.size();
+        double[] ranks = new double[n];
+        // 按值排序的索引
+        List<Integer> idx = new ArrayList<>();
+        for (int i = 0; i < n; i++) idx.add(i);
+        idx.sort(Comparator.comparing(values::get));
+        Map<Integer, Double> rankMap = new HashMap<>();
+        int i = 0;
+        while (i < n) {
+            int j = i;
+            while (j + 1 < n && values.get(idx.get(j + 1)).compareTo(values.get(idx.get(i))) == 0) {
+                j++;
+            }
+            // [i, j] 为并列区间,平均秩 = (i+1 + j+1) / 2
+            double avgRank = ((i + 1) + (j + 1)) / 2.0;
+            for (int k = i; k <= j; k++) {
+                rankMap.put(idx.get(k), avgRank);
+            }
+            i = j + 1;
+        }
+        for (int k = 0; k < n; k++) {
+            ranks[k] = rankMap.get(k);
+        }
+        return ranks;
+    }
+
+    /**
+     * Fisher z 聚合:各对象 rho → z = artanh(rho) → 均值 → rho = tanh(z̄)。
+     * 设计文档 4.6:每对象算 rho → z 变换 → 全对象 z 均值 → 还原为平均 rho。
+     */
+    public static double fisherZAverage(double[] rhos) {
+        if (rhos == null || rhos.length == 0) {
+            return 0.0;
+        }
+        double sumZ = 0;
+        for (double r : rhos) {
+            double v = Math.max(-0.999999, Math.min(0.999999, r));
+            sumZ += 0.5 * Math.log((1 + v) / (1 - v));
+        }
+        double zAvg = sumZ / rhos.length;
+        return Math.tanh(zAvg);
+    }
+}

+ 60 - 0
cfc-backend/src/test/java/com/etotem/cfc/util/SpearmanUtilTest.java

@@ -0,0 +1,60 @@
+package com.etotem.cfc.util;
+
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class SpearmanUtilTest {
+
+    @Test
+    void perfectPositiveRankCorrelation() {
+        // x 与 y 完全同序 → rho = 1
+        List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
+        List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
+        double rho = SpearmanUtil.spearmanRho(x, y);
+        assertEquals(1.0, rho, 1e-9);
+    }
+
+    @Test
+    void perfectNegativeRankCorrelation() {
+        List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
+        List<BigDecimal> y = Arrays.asList(new BigDecimal("4"), new BigDecimal("3"), new BigDecimal("2"), new BigDecimal("1"));
+        double rho = SpearmanUtil.spearmanRho(x, y);
+        assertEquals(-1.0, rho, 1e-9);
+    }
+
+    @Test
+    void tiedRanksHandled() {
+        // 有并列值时使用平均秩;x 与 y 逐项同值(含并列) → rho = 1
+        List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("2"));
+        List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("2"));
+        double rho = SpearmanUtil.spearmanRho(x, y);
+        assertEquals(1.0, rho, 1e-9);
+    }
+
+    @Test
+    void shortSeriesReturnsZero() {
+        // 少于 3 个配对点不计算(对应设计:单对象 ≥3 配对才参与)
+        List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"));
+        List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"));
+        assertEquals(0.0, SpearmanUtil.spearmanRho(x, y), 1e-9);
+    }
+
+    @Test
+    void fisherZAverageRestoresPositiveRho() {
+        // 两个 rho=0.6 的对象 z 均值还原 → 仍约 0.6
+        double avg = SpearmanUtil.fisherZAverage(new double[]{0.6, 0.6});
+        assertEquals(0.6, avg, 1e-6);
+    }
+
+    @Test
+    void fisherZAverageHandlesExtremeRho() {
+        // rho 接近 ±1 时 z 变换仍有限(这里验证不抛异常且符号正确)
+        double avg = SpearmanUtil.fisherZAverage(new double[]{0.99, 0.95});
+        assertTrue(avg > 0.9);
+    }
+}