每日算法 — 使用java实现t-SNE降维:KL散度优化与高维数据可视化

引言

t-SNE(t-Distributed Stochastic Neighbor Embedding)是2008年由Laurens van der Maaten和Geoffrey Hinton提出的非线性降维算法,特别适用于高维数据的可视化。与PCA等线性方法不同,t-SNE能够在低维空间中更好地保留数据的局部结构,使相似样本聚集、不相似样本分离。本文将用Java完整实现t-SNE算法,帮助读者理解其概率建模与梯度优化过程。

核心原理

高维空间中的相似度

对于高维数据点 x_i 和 x_j,t-SNE首先将其相似度建模为条件概率:

p_{j|i} = exp(-||x_i – x_j||² / 2σ_i²) / Σ_{k≠i} exp(-||x_i – x_k||² / 2σ_i²)

然后对称化为联合概率:

p_{ij} = (p_{j|i} + p_{i|j}) / 2n

其中 σ_i 通过二分查找确定,使得以 x_i 为中心的邻域具有固定的困惑度(Perplexity)。

低维空间中的相似度

在低维空间中,t-SNE使用自由度为1的t分布(柯西分布)来建模相似度:

q_{ij} = (1 + ||y_i – y_j||²)^(-1) / Σ_{k≠l} (1 + ||y_k – y_l||²)^(-1)

使用t分布而非高斯分布的原因是其长尾特性,能够有效缓解”拥挤问题”(Crowding Problem),给低维空间中相距较远的点更多空间。

目标函数:KL散度

t-SNE通过最小化高维分布 P 与低维分布 Q 之间的KL散度来优化低维表示:

C = KL(P || Q) = Σ_i Σ_j p_{ij} log(p_{ij} / q_{ij})

低维点 y_i 的梯度为:

∂C/∂y_i = 4 Σ_j (p_{ij} – q_{ij}) (y_i – y_j) / (1 + ||y_i – y_j||²)

Java实现

项目结构

src/
├── TSNE.java           // 核心t-SNE算法
├── MatrixUtils.java    // 矩阵/向量工具类
└── TSNEDemo.java       // 演示主类

核心算法实现

import java.util.Random;

/**
 * t-SNE降维算法实现
 */
public class TSNE {
    private final int outputDims;      // 输出维度(通常为2)
    private final int maxIterations;   // 最大迭代次数
    private final double perplexity;   // 困惑度
    private final double learningRate; // 学习率
    private final double momentum;     // 动量系数
    private final Random rand;

    public TSNE(int outputDims, int maxIterations, double perplexity) {
        this.outputDims = outputDims;
        this.maxIterations = maxIterations;
        this.perplexity = perplexity;
        this.learningRate = 200.0;
        this.momentum = 0.8;
        this.rand = new Random(42);
    }

    /**
     * 计算高维空间中的成对距离
     */
    private double[][] computePairwiseDistances(double[][] X) {
        int n = X.length;
        double[][] dist = new double[n][n];
        for (int i = 0; i < n; i++) {
            for (int j = i + 1; j < n; j++) {
                double sum = 0;
                for (int k = 0; k < X[0].length; k++) {
                    double diff = X[i][k] - X[j][k];
                    sum += diff * diff;
                }
                dist[i][j] = sum;
                dist[j][i] = sum;
            }
        }
        return dist;
    }

    /**
     * 计算给定sigma下的条件概率分布和困惑度
     * @return [P, H] P是概率分布,H是困惑度
     */
    private Object[] computeGaussianPerplexity(double[] distI, double sigma) {
        int n = distI.length;
        double[] p = new double[n];
        double sum = 0;
        for (int j = 0; j < n; j++) {
            if (i != j) { // 实际使用时通过索引控制
                p[j] = Math.exp(-distI[j] / (2 * sigma * sigma));
                sum += p[j];
            }
        }
        double H = 0;
        for (int j = 0; j < n; j++) {
            if (i != j && sum > 0) {
                p[j] /= sum;
                if (p[j] > 1e-7) {
                    H -= p[j] * Math.log(p[j]);
                }
            }
        }
        return new Object[]{p, Math.exp(H)};
    }

