Grafos e recomendação de produtos: comparando Spark GraphFrames com Neo4j

Construindo uma versão do clássico “quem comprou X também comprou Y” com duas ferramentas distintas

Guilherme Boaviagem
Neurolake Blog
9 min readMay 4, 2021

--

Um grafo bipartido.

Grafos são representações de dados conectados ― e, aqui na Neurotech, adoramos conectar dados com inteligência! Em uma tomada de decisão baseada em dados, dispor de informação sobre como eles se relacionam pode prover um diferencial importante. Um problema que se encaixa bem neste cenário é o de recomendação de produtos: sistemas que visam aumentar vendas em negócios de diversos segmentos através da oferta personalizada de itens aos usuários.

Neste artigo, vou apresentar um exemplo de como construir, do zero, uma lista de recomendação de produtos para um usuário de um e-commerce fictício, com duas ferramentas diferentes de graph analytics: Neo4j e Spark GraphFrames. Ao fim do texto, faço uma análise comparativa dos dois frameworks.

Disclaimer 1: os dados e o código podem ser todos encontrados neste repositório do Github.
Disclaimer 2: se está com pressa e quer pular para as queries que geram a recomendação, vá para o intertítulo “Realizando as queries no grafo”.

diversas abordagens para implementar sistemas de recomendação. Aquela que veremos aqui é uma estratégia simples, baseada em queries a grafos, que consiste no seguinte problema:

Suponha que tenho um usuário A, que fez compras em meu e-commerce hoje, e tenho também os usuários Bs que compraram na última semana os mesmos produtos que A adquiriu hoje. Quero recomendar para A todos os produtos que os Bs compraram, mas A não comprou.

É uma versão do clássico “quem comprou X também comprou Y”. Por exemplo, considere o grafo abaixo, indicando 3 usuários de um site de compras e os respectivos produtos comprados.

Um grafo contendo três vértices representando compradores e outros muitos vértices representando os produtos comprados. Alguns produtos foram comprados por mais de um comprador.
Exemplo de grafo consumidor-produto, com três usuários.

Se fôssemos aplicar esta estratégia de recomendação para o usuário user003 (aquele na parte de cima da imagem), considerando que temos somente dados destes três usuários, os produtos recomendados seriam aqueles circulados em vermelho na figura a seguir.

Produtos recomendados para o usuário user003.

Neste artigo, eu realizo as queries com Spark GraphFrames e com o Neo4j. Há algumas razões para isso:

  • Busco validar os resultados, a partir da redundância de soluções.
  • Quero praticar em duas ferramentas distintas, cada uma possuindo suas vantagens e desvantagens.
  • Quero aproveitar a visualização rica que o Neo4j fornece.

A grande vantagem do GraphFrames é a simplicidade de uso e a integração direta com as demais ferramentas do Spark para manipulação de Big Data. Para quem está acostumado com PySpark, por exemplo, é possível usar Python e GraphFrames em um mesmo ambiente. O Neo4j requer um ambiente à parte, para criar e manipular seu banco de grafos. Aqui no Neurolake, por exemplo, já temos clusters rodando Spark — sobre o qual foi construído o Flame para tratar Big Data, por exemplo — de modo que o GraphFrames apresenta-se como uma escolha natural para vários cenários. Convenientemente, o formato dos arquivos de entrada de que preciso para montar o grafo com ambas as ferramentas é bem parecido.

Preparação dos dados

A base de dados criada para este experimento contém 200 transações de compra. Suas primeiras dez linhas são:

O primeiro passo ao modelar um problema como um grafo é definir o graph schema, isto é, quais diferentes tipos de vértices e arestas o grafo conterá, e quais seus atributos. Dado o problema de recomendação de produtos, e dada a base em mãos, fez sentido montar o graph schema a seguir:

Vértices:

Arestas:

Para cada tipo de vértice e aresta, será construído um arquivo .csv, em que cada coluna corresponderá a um atributo e cada linha conterá um vértice ou aresta do determinado tipo. Convém comentar que o formato de dado temporal passado para a função datetime() do Neo4j separa data e horário com o caracter “T”, enquanto o Spark aceita o espaço em branco. Por esse motivo, ainda que a informação temporal não seja utilizada neste projeto, criei colunas separadas para cada formato. Simplesmente para guardar este detalhe na cabeça.

