Статьи

Neo4j: Генерация рекомендаций в реальном времени с Cypher

Одним из наиболее распространенных применений Neo4j является создание механизмов рекомендаций в реальном времени, и общая тема заключается в том, что они используют много разных битов данных, чтобы предложить интересную рекомендацию.

Например, в этом видео Аманда показывает, как сайты знакомств создают движки рекомендаций в режиме реального времени, начиная с социальных связей, а затем рассказывая о страстях, местоположении и некоторых других вещах.

У Graph Aware есть аккуратная структура, которая поможет вам создать собственный механизм рекомендаций с использованием Java, и мне было любопытно, как будет выглядеть версия Cypher.

Это пример графика:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
CREATE
    (m:Person:Male {name:'Michal', age:30}),
    (d:Person:Female {name:'Daniela', age:20}),
    (v:Person:Male {name:'Vince', age:40}),
    (a:Person:Male {name:'Adam', age:30}),
    (l:Person:Female {name:'Luanne', age:25}),
    (c:Person:Male {name:'Christophe', age:60}),
  
    (lon:City {name:'London'}),
    (mum:City {name:'Mumbai'}),
  
    (m)-[:FRIEND_OF]->(d),
    (m)-[:FRIEND_OF]->(l),
    (m)-[:FRIEND_OF]->(a),
    (m)-[:FRIEND_OF]->(v),
    (d)-[:FRIEND_OF]->(v),
    (c)-[:FRIEND_OF]->(v),
    (d)-[:LIVES_IN]->(lon),
    (v)-[:LIVES_IN]->(lon),
    (m)-[:LIVES_IN]->(lon),
    (l)-[:LIVES_IN]->(mum);

Мы хотим порекомендовать некоторых потенциальных друзей «Адаму», поэтому первый слой нашего запроса — найти его друзей друзей, так как среди них обязательно будет несколько потенциальных друзей:

01
02
03
04
05
06
07
08
09
10
11
12
MATCH (me:Person {name: "Adam"})
MATCH (me)-[:FRIEND_OF]-()-[:FRIEND_OF]-(potentialFriend)
RETURN me, potentialFriend, COUNT(*) AS friendsInCommon
  
==> +--------------------------------------------------------------------------------------+
==> | me                             | potentialFriend                   | friendsInCommon |
==> +--------------------------------------------------------------------------------------+
==> | Node[1007]{name:"Adam",age:30} | Node[1006]{name:"Vince",age:40}   | 1               |
==> | Node[1007]{name:"Adam",age:30} | Node[1005]{name:"Daniela",age:20} | 1               |
==> | Node[1007]{name:"Adam",age:30} | Node[1008]{name:"Luanne",age:25}  | 1               |
==> +--------------------------------------------------------------------------------------+
==> 3 rows

Этот запрос возвращает нам список потенциальных друзей и сколько у нас общих друзей.

Теперь, когда у нас есть несколько потенциальных друзей, давайте начнем строить рейтинг для каждого из них. Один из индикаторов, который может повлиять на потенциального друга, — это если они живут в том же месте, что и мы, поэтому давайте добавим это к нашему запросу:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
MATCH (me:Person {name: "Adam"})
MATCH (me)-[:FRIEND_OF]-()-[:FRIEND_OF]-(potentialFriend)
  
WITH me, potentialFriend, COUNT(*) AS friendsInCommon
  
RETURN  me,
        potentialFriend,
        SIZE((potentialFriend)-[:LIVES_IN]->()<-[:LIVES_IN]-(me)) AS sameLocation
  
==> +-----------------------------------------------------------------------------------+
==> | me                             | potentialFriend                   | sameLocation |
==> +-----------------------------------------------------------------------------------+
==> | Node[1007]{name:"Adam",age:30} | Node[1006]{name:"Vince",age:40}   | 0            |
==> | Node[1007]{name:"Adam",age:30} | Node[1005]{name:"Daniela",age:20} | 0            |
==> | Node[1007]{name:"Adam",age:30} | Node[1008]{name:"Luanne",age:25}  | 0            |
==> +-----------------------------------------------------------------------------------+
==> 3 rows

