PgSQL 5分钟实现推荐系统

用PostgreSQL 5分钟实现一个最简单ItemCF推荐系统

推荐系统大家都熟悉,猜你喜欢,淘宝个性化什么的,前年双十一搞了个大新闻,还拿了CEO特别贡献奖。

今天就来说说怎么用PostgreSQL 5分钟实现一个最简单ItemCF推荐系统,以推荐系统最喜闻乐见的movielens数据集为例。

原理

ItemCF的原理可以看项亮的《推荐系统实战》,不过还是稍微提一下吧,了解的直接跳过就好。

Item CF,全称Item Collaboration Filter,即基于物品的协同过滤,是目前业界应用最多的推荐算法。ItemCF不需要物品与用户的标签、属性,只要有用户对物品的行为日志就可以了,同时具有很好的可解释性。所以无论是亚马逊,Hulu,YouTube,balabala用的都是该算法。

ItemCF算法的核心思想是:给用户推荐那些和他们之前喜欢的物品相似的物品。

这里有两个要点:

  • 用户喜欢物品怎么表示?
  • 物品的相似度怎样表示?

用户评分表

可以通过用户评分表来判断用户对物品的喜爱程度,例如电影数据的5分制:5分表示非常喜欢,1分表示不喜欢。

用户评分表有三个核心字段:user_id, movie_id, rating,分别是用户ID,物品ID,用户对物品的评分。

怎样得到这个表呢?如果本来就是评论打分网站在做推荐系统,直接有用户对电影,音乐,小说的评分记录那是最好不过。其他的场景,比如电商,社交网络,则可以通过用户对物品的行为日志生成这张评分表。例如可以为“浏览”,“点击”,“收藏”,“购买”,点击“我不喜欢”按钮这些行为分别设一个喜好权重:0.1, 0.2, 0.3, 0.4, -100。将所有行为评分加权求和,最终得到这张用户对物品的评分表来,事就成了一半了。

物品相似度

还需要解决的一个问题是物品相似度的计算表示

假设一共有$N$个物品,则物品相似度数据可以表示为一个$N \times N$的矩阵,第$i$行$j$列的值表示物品$i$与物品$j$之间的相似度。这样相似度表示的问题就解决了。

第二个问题是物品相似度矩阵的计算。

但在计算前,首先必须定义,什么是物品的相似度?

两个物品之间的相似度有很多种定义与计算方式,如果我们有物品的各种属性数据(类型,大小,价格,风格,标签)的话,就可以在属性空间定义各式各样的“距离”,来定义相似度。但ItemCF的亮点就在于,不需要物品的属性标签数据也可以计算其相似度来。其核心思想是:如果一对物品被很多人同时喜欢,则认为这一对物品更为相似。

令$N(i)$为喜欢物品$i$的用户集合,$|N(i)|$为喜欢物品$i$的人数,$|N(i) \cap N(j)|$为同时喜欢物品$i,j$的人数,则物品$i,j$之间的相似度$_{ij}$可w以表示为:

$$ w_{ij} = \frac{|N(i) \cap N(j)|}{ \sqrt{ |N(i)| * |N(j)|}} $$

即:同时喜欢物品$i,j$的人数,除以喜爱物品$i$人数和喜爱物品$j$人数的几何平均数。

这样,就可以通过用户对物品的行为日志,导出一份物品之间的相似矩阵数据来。

推荐物品

现在有一个用户$u$,他对物品$j$的评分可以通过以下公式计算:

$$ \displaystyle p_{uj} = \sum_{i \in N(u) \cap S(i, K)} w_{ji}r_{ui} $$

其中,用户$i$对物品$i_1,i_2,\cdots,i_n$的评分分别为$r_1,r_2,…,r_n$,而物品$i_1,i_2,\cdots,i_n$与目标物品$j$的相似度分别为$w_1,w_2,\cdots,w_n$。以用户$u$评分过的物品集合作为纽带,按照评分以相似度加权求和,就可以得到用户$u$对物品$j$的评分了。

对这个预测评分$p$排序取TopN,就得到了用户$u$的推荐物品列表

实践

说了这么多废话,赶紧燥起来。

第一步:准备数据