As primeiras linhas da lista de arestas são mostradas a seguir.

Após carregar o grafo no Neo4j, podemos verificar que o graph schema é exatamente igual ao planejado, com a chamada call db.schema.visualization(). Ao clicar nos vértices ou aresta do schema, as propriedades que aparecem batem com o esperado.

Graph schema para o exemplo proposto.

Para o GraphFrames, resta atentar para dois detalhes:

  • Após ler as listas de vértices separadamente, é preciso renomear as colunas que contêm os IDs (email e ID do produto) para id(o GraphFrames espera encontrar essa coluna) e dar um outer-join nas bases tomando idpor chave.
  • De forma semelhante, ao ler a lista de arestas, devemos renomear a coluna de onde parte a aresta para src (de source) e aquela onde chega para dst(de destination).

Basta de descrição dos dados! Vamos ao que interessa!

Realizando as queries no grafo

Tanto o Neo4j como o Spark GraphFrames possuem uma sintaxe para queries que ajuda bastante a responder perguntas complexas em grafos: a busca por padrões. Na linguagem Cypher, usada no Neo4j, o termo é exatamente a tradução para o inglês, pattern matching. No GraphFrames, eles chamam de motif finding, mas o conceito é o mesmo. Em vez de elaborar a query com uma lógica de travessia no grafo, como Gremlin, indica-se qual o padrão de caminho em que estamos interessados e, a partir daí, formula-se o filtro que gera a informação desejada.

Via Neo4j

O padrão que desejamos é o seguinte: um vértice do tipo usuário A se conecta com um do tipo produto PA, que por sua vez também recebe uma aresta de um outro vértice B, que, finalmente, se conecta com outro produto PB. Desejo recomendar PB a A.

A partir deste padrão, nossa pergunta (nosso filtro) é: se A é o usuário “user003@gmail.com”, quais são os produtos PB, dado que A é diferente de B e PB é diferente de PA?

Pois bem, em Cypher, fica assim:

Percebe a definição do padrão, na primeira linha, seguida da descrição do filtro? O resultado desta query é a lista

["Produto14", "Produto15", "Produto17", "Produto18", "Produto19", "Produto23", "Produto26", "Produto28", "Produto3", "Produto30", "Produto31", "Produto32", "Produto35", "Produto36", "Produto38", "Produto39", "Produto4", "Produto40", "Produto41", "Produto43", "Produto44", "Produto45", "Produto47", "Produto48", "Produto49", "Produto50", "Produto6", "Produto7", "Produto8", "Produto9"]

Via Spark GraphFrames

E em GraphFrames, como fica essa query? Usando motif finding, você verá que a estrutura é bem semelhante. Dado que você criou um graphframe no objeto g, a definição do padrão é feita dentro do método g.find() (veja abaixo; tenha em mente que o ponto-e-vírgula separa dois padrões que devem ser simultaneamente obedecidos, e a exclamação indica a negação de um padrão). O tipo de valor retornado é um Spark DataFrame e, por isso, o filtro é todo construído com comandos PySpark usuais.

O mesmo padrão e subsequente filtro, realizado acima com Neo4j, pode ser feito com GraphFrames e PySpark da seguinte forma:

Para extrair desta query uma lista em Python, diretamente com as recomendações de produtos para o usuário user003@gmail.com, basta usar o collect() do PySpark:

que resulta na lista

["Produto14", "Produto15", "Produto17", "Produto18", "Produto19", "Produto23", "Produto26", "Produto28", "Produto3", "Produto30", "Produto31", "Produto32", "Produto35", "Produto36", "Produto38", "Produto39", "Produto4", "Produto40", "Produto41", "Produto43", "Produto44", "Produto45", "Produto47", "Produto48", "Produto49", "Produto50", "Produto6", "Produto7", "Produto8", "Produto9"]

Exatamente como o resultado obtido com o Neo4j, mas com a vantagem de realizar todo o processo dentro de um mesmo notebook Python.

Como você deve ter percebido, as imagens do grafo consumidor-produto que usei lá no começo do artigo foram geradas com o Neo4j, a partir do grafo construído para este exemplo. Para ver se nossa recomendação via GraphFrames concorda com o que vemos na imagem, podemos extrair somente os produtos que foram comprados pelo usuário user005 (usuário do canto inferior esquerdo da imagem) e devem ser recomendados ao user003:

