每日算法 — 使用java实现推荐系统:协同过滤与矩阵分解

推荐系统是互联网产品的核心技术之一,从电商购物到视频流媒体,无处不在。本文用Java实现两种经典推荐算法——基于用户的协同过滤与矩阵分解(Funk SVD),讲解相似度计算、邻居选择与隐向量训练,帮助读者理解推荐系统背后的算法原理与工程实现。

一、问题建模

推荐系统的核心任务可以抽象为:给定一个用户-物品评分矩阵R(m个用户×n个物品,其中大部分元素为空),预测用户对未评分物品的偏好程度,并推荐得分最高的物品。

import java.util.*;

/**
 * 推荐系统核心算法实现
 * 包含用户协同过滤与矩阵分解(SVD)
 */
public class RecommenderSystem {

    // 用户数量
    private int numUsers;
    // 物品数量
    private int numItems;
    // 评分矩阵(稀疏表示:user -> {item -> rating})
    private Map<Integer, Map<Integer, Double>> ratings;
    // 用户平均评分
    private Map<Integer, Double> userAvgRatings;
    // 物品平均评分
    private Map<Integer, Double> itemAvgRatings;

    public RecommenderSystem(int numUsers, int numItems) {
        this.numUsers = numUsers;
        this.numItems = numItems;
        this.ratings = new HashMap<>();
        this.userAvgRatings = new HashMap<>();
        this.itemAvgRatings = new HashMap<>();
    }

    /**
     * 添加评分
     */
    public void addRating(int user, int item, double rating) {
        ratings.computeIfAbsent(user, k -> new HashMap<>()).put(item, rating);
    }

    /**
     * 计算所有用户和物品的平均评分
     */
    public void computeAverages() {
        // 用户平均
        for (Map.Entry<Integer, Map<Integer, Double>> entry : ratings.entrySet()) {
            double sum = 0;
            for (double r : entry.getValue().values()) {
                sum += r;
            }
            userAvgRatings.put(entry.getKey(), sum / entry.getValue().size());
        }

        // 物品平均
        Map<Integer, List<Double>> itemRatings = new HashMap<>();
        for (Map<Integer, Double> userRatings : ratings.values()) {
            for (Map.Entry<Integer, Double> e : userRatings.entrySet()) {
                itemRatings.computeIfAbsent(e.getKey(), k -> new ArrayList<>()).add(e.getValue());
            }
        }
        for (Map.Entry<Integer, List<Double>> entry : itemRatings.entrySet()) {
            double sum = 0;
            for (double r : entry.getValue()) {
                sum += r;
            }
            itemAvgRatings.put(entry.getKey(), sum / entry.getValue().size());
        }
    }

二、基于用户的协同过滤

协同过滤的核心假设是”相似的用户有相似的偏好”。算法步骤为:找到与目标用户最相似的K个邻居,根据邻居的评分加权预测目标用户对未知物品的评分。

    /**
     * 计算两个用户之间的皮尔逊相关系数
     */
    public double pearsonCorrelation(int user1, int user2) {
        Map<Integer, Double> r1 = ratings.getOrDefault(user1, Collections.emptyMap());
        Map<Integer, Double> r2 = ratings.getOrDefault(user2, Collections.emptyMap());

        // 找到共同评分的物品
        List<Double> common1 = new ArrayList<>();
        List<Double> common2 = new ArrayList<>();

        for (int item : r1.keySet()) {
            if (r2.containsKey(item)) {
                common1.add(r1.get(item));
                common2.add(r2.get(item));
            }
        }

        if (common1.size() < 2) {
            return 0; // 共同评分太少,无法计算相关性
        }

        double avg1 = common1.stream().mapToDouble(Double::doubleValue).average().orElse(0);
        double avg2 = common2.stream().mapToDouble(Double::doubleValue).average().orElse(0);

        double numerator = 0;
        double denom1 = 0;
        double denom2 = 0;

        for (int i = 0; i < common1.size(); i++) {
            double diff1 = common1.get(i) - avg1;
            double diff2 = common2.get(i) - avg2;
            numerator += diff1 * diff2;
            denom1 += diff1 * diff1;
            denom2 += diff2 * diff2;
        }

        if (denom1 == 0 || denom2 == 0) {
            return 0;
        }

        return numerator / Math.sqrt(denom1 * denom2);
    }