    /**
     * 二分查找确定最优sigma使困惑度匹配目标值
     */
    private double binarySearchSigma(double[] distI, double targetPerplexity) {
        double sigmaMin = 1e-20, sigmaMax = 1e20;
        double sigma = 1.0;
        for (int iter = 0; iter < 50; iter++) {
            // 简化的困惑度计算
            double[] p = new double[distI.length];
            double sum = 0;
            for (int j = 0; j < distI.length; j++) {
                p[j] = Math.exp(-distI[j] / (2 * sigma * sigma));
                sum += p[j];
            }
            double H = 0;
            for (int j = 0; j < distI.length; j++) {
                if (sum > 0) p[j] /= sum;
                if (p[j] > 1e-7) H -= p[j] * Math.log(p[j]);
            }
            double perp = Math.exp(H);

            if (perp > targetPerplexity) {
                sigmaMax = sigma;
                sigma = (sigma + sigmaMin) / 2;
            } else {
                sigmaMin = sigma;
                sigma = (sigma + sigmaMax) / 2;
            }
            if (Math.abs(perp - targetPerplexity) < 1e-5) break;
        }
        return sigma;
    }

    /**
     * 计算高维联合概率P
     */
    private double[][] computeP(double[][] X) {
        int n = X.length;
        double[][] dist = computePairwiseDistances(X);
        double[][] P = new double[n][n];

        for (int i = 0; i < n; i++) {
            double sigma = binarySearchSigma(dist[i], perplexity);
            double sum = 0;
            for (int j = 0; j < n; j++) {
                if (i != j) {
                    P[i][j] = Math.exp(-dist[i][j] / (2 * sigma * sigma));
                    sum += P[i][j];
                }
            }
            for (int j = 0; j < n; j++) {
                if (i != j && sum > 0) P[i][j] /= sum;
            }
        }

        // 对称化: p_ij = (p_j|i + p_i|j) / 2n
        double[][] Psym = new double[n][n];
        for (int i = 0; i < n; i++) {
            for (int j = i + 1; j < n; j++) {
                Psym[i][j] = (P[i][j] + P[j][i]) / (2 * n);
                Psym[j][i] = Psym[i][j];
            }
        }
        return Psym;
    }

    /**
     * 执行t-SNE降维
     */
    public double[][] fitTransform(double[][] X) {
        int n = X.length;
        double[][] P = computeP(X);

        // 初始化低维表示Y,服从N(0, 1e-4)
        double[][] Y = new double[n][outputDims];
        double[][] Yprev = new double[n][outputDims];
        for (int i = 0; i < n; i++) {
            for (int d = 0; d < outputDims; d++) {
                Y[i][d] = rand.nextGaussian() * 0.0001;
            }
        }

        // 梯度下降优化
        for (int iter = 0; iter < maxIterations; iter++) {
            // 计算低维成对距离和Q分布
            double[][] Q = new double[n][n];
            double[][] num = new double[n][n];
            double sumQ = 0;

            for (int i = 0; i < n; i++) {
                for (int j = i + 1; j < n; j++) {
                    double dist = 0;
                    for (int d = 0; d < outputDims; d++) {
                        double diff = Y[i][d] - Y[j][d];
                        dist += diff * diff;
                    }
                    num[i][j] = 1.0 / (1.0 + dist);
                    num[j][i] = num[i][j];
                    sumQ += num[i][j];
                }
            }
            sumQ *= 2;

            for (int i = 0; i < n; i++) {
                for (int j = i + 1; j < n; j++) {
                    Q[i][j] = Math.max(num[i][j] / sumQ, 1e-12);
                    Q[j][i] = Q[i][j];
                }
            }

            // 计算梯度并更新Y
            double[][] grads = new double[n][outputDims];
            for (int i = 0; i < n; i++) {
                for (int j = 0; j < n; j++) {
                    if (i == j) continue;
                    double factor = 4 * (P[i][j] - Q[i][j]) * num[i][j];
                    for (int d = 0; d < outputDims; d++) {
                        grads[i][d] += factor * (Y[i][d] - Y[j][d]);
                    }
                }
            }

            // 更新Y = Y + lr * grads + momentum * (Y - Yprev)
            for (int i = 0; i < n; i++) {
                for (int d = 0; d < outputDims; d++) {
                    double update = learningRate * grads[i][d] + momentum * (Y[i][d] - Yprev[i][d]);
                    Yprev[i][d] = Y[i][d];
                    Y[i][d] += update;
                }
            }

            // 早期压缩: 前100轮增大P的值
            if (iter < 100) {
                for (int i = 0; i < n; i++) {
                    for (int j = 0; j < n; j++) {
                        P[i][j] *= 4;
                    }
                }
            }

            if ((iter + 1) % 100 == 0) {
                double loss = computeLoss(P, Q);
                System.out.println("迭代 " + (iter + 1) + ", KL散度: " + String.format("%.6f", loss));
            }
        }

        return Y;
    }