o que resulta em

["Produto15", "Produto28", "Produto23", "Produto3", "Produto36", "Produto4", "Produto43", "Produto48", "Produto49", "Produto6", "Produto8", "Produto9"]

Se você conferir a imagem, bate exatamente com os 12 produtos comprados pelo usuário user005 e que foram circulados em vermelho.

Amarrando as pontas

Embora o leque de técnicas para recomendação de produtos seja amplo, usando desde métricas de similaridade até arquiteturas de redes neurais do estado-da-arte, grafos emergem como uma ferramenta natural para explorar de forma intuitiva as relações entre registros de compra nos bancos de dados.

Com poucas linhas de código, implementamos uma versão da clássica estratégia do “quem comprou X também comprou Y” em dois frameworks diferentes: Neo4j e Spark GraphFrame. O que deu para perceber?

  • Tanto o Neo4j como o GraphFrames oferecem o poderoso recurso de descrever as queries como padrões seguidos de filtros. Seja com o pattern matching do Cypher, ou o motif finding do GraphFrames, a query é feita em pouquíssimas linhas e de forma bastante legível.
  • Foi possível validar os resultados através da comparação entre a saída do Neo4j e do GraphFrames, bem como da inspeção visual do subgrafo gerado via Neo4j.

Diferenças práticas entre usar o Neo4j e o GraphFrames

O processo de criar este exemplo, juntamente com as experiências que tive recentemente em breves POCs com grafos no Neurolake, levaram-me a entender algumas diferenças de ordem prática no uso dos dois frameworks:

  • O Neo4j certamente se destaca pela sua interface: a visualização dos resultados das queries é excelente, seja gerando um subgrafo interativo, seja criando uma tabela que é exportada para .csv com um simples clique. A linguagem Cypher é bastante flexível, e conta com uma documentação vasta e clara. Por outro lado, embora existam drivers que permitam se comunicar com um banco ativo do Neo4j em Python, as queries e testes no grafo são mais facilmente executadas direto no Neo4j — uma desvantagem para quem gostaria de automatizar alguns processos e aproveitar o ambiente Jupyter/PySpark.
  • O Spark GraphFrames não possui interface de usuário, é voltado para o cientista de dados que já utiliza PySpark e quer trazer a análise de grafos para seu script/notebook. Ainda não explorei tanto os motif findings do GraphFrames, mas parece-me que têm uma capacidade de descrição de padrões mais limitada que Cypher.

E quanto à escalabilidade? O Neo4j possui serviços próprios para hospedar bancos de grafos massivos de forma escalável e eficiente, mas para quem já possui seu ambiente de cloud computing instanciado na AWS, o custo-benefício de comprá-lo ou mesmo tentar hospedá-lo em instâncias EC2 não é interessante. Se a ideia é escalar processos e construir grafos com várias centenas de milhares de arestas, um cliente da Amazon vai se sentir bem mais confortável com o uso do GraphFrames em um cluster EMR.

Para onde ir daqui?

  • Se você quer conhecer mais sobre sistemas de recomendação, aqui vão algumas… recomendações. Nesse artigo do Patrick Gomes, ele faz uma introdução bem legal sobre filtragem colaborativa. Já neste post do Mario Filho, ele traz um caso de uso simples da biblioteca Surprise, em Python, em que recomenda produtos usando fatorização de matrizes (decomposição em valores singulares) e compara com uma abordagem de baseline. Neste artigo em inglês, por fim, o autor constrói uma rede neural recorrente, o que geralmente apresenta bons resultados em recomendação de produtos.
  • Para entender melhor como abordar um problema do ponto de vista de grafos, este artigo do Lennon Dias sobre modelagem e definição de queries em um banco Neo4j é uma introdução bem completa.

Pontas amarradas!

Bom, é isso! O que achou deste exemplo? Deu para aprender algo novo? Lembro que o código está no Github e estou à disposição para quem quiser conversar a respeito.

Feitas as considerações finais, vou encerrar o artigo por aqui; do contrário, você abandonaria este longo texto e o Medium poderia ver um desinteresse por nosso blog. Sabe como é, esses sistemas de recomendação são bons. Podem usar grafos e mais um monte de coisa.

--

--