    /**
     * 基于用户的协同过滤预测评分
     * @param user 目标用户
     * @param item 目标物品
     * @param k 邻居数量
     */
    public double userBasedCF(int user, int item, int k) {
        if (!ratings.containsKey(user)) {
            return itemAvgRatings.getOrDefault(item, 3.0);
        }

        // 计算该用户与其他所有用户的相似度
        List<Neighbor> neighbors = new ArrayList<>();
        for (int otherUser : ratings.keySet()) {
            if (otherUser == user) continue;
            Map<Integer, Double> otherRatings = ratings.get(otherUser);
            if (!otherRatings.containsKey(item)) continue; // 邻居必须评分过该物品

            double sim = pearsonCorrelation(user, otherUser);
            if (sim > 0) { // 只考虑正相关用户
                neighbors.add(new Neighbor(otherUser, sim, otherRatings.get(item)));
            }
        }

        // 按相似度排序,取前K个
        neighbors.sort((a, b) -> Double.compare(b.similarity, a.similarity));
        if (neighbors.size() > k) {
            neighbors = neighbors.subList(0, k);
        }

        if (neighbors.isEmpty()) {
            return userAvgRatings.getOrDefault(user, itemAvgRatings.getOrDefault(item, 3.0));
        }

        // 加权预测
        double numerator = 0;
        double denominator = 0;
        double userAvg = userAvgRatings.getOrDefault(user, 3.0);

        for (Neighbor n : neighbors) {
            double otherAvg = userAvgRatings.getOrDefault(n.userId, 3.0);
            numerator += n.similarity * (n.rating - otherAvg);
            denominator += Math.abs(n.similarity);
        }

        return userAvg + numerator / denominator;
    }

    private static class Neighbor {
        int userId;
        double similarity;
        double rating;

        Neighbor(int userId, double similarity, double rating) {
            this.userId = userId;
            this.similarity = similarity;
            this.rating = rating;
        }
    }

三、矩阵分解(Funk SVD)

矩阵分解将用户-物品评分矩阵R分解为两个低秩矩阵的乘积:R ≈ P × Q^T。其中P是用户隐向量矩阵,Q是物品隐向量矩阵。通过梯度下降优化,最小化预测评分与实际评分的误差。

    // 隐向量维度
    private int numFactors;
    // 用户隐向量
    private double[][] userFactors;
    // 物品隐向量
    private double[][] itemFactors;
    // 用户偏置
    private double[] userBias;
    // 物品偏置
    private double[] itemBias;
    // 全局平均评分
    private double globalAvg;

    /**
     * 初始化矩阵分解模型
     * @param numFactors 隐向量维度
     */
    public void initMatrixFactorization(int numFactors) {
        this.numFactors = numFactors;
        this.userFactors = new double[numUsers][numFactors];
        this.itemFactors = new double[numItems][numFactors];
        this.userBias = new double[numUsers];
        this.itemBias = new double[numItems];

        // 计算全局平均
        double sum = 0;
        int count = 0;
        for (Map<Integer, Double> userRatings : ratings.values()) {
            for (double r : userRatings.values()) {
                sum += r;
                count++;
            }
        }
        this.globalAvg = count > 0 ? sum / count : 3.0;

        // 随机初始化隐向量(标准正态分布)
        Random rand = new Random(42);
        for (int u = 0; u < numUsers; u++) {
            for (int f = 0; f < numFactors; f++) {
                userFactors[u][f] = rand.nextGaussian() * 0.01;
            }
        }
        for (int i = 0; i < numItems; i++) {
            for (int f = 0; f < numFactors; f++) {
                itemFactors[i][f] = rand.nextGaussian() * 0.01;
            }
        }
    }

    /**
     * 使用随机梯度下降训练矩阵分解模型
     * @param learningRate 学习率
     * @param regularization 正则化系数
     * @param epochs 训练轮数
     */
    public void trainSGD(double learningRate, double regularization, int epochs) {
        // 将评分数据转换为列表,便于随机采样
        List<RatingEntry> entries = new ArrayList<>();
        for (Map.Entry<Integer, Map<Integer, Double>> e1 : ratings.entrySet()) {
            for (Map.Entry<Integer, Double> e2 : e1.getValue().entrySet()) {
                entries.add(new RatingEntry(e1.getKey(), e2.getKey(), e2.getValue()));
            }
        }

        Random rand = new Random(42);

        for (int epoch = 0; epoch < epochs; epoch++) {
            // 随机打乱
            Collections.shuffle(entries, rand);

            double totalLoss = 0;

            for (RatingEntry entry : entries) {
                int u = entry.user;
                int i = entry.item;
                double r = entry.rating;

                // 预测评分
                double pred = predictMF(u, i);
                double error = r - pred;
                totalLoss += error * error;

                // 更新偏置
                userBias[u] += learningRate * (error - regularization * userBias[u]);
                itemBias[i] += learningRate * (error - regularization * itemBias[i]);

                // 更新隐向量
                for (int f = 0; f < numFactors; f++) {
                    double uf = userFactors[u][f];
                    double itemf = itemFactors[i][f];

                    userFactors[u][f] += learningRate * (error * itemf - regularization * uf);
                    itemFactors[i][f] += learningRate * (error * uf - regularization * itemf);
                }
            }

            if (epoch % 10 == 0) {
                double rmse = Math.sqrt(totalLoss / entries.size());
                System.out.printf("Epoch %d, RMSE: %.4f%n", epoch, rmse);
            }
        }
    }

