miércoles, 7 de febrero de 2024

26.- Construyendo un motor de recomendaciones de productos. (Redis)

Un motor de recomendación es un sistema que predice la preferencia o calificación que un usuario daría a un artículo. El sistema selecciona artículos relevantes para un usuario basándose en su comportamiento y el conocimiento que tiene sobre ellos. Hoy en día, los sistemas de recomendación se utilizan en muchos servicios en línea. Ayudan a los usuarios al seleccionar cosas en las que podrían estar interesados entre la gran cantidad de datos disponibles que les resultan irrelevantes. Ofrecer buenas recomendaciones mejora la participación del usuario. Los sitios de comercio electrónico también se benefician al ofrecer recomendaciones de productos relevantes al aumentar su ingreso promedio por usuario.

Vas a crear un motor de recomendación simple pero potente que sugiera productos que generalmente se compran juntos. Vamos a sugerir productos basados en ventas históricas, identificando así los productos que normalmente se compran conjuntamente. También vamos a sugerir productos complementarios en dos escenarios diferentes:

- Página de detalle del producto: Mostraremos una lista de productos que generalmente se compran con el producto dado. Esto se mostrará como "Los usuarios que compraron esto también compraron X, Y, y Z". Necesitas una estructura de datos que te permita almacenar el número de veces que cada producto se ha comprado junto con el producto que se muestra.

- Página de detalle del carrito: Basándote en los productos que los usuarios agregan al carrito, vas a sugerir productos que generalmente se compran junto con estos. En este caso, la puntuación que calculas para obtener productos relacionados tiene que ser añadido.

Vas a utilizar Redis para almacenar los productos que generalmente se compran juntos. 

Redis es una base de datos avanzada de clave/valor que te permite guardar diferentes tipos de datos. También cuenta con operaciones de E/S extremadamente rápidas. Redis almacena todo en memoria, pero los datos pueden guardarse al volcar el conjunto de datos al disco de vez en cuando, o al agregar cada comando a un registro. Redis es muy versátil en comparación con otras bases de datos clave/valor: proporciona un conjunto de comandos poderosos y admite diversas estructuras de datos, como cadenas, hashes, listas, conjuntos, conjuntos ordenados e incluso mapas de bits o Hyper-LogLogs.

Aunque SQL es más adecuado para el almacenamiento de datos persistentes, Redis ofrece numerosas ventajas al tratar con datos que cambian rápidamente, almacenamiento volátil o cuando se necesita un caché rápido. Veamos cómo Redis puede ser utilizado para agregar nuevas funcionalidades al proyecto.

Puedes encontrar más información sobre Redis en su página de inicio en https://redis.io/.

Redis proporciona una imagen de Docker que facilita mucho desplegar un servidor Redis con una configuración estándar.


Instalando Redis con Docker.

Después de instalar Docker en tu máquina Linux, macOS o Windows, puedes descargar fácilmente la imagen de Docker de Redis. Ejecuta el siguiente comando desde la terminal:


$ sudo docker pull redis


Esto descargará la imagen de Docker de Redis en tu máquina local. Puedes encontrar información sobre la imagen de Docker oficial de Redis en https://hub.docker.com/_/redis. También puedes encontrar otros métodos alternativos para instalar Redis en https://redis.io/download/.

Ejecuta el siguiente comando en la terminal para iniciar el contenedor de Docker de Redis:


sudo docker run -it --rm --name redis -p 6379:6379 redis


Con este comando, ejecutamos Redis en un contenedor de Docker. La opción -it le indica a Docker que te lleve directamente dentro del contenedor para una entrada interactiva. La opción --rm le dice a Docker que limpie automáticamente el contenedor y elimine el sistema de archivos cuando el contenedor se cierre. La opción --name se utiliza para asignar un nombre al contenedor. La opción -p se utiliza para publicar el puerto 6379, en el que se ejecuta Redis, en el mismo puerto de la interfaz del host. 6379 es el puerto predeterminado para Redis.

Deberías ver una salida que termine con las siguientes líneas:

# Server initialized
* Ready to accept connections tcp


Mantén el servidor Redis en funcionamiento en el puerto 6379 y abre otra terminal. Inicia el cliente Redis con el siguiente comando:

$ sudo docker exec -it redis sh