Далее мы проверим, имеют ли потенциальные друзья Адамса тот же пол, что и он, сравнивая ярлыки каждого узла. У нас есть ярлыки «Мужской» и «Женский», которые указывают на пол.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
MATCH (me:Person {name: "Adam"})
MATCH (me)-[:FRIEND_OF]-()-[:FRIEND_OF]-(potentialFriend)
  
WITH me, potentialFriend, COUNT(*) AS friendsInCommon
  
RETURN  me,
        potentialFriend,
        SIZE((potentialFriend)-[:LIVES_IN]->()<-[:LIVES_IN]-(me)) AS sameLocation,
        LABELS(me) = LABELS(potentialFriend) AS gender
  
==> +--------------------------------------------------------------------------------------------+
==> | me                             | potentialFriend                   | sameLocation | gender |
==> +--------------------------------------------------------------------------------------------+
==> | Node[1007]{name:"Adam",age:30} | Node[1006]{name:"Vince",age:40}   | 0            | true   |
==> | Node[1007]{name:"Adam",age:30} | Node[1005]{name:"Daniela",age:20} | 0            | false  |
==> | Node[1007]{name:"Adam",age:30} | Node[1008]{name:"Luanne",age:25}  | 0            | false  |
==> +--------------------------------------------------------------------------------------------+
==> 3 rows

Далее давайте посчитаем возраст, различный для Адама и его потенциальных друзей

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
MATCH (me:Person {name: "Adam"})
MATCH (me)-[:FRIEND_OF]-()-[:FRIEND_OF]-(potentialFriend)
  
WITH me, potentialFriend, COUNT(*) AS friendsInCommon
  
RETURN me,
       potentialFriend,
       SIZE((potentialFriend)-[:LIVES_IN]->()<-[:LIVES_IN]-(me)) AS sameLocation,
       abs( me.age - potentialFriend.age) AS ageDifference,
       LABELS(me) = LABELS(potentialFriend) AS gender,
       friendsInCommon
  
==> +--------------------------------------------------------------------------------------+
==> | me                             | potentialFriend                   | sameLocation | ageDifference | gender | friendsInCommon |
==> +--------------------------------------------------------------------------------------+
==> | Node[1007]{name:"Adam",age:30} | Node[1006]{name:"Vince",age:40}   | 0            | 10.0          | true   | 1               |
==> | Node[1007]{name:"Adam",age:30} | Node[1005]{name:"Daniela",age:20} | 0            | 10.0          | false  | 1               |
==> | Node[1007]{name:"Adam",age:30} | Node[1008]{name:"Luanne",age:25}  | 0            | 5.0           | false  | 1               |
==> +--------------------------------------------------------------------------------------+
==> 3 rows

Теперь давайте сделаем фильтрацию, чтобы избавиться от людей, с которыми Адам уже дружит — не было бы особого смысла рекомендовать этих людей!

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
MATCH (me:Person {name: "Adam"})
MATCH (me)-[:FRIEND_OF]-()-[:FRIEND_OF]-(potentialFriend)
  
WITH me, potentialFriend, COUNT(*) AS friendsInCommon
  
WITH me,
     potentialFriend,
     SIZE((potentialFriend)-[:LIVES_IN]->()<-[:LIVES_IN]-(me)) AS sameLocation,
     abs( me.age - potentialFriend.age) AS ageDifference,
     LABELS(me) = LABELS(potentialFriend) AS gender,
     friendsInCommon
  
WHERE NOT (me)-[:FRIEND_OF]-(potentialFriend)
  
RETURN me,
       potentialFriend,
       SIZE((potentialFriend)-[:LIVES_IN]->()<-[:LIVES_IN]-(me)) AS sameLocation,
       abs( me.age - potentialFriend.age) AS ageDifference,
       LABELS(me) = LABELS(potentialFriend) AS gender,
       friendsInCommon
  
==> +---------------------------------------------------------------------------------------+
==> | me                             | potentialFriend                   | sameLocation | ageDifference | gender | friendsInCommon |
==> +---------------------------------------------------------------------------------------+
==> | Node[1007]{name:"Adam",age:30} | Node[1006]{name:"Vince",age:40}   | 0            | 10.0          | true   | 1               |
==> | Node[1007]{name:"Adam",age:30} | Node[1005]{name:"Daniela",age:20} | 0            | 10.0          | false  | 1               |
==> | Node[1007]{name:"Adam",age:30} | Node[1008]{name:"Luanne",age:25}  | 0            | 5.0           | false  | 1               |
==> +---------------------------------------------------------------------------------------+
==> 3 rows