下载Movielens数据集,开发测试的话选小规模的(100k)就可以。对于ItemCF来说,有用的数据就是用户行为日志,即文件ratings.csv地址

-- movielens 用户评分数据集
CREATE TABLE mls_ratings (
  user_id   INTEGER,
  movie_id  INTEGER,
  rating    TEXT,
  timestamp INTEGER,
  PRIMARY KEY (user_id, movie_id)
);

-- 从CSV导入数据,并将评分乘以2变为2~10的整数便于处理,将Unix时间戳转换为日期类型
COPY mls_ratings FROM '/Users/vonng/Dev/recsys/ml-latest-small/ratings.csv' DELIMITER ',' CSV HEADER;
ALTER TABLE mls_ratings
  ALTER COLUMN rating SET DATA TYPE INTEGER USING (rating :: DECIMAL * 2) :: INTEGER;
ALTER TABLE mls_ratings
  ALTER COLUMN timestamp SET DATA TYPE TIMESTAMPTZ USING to_timestamp(timestamp :: DOUBLE PRECISION);

得到的数据长这样:第一列用户ID列表,第二列电影ID列表,第三列是评分,最后是时间戳。一共十万条

movielens=# select * from mls_ratings limit 10;
 user_id | movie_id | rating |       timestamp
---------+----------+--------+------------------------
       1 |       31 |      5 | 2009-12-14 10:52:24+08
       1 |     1029 |      6 | 2009-12-14 10:52:59+08
       1 |     1061 |      6 | 2009-12-14 10:53:02+08
       1 |     1129 |      4 | 2009-12-14 10:53:05+08

第二步:计算物品相似度

物品相似度的DDL

-- 物品相似度表,这是把矩阵用<i,j,M_ij>的方式在数据库中表示。
CREATE TABLE mls_similarity (
  i INTEGER,
  j INTEGER,
  p FLOAT,
  PRIMARY KEY (i, j)
);

物品相似度是一个矩阵,虽说PostgreSQL里提供了数组,多维数组,自定义数据结构,不过这里为了方便起见还是使用了最传统的矩阵表示方法:坐标索引法$(i,j,m_{ij})$。其中前两个元素为矩阵下标,各自表示物品的ID。最后一个元素存储了这一对物品的相似度。

物品相似度的计算

计算物品相似度,要计算两个中间数据:

  • 每个物品被用户喜欢的次数:$|N(i)|$
  • 每对物品共同被同一个用户喜欢的次数 $|N(i) \cap N(j)|$

如果是用编程语言,那自然可以一趟(One-Pass)解决两个问题。不过SQL就要稍微麻烦点了,好处是不用操心撑爆内存的问题。

这里可以使用PostgreSQL的With子句功能,计算两个临时结果供后续使用,一条SQL就搞定相似矩阵计算:

-- 计算物品相似度矩阵: 3m 53s
WITH mls_occur AS ( -- 中间表:计算每个电影被用户看过的次数
    SELECT
      movie_id,     -- 电影ID: i
      count(*) AS n -- 看过电影i的人数: |N(i)|
    FROM mls_ratings
    GROUP BY movie_id
),
    mls_common AS ( -- 中间表:计算每对电影被用户同时看过的次数
      SELECT
        a.movie_id AS i, -- 电影ID: i
        b.movie_id AS j, -- 电影ID: j
        count(*)   AS n  -- 同时看过电影i和j的人数: |N(i) ∩ N(j)|
      FROM mls_ratings a INNER JOIN mls_ratings b ON a.user_id = b.user_id
      GROUP BY i, j
  )
INSERT INTO mls_similarity
  SELECT
    i,
    j,
    n / sqrt(n1 * n2) AS p  -- 距离公式
  FROM
    mls_common c,
    LATERAL (SELECT n AS n1 FROM mls_occur WHERE movie_id = i) n1,
    LATERAL (SELECT n AS n2 FROM mls_occur WHERE movie_id = j) n2;

物品相似度表大概长这样:

movielens=# SELECT * FROM mls_similarity LIMIT 10;
   i    | j |         p