Verás una línea con el símbolo de almohadilla (#):

#

Inicia el cliente Redis con el siguiente comando:

# redis-cli

Verás el indicador del shell del cliente Redis, como esto:

127.0.0.1:6379>

El cliente Redis te permite ejecutar comandos de Redis directamente desde el shell. Vamos a probar algunos comandos.

Ingresa el comando SET en el shell de Redis para almacenar un valor en una clave:

127.0.0.1:6379> SET name "Yasmina"
OK

El comando anterior crea una clave "name" con el valor de cadena "Yasmina" en la base de datos de Redis. La salida OK indica que la clave se ha guardado correctamente.

A continuación, recupera el valor usando el comando GET, de la siguiente manera:

127.0.0.1:6379> GET name
"Yasmina"

También puedes verificar si una clave existe usando el comando EXISTS. Este comando devuelve 1 si la clave dada existe, y 0 en caso contrario:

127.0.0.1:6379> EXISTS name
(integer) 1

Puedes establecer el tiempo de expiración de una clave usando el comando EXPIRE, que te permite establecer el tiempo de vida en segundos. Otra opción es usar el comando EXPIREAT, que espera una marca de tiempo Unix. La expiración de la clave es útil para usar Redis como caché o para almacenar datos volátiles:

127.0.0.1:6379> GET name
"Yasmina"
127.0.0.1:6379> EXPIRE name 2
(integer) 1

Espera más de dos segundos e intenta obtener la misma clave nuevamente:

127.0.0.1:6379> GET name
(nil)

La respuesta (nil) es una respuesta nula y significa que no se ha encontrado ninguna clave. También puedes eliminar cualquier clave usando el comando DEL, de la siguiente manera:

127.0.0.1:6379> SET total 1
OK
127.0.0.1:6379> DEL total
(integer) 1
127.0.0.1:6379> GET total
(nil)

Estos son solo comandos básicos para operaciones de claves. Puedes encontrar todos los comandos de Redis en https://redis.io/commands/ y todos los tipos de datos de Redis en https://redis.io/docs/manual/data-types/.

Usar Redis con Python

Necesitarás los enlaces de Python para Redis. Instala redis-py a través de pip usando el siguiente comando:

$ pip install redis

Puedes encontrar la documentación de redis-py en https://redis-py.readthedocs.io/.

El paquete redis-py interactúa con Redis, proporcionando una interfaz de Python que sigue la sintaxis de los comandos de Redis. Abre el shell de Python con el siguiente comando:

$ python manage.py shell

Ejecuta el siguiente código:

>>> import redis

>>> r = redis.Redis(host='localhost', port=6379, db=0)

El código anterior crea una conexión con la base de datos de Redis. En Redis, las bases de datos se identifican por un índice entero en lugar de un nombre de base de datos. Por defecto, un cliente está conectado a la base de datos 0. El número de bases de datos de Redis disponibles se establece en 16, pero puedes cambiar esto en el archivo de configuración redis.conf.

A continuación, establece una clave usando el shell de Python:

>>> r.set('foo', 'bar')

True

El comando devuelve True, lo que indica que la clave se ha creado correctamente. Ahora puedes recuperar la clave usando el comando get():

>>> r.get('foo')

b'bar'

Como podrás observar en el código anterior, los métodos de Redis siguen la sintaxis de los comandos de Redis.

Integremos Redis en nuestro proyecto. Edita el archivo settings.py del proyecto y añade la siguiente configuración:

PracticaDjango/PracticaDjango/settings.py

# Configuración para Redis
REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 1

Estas son la configuración requerida para establecer una conexión con el servidor Redis. Crea un nuevo archivo dentro del directorio de aplicaciones de la tienda y llámalo recomendar.py. Agrega el siguiente código:

PracticaDjango/Tienda/recomendar.py

import redis
from django.conf import settings
from .models import Producto

# Conectamos a redis
r = redis.Redis(host=settings.REDIS_HOST,
                port=settings.REDIS_PORT,
                db=settings.REDIS_DB)

class Recomendar():
    def obtener_key_producto(self, id):
        return f'producto:{id}:comprado_junto'
    
    def productos_comprados(self, productos):
        productos_ids = [p.id for p in productos]
        for producto_id in productos_ids:
            for junto_id in productos_ids:
                # obtenemos el otro producto comprado junto con este
                if producto_id != junto_id:
                    # incrementamos el contador de junto comprado
                    r.zincrby(self.obtener_key_producto(producto_id),
                              1, junto_id)


Esta es la clase Recomendar, que te permitirá almacenar compras de productos y recuperar sugerencias de productos para un producto o productos dados.

El método obtener_key_producto() recibe un ID de un objeto Producto y construye la clave Redis para el conjunto ordenado donde se almacenan los productos relacionados, que se ve así: producto:[id]:comprado_junto.

El método productos_comprados() recibe una lista de objetos Producto que se han comprado juntos (es decir, pertenecen al mismo pedido).

En este método, realizas las siguientes tareas:

1. Obtienes los IDs de los productos para los objetos Producto dados.

2. Iteras sobre los IDs de los productos. Para cada ID, iteras nuevamente sobre los IDs de los productos y omites el mismo producto para que obtengas los productos que se compran junto con cada producto.

3. Obtienes la clave del producto Redis para cada producto comprado usando el método obtener_key_producto(). Para un producto con un ID de 33, este método devuelve la clave producto:33:comprado_junto. Esta es la clave para el conjunto ordenado que contiene los IDs de productos que se compraron junto con este.

4. Incrementas la puntuación de cada ID de producto contenido en el conjunto ordenado en 1. La puntuación representa el número de veces que otro producto se ha comprado junto con el producto dado.

Ahora tienes un método para almacenar y puntuar los productos que se compraron juntos. A continuación, necesitas un método para recuperar los productos que se compraron juntos para una lista de productos dados. Agrega el siguiente método sugerir_productos() a la clase Recomendar:

PracticaDjango/Tienda/recomendar.py

class Recomendar:
    #...
    def sugerir_productos(self, productos, max_resultados=6):
        productos_ids = [p.id for p in productos]
        if len(productos) == 1:
            # solamente hay un producto
            sugerencias = r.zrange(
                self.obtener_key_producto(productos_ids[0]),
                0, -1, desc=True)[:max_resultados]
        else:
            # generamos una clave temporal
            flat_ids = ''.join([str(id) for id in productos_ids])
            tmp_key = f'tmp_{flat_ids}'
            # multiples productos, combinamos las puntuaciones de los productos
            # guardamos el resultado ordenado en una clave temporal
            keys = [self.obtener_key_producto(id) for id in productos_ids]
            r.zunionstore(tmp_key, keys)
            # eliminamos los identificadores de los productos recomendados
            r.zrem(tmp_key, *productos_ids)
            # obtener los ID de los productos por su puntuación, ordenada de forma descendente
            sugerencias = r.zrange(tmp_key, 0, -1, desc=True)[:max_resultados]
            # removemos la clave temporal
            r.delete(tmp_key)
        productos_sugeridos_ids = [int(id) for id in sugerencias]
        # establecemos los productos sugeridos y los ordenamos por orden de aparición.
        productos_sugeridos = list(
            Producto.objects.filter(id__in=productos_sugeridos_ids))
        productos_sugeridos.sort(
            key=lambda x: productos_sugeridos_ids.index(x.id))
        return productos_sugeridos

El método sugerir_productos() recibe los siguientes parámetros:

- productos: Esta es una lista de objetos Producto para los que obtener recomendaciones. Puede contener uno o más productos.

- max_resultados: Este es un entero que representa el número máximo de recomendaciones a devolver.

En este método, realizas las siguientes acciones:

1. Obtienes los IDs de los productos para los objetos Producto dados.

2. Si solo se proporciona un producto, recuperas el ID de los productos que se compraron junto con el producto dado, ordenados por el número total de veces que se compraron juntos. Para hacerlo, utilizas el comando ZRANGE de Redis. Limitas el número de resultados al número especificado en el atributo max_resultados (6 por defecto).

3. Si se proporcionan más de un producto, generas una clave temporal de Redis construida con los IDs de los productos.

4. Combina y suma todos las puntuaciones para los elementos contenidos en el conjunto ordenado de cada uno de los productos dados. Esto se hace utilizando el comando ZUNIONSTORE de Redis. El comando ZUNIONSTORE realiza una unión de los conjuntos ordenados con las claves dadas y almacena la suma agregada de las puntuaciones de los elementos en una nueva clave de Redis. Puedes leer más sobre este comando en https://redis.io/commands/zunionstore/. Guardas las puntuaciones agregadas en la clave temporal.

5. Dado que estás agregando puntuaciones, es posible que obtengas los mismos productos para los que estás obteniendo recomendaciones. Los eliminas del conjunto ordenado generado utilizando el comando ZREM.

6. Recuperas los IDs de los productos de la clave temporal, ordenados por sus puntuaciones utilizando el comando ZRANGE. Limitas el número de resultados al número especificado en el atributo max_resultados. Luego, eliminas la clave temporal.

7. Finalmente, obtienes los objetos Producto con los IDs dados y ordenas los productos en el mismo orden que ellos.

Para propósitos prácticos, también añadiremos un método para borrar las recomendaciones. Agrega el siguiente método a la clase Recomendar:

PracticaDjango/Tienda/recomendar.py

class Recomendar:
    #...
    def limpiar_compras(self):
        for id in Producto.objects.values_list('id', flat=True):
            r.delete(self.obtener_key_producto(id))

Una vez que tenemos el código vamos a probar nuestro motor de recomendaciones. Asegurate de tener varios objetos Producto en la base de datos. En mi caso ya tengo grabados varios juegos para PS4 que son los que voy a utilizar para la prueba. Empecemos inicializando el contenedor de Redis en Docker usando el siguiente comando en una consola del terminal:

>>> sudo docker run -it --rm --name redis -p 6379:6379 redis

Abre otra consola del terminal y ejecuta el shell de Python:

>>> python manage.py shell

Asegúrate de que al menos tienes cuatro productos diferentes almacenados en tu base de datos. Vamos a empezar por recuperarlos usando sus nombres, de la siguiente forma:

>>> from Tienda.models import Producto
>>> nba2k23  = Producto.objects.get(nombre="NBA2K23")
>>> ghost  = Producto.objects.get(nombre="GHOST OF TSUSHIMA")
>>> god  = Producto.objects.get(nombre="GOD OF WAR")
>>> read = Producto.objects.get(nombre="READ DEAD REDEMPTION 2")

Luego, añadiremos algunas compras de prueba para que puedan ser procesados por el motor de recomendaciones:

>>> from Tienda.recomendar import Recomendar
>>> r = Recomendar()
>>> r.productos_comprados([nba2k23, ghost])
>>> r.productos_comprados([nba2k23, god])
>>> r.productos_comprados([ghost, nba2k23, read])
>>> r.productos_comprados([god, read])
>>> r.productos_comprados([nba2k23, read])
>>> r.productos_comprados([ghost, god])

Con lo cual hemos guardado las siguientes puntuaciones para los productos:

nba2k23:    ghost(2), read(2), god(1)
ghost:      nba2k23(2), read(1), god(1)
god:        nba2k23(1), read(1), ghost(1)
read:       nba2k23(2), ghost(1), god(1)

Esta es una representación de los productos que se han comprado junto con cada uno de los productos, incluyendo cuantas veces se han comprado juntos.

Recuperemos las recomendaciones para un solo producto:

>>> r.sugerir_productos([nba2k23])
[<Producto: GHOST OF TSUSHIMA>, <Producto: READ DEAD REDEMPTION 2>, <Producto: GOD OF WAR>]
>>> r.sugerir_productos([ghost])
[<Producto: NBA2K23>, <Producto: GOD OF WAR>, <Producto: READ DEAD REDEMPTION 2>]
>>> r.sugerir_productos([god])
[<Producto: GHOST OF TSUSHIMA>, <Producto: READ DEAD REDEMPTION 2>, <Producto: NBA2K23>]
>>> r.sugerir_productos([read])
[<Producto: NBA2K23>, <Producto: GOD OF WAR>, <Producto: GHOST OF TSUSHIMA>]
>>> 

Como puedes observar el orden de recomendación de los productos está basado en su puntuación. Vamos a obtener recomendaciones para múltiples productos usando sus puntuaciones:

>>> r.sugerir_productos([nba2k23, ghost])
[<Producto: READ DEAD REDEMPTION 2>, <Producto: GOD OF WAR>]
>>> r.sugerir_productos([god, ghost])
[<Producto: NBA2K23>, <Producto: READ DEAD REDEMPTION 2>]
>>> r.sugerir_productos([read, nba2k23])
[<Producto: GHOST OF TSUSHIMA>, <Producto: GOD OF WAR>]

Como puedes ver el orden de las recomendaciones coincide con las puntuaciones. Por ejemplo, los juegos sugeridos para nba2k23 y ghost son read(2+1) y god(1+1).

Si en vez de introducir una simulación de productos comprados conjuntamente como hemos visto hasta ahora quieres basarte en los pedidos u ordenes que estén en la base de datos del proyecto puedes utilizar el siguiente código en su lugar. Empecemos inicializando el contenedor de Redis en Docker usando el siguiente comando en una consola del terminal:

>>> sudo docker run -it --rm --name redis -p 6379:6379 redis

Abre otra consola del terminal y ejecuta el shell de Python:

>>> python manage.py shell

Introduce el siguiente código:

>>> from Orders.models import OrderItem

# Obtener todos los objetos OrderItem
>>> order_items = OrderItem.objects.all()

# Crear una lista para almacenar los productos comprados, donde cada elemento representa una orden
>>> productos_comprados = []

# Inicializar una lista vacía para cada orden
>>> for _ in range(len(order_items)):
        productos_comprados.append([])

# Iterar sobre cada OrderItem y agrupar los productos por orden
>>> for order_item in order_items:
        orden_id = order_item.order_id
        producto_comprado = order_item.product
    
        # Agregar el producto a la lista correspondiente a la orden
        productos_comprados[orden_id - 1].append(producto_comprado)

# Ahora 'productos_comprados' contiene una lista de listas donde cada lista interna 
# representa los productos comprados en una orden específica


Para pasar la lista de los productos comprados conjuntamente al motor de recomendación en Redis, puedes iterar sobre la lista productos_comprados y pasar cada lista de productos de cada orden al método productos_comprados de tu instancia Recomendar. Aquí te muestro cómo hacerlo:


python manage.py shell

>>> from Tienda.models import Producto
>>> from Tienda.recomendar import Recomendar

# Crear una instancia del motor de recomendación
>>> recomendador = Recomendar()

# Iterar sobre cada lista de productos comprados por orden
>>> for productos_en_orden in productos_comprados:
        # Crear una lista de instancias de Producto a partir de los nombres de los productos
        productos_instancias = [Producto.objects.get(nombre=producto.nombre) for producto in productos_en_orden]
        # Pasar la lista de instancias de Producto al método productos_comprados del motor de recomendación
        recomendador.productos_comprados(productos_instancias)

y ya solo nos queda ver si funciona el recomendador para ello instanciaremos un juego y probaremos si funciona la recomendación. Lo haremos de la siguiente forma:

>>> nba2k23  = Producto.objects.get(nombre="NBA2K23")
>>> recomendador.sugerir_productos([nba2k23])
[<Producto: READ DEAD REDEMPTION 2>, <Producto: GOD OF WAR>, <Producto: GHOST OF TSUSHIMA>]


Una vez que hemos verificado el funcionamiento correcto del motor de recomendaciones bien utilizando datos que le hemos proporcionado o usando los datos reales de los pedidos de la aplicación vamos a aplicarlo al modelo. Para ello cuando hagamos una compra y vayamos al carro la aplicación no s mostrará los productos recomendados junto con los que el usuario haya añadido al carro. Edita el archivo views.py de la aplicación Carro, importa la clase Recomendar y edita el método mostrar_carro y añade el código resaltado:

PracticaBlog/PracticaDjango/Carro/views.py

#...
# Para aplicar el recomendador de productos
from Tienda.recomendar import Recomendar

# vista para mostrar los productos del carro
def mostrar_carro(request):
    productos = Producto.objects.all()
    carro = Carro(request)
    formulario_cupon = CuponFormulario()
    # para aplicar el recomendador de productos
    r = Recomendar()
    productos_carro = [item['producto'] for item in carro]
    if(productos_carro):
        productos_recomendados = r.sugerir_productos(productos_carro, max_resultados=4)
    else:
        productos_recomendados = []
    contexto = {
        "productos": productos,
        "carro": carro,
        "formulario_cupon": formulario_cupon,
        "productos_recomendados": productos_recomendados,
    }
    return render(request, "Tienda/carro_detalle.html", contexto)
Edita la plantilla  Tienda/carro_detalle.html dentro de la aplicación Tienda y añade el siguiente código justo después de los cupones y antes del código para mostrar un texto cuando la cesta está vacia.

PracticaDjango/Tienda/templates/Tienda/carro_detalle.html

      <div>
        {% if productos_recomendados %}
        <h3 style="color:aliceblue;">Comprados juntos habitualmente</h3>
        <div class="row">
            {% for p in productos_recomendados %}
            <div class="col-md-3">
                <div class="row">
                    <div class="col-md-12">
                        <a href="{% url 'carro:agregar' p.id %}">
                            <img src="{% thumbnail p.imagen 100x0 %}" class="img-fluid">
                        </a>
                    </div>
                </div>
                <div class="row">
                    <div class="col-md-12">
                        <a href="{% url 'carro:agregar' p.id %}" style="color: black;">
                            {{ p.nombre }}
                        </a>
                    </div>
                </div>
            </div>
            {% endfor %}
        </div>
        {% endif %}
    </div>

Si ahora realizas alguna compra y entras en el carro, deberías ver una imagen muy similar a esta:

producto recomendados junto a la compra de otro

Si haces clic en alguno de ellos será añadido al carrito y el motor de recomendaciones volverá a funcionar y te recomendará nuevos productos.

Código del capítulo en GITHUB

No hay comentarios:

Publicar un comentario