В этом случае мы фактически никого не отфильтровали, но для некоторых других людей мы увидим сокращение числа потенциальных друзей.

Наш последний шаг — получить оценку для каждой из функций, которые мы определили как важные для предложения друзей.

Мы присвоим 10 баллов, если люди живут в одном месте или имеют тот же пол, что и Адам, и 0, если нет. Для ageDifference и friendsInCommon мы будем применять логарифмическую кривую, чтобы эти значения не оказывали непропорционального влияния на наш окончательный результат. Мы будем использовать формулу, определенную в ParetoScoreTransfomer, чтобы сделать это:

1
2
3
4
5
6
7
8
9
public <OUT> float transform(OUT item, float score) {
        if (score < minimumThreshold) {
            return 0;
        }
  
        double alpha = Math.log((double) 5) / eightyPercentLevel;
        double exp = Math.exp(-alpha * score);
        return new Double(maxScore * (1 - exp)).floatValue();
    }

А теперь для нашего завершенного запроса рекомендаций:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
MATCH (me:Person {name: "Adam"})
MATCH (me)-[:FRIEND_OF]-()-[:FRIEND_OF]-(potentialFriend)
  
WITH me, potentialFriend, COUNT(*) AS friendsInCommon
  
WITH me,
     potentialFriend,
     SIZE((potentialFriend)-[:LIVES_IN]->()<-[:LIVES_IN]-(me)) AS sameLocation,
     abs( me.age - potentialFriend.age) AS ageDifference,
     LABELS(me) = LABELS(potentialFriend) AS gender,
     friendsInCommon
  
WHERE NOT (me)-[:FRIEND_OF]-(potentialFriend)
  
WITH potentialFriend,
       // 100 -> maxScore, 10 -> eightyPercentLevel, friendsInCommon -> score (from the formula above)
       100 * (1 - exp((-1.0 * (log(5.0) / 10)) * friendsInCommon)) AS friendsInCommon,
       sameLocation * 10 AS sameLocation,
       -1 * (10 * (1 - exp((-1.0 * (log(5.0) / 20)) * ageDifference))) AS ageDifference,
       CASE WHEN gender THEN 10 ELSE 0 END as sameGender
  
RETURN potentialFriend,
      {friendsInCommon: friendsInCommon,
       sameLocation: sameLocation,
       ageDifference:ageDifference,
       sameGender: sameGender} AS parts,
     friendsInCommon + sameLocation + ageDifference + sameGender AS score
ORDER BY score DESC
  
==> +---------------------------------------------------------------------------------------+
==> | potentialFriend                   | parts                                                                                                           | score             |
==> +---------------------------------------------------------------------------------------+
==> | Node[1006]{name:"Vince",age:40}   | {friendsInCommon -> 14.86600774792154, sameLocation -> 0, ageDifference -> -5.52786404500042, sameGender -> 10} | 19.33814370292112 |
==> | Node[1008]{name:"Luanne",age:25}  | {friendsInCommon -> 14.86600774792154, sameLocation -> 0, ageDifference -> -3.312596950235779, sameGender -> 0} | 11.55341079768576 |
==> | Node[1005]{name:"Daniela",age:20} | {friendsInCommon -> 14.86600774792154, sameLocation -> 0, ageDifference -> -5.52786404500042, sameGender -> 0}  | 9.33814370292112  |
==> +----------------------------------------------------------------------------------------+

Последний запрос не так уж и плох — единственный действительно сложный бит — это расчет кривой логарифма. Здесь пользовательские функции вступят в свои права в будущем.

Хорошая особенность этого подхода заключается в том, что нам не нужно выходить за пределы шифра, поэтому, если вы не знакомы с Java, вы все равно можете делать рекомендации в реальном времени! С другой стороны, разные части механизма рекомендаций перепутаны, поэтому не так просто увидеть весь конвейер, как если бы вы использовали платформу с поддержкой графов.

Следующий шаг — применить это к графику Twitter и выработать там рекомендации для подписчиков.