--------+---+--------------------
 140267 | 1 |  0.110207753755597
   2707 | 1 |  0.180280682843137
 140174 | 1 |  0.113822078644894
   7482 | 1 | 0.0636284762975778

实际上还可以修剪修剪,比如计算时非常小的相似度干脆可以直接删掉。也可以用整个表中相似度的最大值作为单位1,进行归一化。这里都不弄了。

第三步:进行推荐!

现在假设我们为ID为10的用户推荐10部他没看过的电影,该怎么做呢?

WITH seed AS	-- 10号用户评分过的影片作为种子集合
  (SELECT movie_id,rating FROM mls_ratings WHERE user_id = 10)
SELECT
  j as movie_id,	-- 所有待预测评分的电影ID
  sum(seed.rating * p) AS score -- 预测加权分,按此字段降序排序取TopN
FROM
  seed LEFT JOIN mls_similarity s ON seed.movie_id = s.i 
  WHERE j not in (SELECT DISTINCT movie_id FROM seed) -- 去除已经看过的电影(可选)
GROUP BY j ORDER BY score DESC LIMIT 10; -- 聚合,排序,取TOP

推荐结果如下:

 movie_id |      score
----------+------------------
     1270 | 121.487735902517
     1214 | 116.146138947698
     1580 | 116.015331936539
     2797 | 115.144083402858
     1265 | 114.959033115913
      260 | 114.313571128143
     2716 | 113.087151014987
     1097 |  113.07771922959
     1387 | 112.869891345883
     2916 |  112.84326997566

可以进一步包装一下,把它变成一个存储过程get_recommendation

CREATE OR REPLACE FUNCTION get_recommendation(userid INTEGER)
  RETURNS JSONB AS $$ BEGIN
  RETURN (SELECT jsonb_agg(movie_id)
          FROM (WITH seed AS
          (SELECT movie_id,rating FROM mls_ratings WHERE user_id = userid)
                SELECT
                  j as movie_id,
                  sum(seed.rating * p) AS score
                FROM
                  seed LEFT JOIN mls_similarity s ON seed.movie_id = s.i
                WHERE j not in (SELECT DISTINCT movie_id FROM seed)
                GROUP BY j ORDER BY score DESC LIMIT 10) res);
END $$ LANGUAGE plpgsql STABLE;

这样用起来更方便啦,同时也可以在这里加入一些其他的处理逻辑:比如过滤掉禁片黄片,去除用户明确表示过不喜欢的电影,加入一些热门电影,引入一些随机惊喜,打点小广告之类的。

movielens=# SELECT get_recommendation(11) as res;
                                  res
-----------------------------------------------------------------------
 [80489, 96079, 79132, 59315, 91529, 69122, 58559, 59369, 1682, 71535]

最后写个应用把这个存储过程作为OpenAPI开放出去,事就这样成了。

关于这一步可以参考前一篇:当PostgreSQL遇上GraphQL:Postgraphql中的做法,直接由存储过程生成GraphQL API,啥都不用操心了。

What’s more

几行SQL一条龙执行下来,加上下载数据的时间,总共也就五分钟吧。一个简单的推荐系统就这样搭建起来了。

但一个真正的生产系统还需要考虑许许多多其他问题,例如,性能。

这里比如说计算相似度矩阵的时候,才100k条记录花了三四分钟,不太给力。而且这么多SQL写起来,管理起来也麻烦,有没有更好的方案?

这儿有个基于PostgreSQL源码魔改的推荐数据库:RecDB,直接用C实现了推荐系统相关的功能扩展,性能看起来杠杠地;同时还包装了SQL语法糖,一行SQL建立推荐系统!再一行SQL就开始使用啦。

-- 计算推荐所需的信息
CREATE RECOMMENDER MovieRec ON ml_ratings
USERS FROM userid
ITEMS FROM itemid
EVENTS FROM ratingval
USING ItemCosCF

-- 进行推荐!
SELECT * FROM ml_ratings R
RECOMMEND R.itemid TO R.userid ON R.ratingval USING ItemCosCF
WHERE R.userid = 1
ORDER BY R.ratingval
LIMIT 10

PostgreSQL能干的事情太多了,最先进的开源关系数据库确实不是吹的,其实真的可以试一试。