    private double computeLoss(double[][] P, double[][] Q) {
        double loss = 0;
        int n = P.length;
        for (int i = 0; i < n; i++) {
            for (int j = i + 1; j < n; j++) {
                if (P[i][j] > 1e-7) {
                    loss += P[i][j] * Math.log(P[i][j] / Q[i][j]);
                }
            }
        }
        return loss;
    }
}

演示主类

/**
 * t-SNE降维演示
 */
public class TSNEDemo {
    public static void main(String[] args) {
        // 构造模拟数据: 3个簇的高维点
        int nPerCluster = 30;
        int dims = 50;
        double[][] X = new double[3 * nPerCluster][dims];

        Random rand = new Random(123);
        double[][] centers = {{1,1,1,1,1}, {5,5,5,5,5}, {9,9,9,9,9}};

        for (int c = 0; c < 3; c++) {
            for (int i = 0; i < nPerCluster; i++) {
                for (int d = 0; d < 5; d++) {
                    X[c * nPerCluster + i][d] = centers[c][d] + rand.nextGaussian() * 0.3;
                }
                for (int d = 5; d < dims; d++) {
                    X[c * nPerCluster + i][d] = rand.nextGaussian() * 0.1;
                }
            }
        }

        System.out.println("输入数据: " + X.length + "个样本, " + dims + "维");

        // 执行t-SNE降维到2维
        TSNE tsne = new TSNE(2, 1000, 30.0);
        double[][] Y = tsne.fitTransform(X);

        System.out.println("\n降维结果 (2D坐标):");
        for (int c = 0; c < 3; c++) {
            System.out.println("簇 " + (c + 1) + " 样本坐标:");
            for (int i = 0; i < Math.min(5, nPerCluster); i++) {
                int idx = c * nPerCluster + i;
                System.out.printf("  [%.4f, %.4f]%n", Y[idx][0], Y[idx][1]);
            }
        }
    }
}

复杂度分析

阶段 时间复杂度 空间复杂度
高维距离计算 O(n² · D) O(n²)
二分查找sigma O(n² · log(V) · iter) O(n²)
低维梯度计算 O(n² · d · iter) O(n²)
总计 O(n² · (D + d · iter)) O(n²)

其中 n 为样本数,D 为原始维度,d 为目标维度(通常为2),iter 为迭代次数。t-SNE的 O(n²) 复杂度限制了其在大规模数据上的直接应用,此时可采用Barnes-Hut近似将复杂度降至 O(n log n)。

总结

本文用Java完整实现了t-SNE降维算法,涵盖高斯相似度建模、困惑度驱动的sigma搜索、t分布低维映射与KL散度梯度下降优化。t-SNE是理解高维数据结构的重要工具,读者可将其应用于图像特征可视化、文本聚类分析等场景,或进一步探索UMAP等更高效的降维方法。