    /**
     * 矩阵分解预测评分
     */
    public double predictMF(int user, int item) {
        if (user < 0 || user >= numUsers || item < 0 || item >= numItems) {
            return globalAvg;
        }

        double pred = globalAvg + userBias[user] + itemBias[item];
        for (int f = 0; f < numFactors; f++) {
            pred += userFactors[user][f] * itemFactors[item][f];
        }

        // 限制评分范围
        return Math.max(1.0, Math.min(5.0, pred));
    }

    private static class RatingEntry {
        int user, item;
        double rating;

        RatingEntry(int user, int item, double rating) {
            this.user = user;
            this.item = item;
            this.rating = rating;
        }
    }

四、推荐生成与评估

    /**
     * 为用户生成Top-N推荐
     * @param user 目标用户
     * @param n 推荐数量
     * @param useMF 是否使用矩阵分解(否则使用协同过滤)
     */
    public List<Recommendation> recommend(int user, int n, boolean useMF) {
        Set<Integer> ratedItems = ratings.getOrDefault(user, Collections.emptyMap()).keySet();
        List<Recommendation> candidates = new ArrayList<>();

        for (int item = 0; item < numItems; item++) {
            if (ratedItems.contains(item)) continue; // 跳过已评分物品

            double score;
            if (useMF) {
                score = predictMF(user, item);
            } else {
                score = userBasedCF(user, item, 10);
            }
            candidates.add(new Recommendation(item, score));
        }

        // 按预测评分降序排序
        candidates.sort((a, b) -> Double.compare(b.score, a.score));

        return candidates.size() > n ? candidates.subList(0, n) : candidates;
    }

    /**
     * 计算RMSE评估模型
     */
    public double evaluateRMSE(boolean useMF) {
        double sumSquaredError = 0;
        int count = 0;

        for (Map.Entry<Integer, Map<Integer, Double>> e1 : ratings.entrySet()) {
            for (Map.Entry<Integer, Double> e2 : e1.getValue().entrySet()) {
                int user = e1.getKey();
                int item = e2.getKey();
                double actual = e2.getValue();
                double predicted = useMF ? predictMF(user, item) : userBasedCF(user, item, 10);

                sumSquaredError += (actual - predicted) * (actual - predicted);
                count++;
            }
        }

        return Math.sqrt(sumSquaredError / count);
    }

    public static class Recommendation {
        public int itemId;
        public double score;

        public Recommendation(int itemId, double score) {
            this.itemId = itemId;
            this.score = score;
        }

        @Override
        public String toString() {
            return String.format("物品%d (预测评分: %.2f)", itemId, score);
        }
    }

    public static void main(String[] args) {
        // 构造测试数据:10个用户,20个物品
        RecommenderSystem rs = new RecommenderSystem(10, 20);
        Random rand = new Random(42);

        // 随机生成评分(1-5分)
        for (int u = 0; u < 10; u++) {
            for (int i = 0; i < 20; i++) {
                if (rand.nextDouble() < 0.3) { // 30%的填充率
                    double rating = 1 + rand.nextInt(5);
                    rs.addRating(u, i, rating);
                }
            }
        }

        rs.computeAverages();

        // 测试协同过滤
        System.out.println("=== 用户协同过滤 ===");
        double cfRMSE = rs.evaluateRMSE(false);
        System.out.printf("CF RMSE: %.4f%n", cfRMSE);

        List<Recommendation> cfRecs = rs.recommend(0, 5, false);
        System.out.println("为用户0推荐(协同过滤):");
        for (Recommendation rec : cfRecs) {
            System.out.println("  " + rec);
        }

        // 测试矩阵分解
        System.out.println("\n=== 矩阵分解(SVD) ===");
        rs.initMatrixFactorization(10);
        rs.trainSGD(0.01, 0.02, 100);

        double mfRMSE = rs.evaluateRMSE(true);
        System.out.printf("MF RMSE: %.4f%n", mfRMSE);

        List<Recommendation> mfRecs = rs.recommend(0, 5, true);
        System.out.println("为用户0推荐(矩阵分解):");
        for (Recommendation rec : mfRecs) {
            System.out.println("  " + rec);
        }
    }
}

五、复杂度分析

  • 协同过滤:
  • 相似度计算:O(m × n) 预处理,每次预测O(m);
  • 邻居搜索:O(m log m) 排序取Top-K;
  • 适合用户/物品数量较小的场景。

  • 矩阵分解:

  • 训练复杂度:O(epochs × |R| × k),|R|为评分数量,k为隐向量维度;
  • 预测复杂度:O(k),非常高效;
  • 适合大规模推荐场景。

六、总结

本文实现了推荐系统的两种经典算法:

  1. 用户协同过滤:基于皮尔逊相关系数度量用户相似度,通过邻居加权预测未知评分,原理直观、可解释性强;
  2. 矩阵分解(Funk SVD):将高维评分矩阵分解为低维隐向量乘积,通过随机梯度下降训练,预测效率高且能捕捉深层语义关联。

读者可以在此基础上扩展更多功能,如加入时间衰减因子、融合物品内容特征、实现基于物品的协同过滤,或探索更先进的深度学习推荐模型(如Neural Collaborative Filtering)。