学习

推荐系统是机器学习的常见应用,广泛应用于从电商到音乐流媒体平台的各个行业。

构建推荐系统有多种架构。在我们之前的博客文章中,我们展示了如何构建两种采用不同技术的流行方法。第一种是关于使用 RedisVL 的内容过滤,其中推荐基于项目的底层特征。接下来,我们展示了如何应用 RedisVL 构建协同过滤推荐器,它使用用户评分来创建个性化建议。如果你还没有查看过它们,它们是开始构建推荐系统之旅的绝佳起点。

内容过滤和协同过滤的缺点#

内容过滤可能是最直接的推荐器架构,因为它只需要你想要推荐的产品的数据。这意味着你可以快速入门,并且结果大多会如你所期。用户将获得与他们之前消费过的项目相似的推荐。问题在于,这种方法可能导致用户陷入内容孤岛。在我们之前使用 IMDB 电影的例子中,这意味着一旦某人观看了一部动作片,他们可能就只会看到其他动作片。他们可能也真的很喜欢一部经典的恐怖片,但在他们观看类似内容之前,永远不会获得这类推荐。这种先有鸡还是先有蛋的问题对于内容过滤系统来说很难摆脱。

协同过滤系统没有内容孤岛问题,因为它们利用其他用户的行为来提供个性化推荐。但它们面临着一个不同的挑战,那就是,‘协同过滤依赖现有的用户-项目交互来为用户和项目生成特征,这意味着它们无法自然处理新条目。每次新的交互都会改变其真实交互数据,因此模型需要经常重新训练。

我们想要的是一种能够同时从现有用户行为中学习,并且仍然能够处理添加到系统中的新用户和新项目的方法。双塔模型就能做到这一点。

在本文中,我们将介绍如何构建一个双塔推荐系统,并将其与其他方法进行比较。我们将介绍它的优势以及它如何解决其他方法的缺点。为了稍微改变一下,我们将不使用像前两个例子那样的电影数据集,而是将旧金山的实体餐厅作为推荐的项目来研究。

代码速览#

本博客将逐步介绍如何构建此架构。你可以在此处查看包含这些步骤的 notebook。请确保你的 Redis 实例正在运行。首先,定义一些常量和辅助方法,将我们的餐厅数据加载到 pandas DataFrame 中。我们将使用旧金山的一组餐厅评论数据。原始数据集可以在此处找到。

import os
import requests
import pandas as pd
import json

# Replace values below with your own if using Redis Cloud instance
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = os.getenv("REDIS_PORT", "6379")
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
REDIS_URL = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}"

def fetch_data(file_name):
    dataset_path = 'datasets/two_towers/'
    try:
        with open(dataset_path + file_name, 'r') as f:
            return json.load(f)
    except:
        url = 'https://redis-ai-resources.s3.us-east-2.amazonaws.com/recommenders/datasets/two-towers/'
        r = requests.get(url + file_name)
        if not os.path.exists(dataset_path):
            os.makedirs(dataset_path)
        with open(dataset_path + file_name, 'wb') as f:
            f.write(r.content)
        return json.loads(r.content.decode('utf-8'))

restaurant_data = fetch_data('factual_tripadvisor_restaurant_data_all_100_reviews.json')
restaurant_data = restaurant_data["restaurants"] # ignore count fields

df = pd.DataFrame(restaurant_data)
df.fillna('', inplace=True)
df.drop(columns=['region', 'country', 'tel','fax', 'email', 'website', 'address_extended', 'chain_name','trip_advisor_url'], inplace=True)
df['unique_name'] = df['name'] +' ' + df['address'] # some restaurants are chains or have more than one location
df.head()

以下是我们正在处理的数据概览。

名称

地址

地点

纬度

经度

菜系

价格

评分

21st Amendment Brewery & Restaurant

563 2nd St

San Francisco

37.782448

-122.392576

[Cafe, Pub Food, American, Burgers, Pizza]

2

4.0

Absinthe Brasserie & Bar

398 Hayes St

San Francisco

37.777083

-122.422882

[French, Californian, Mediterranean, Cafe, Ame...

3

4.0

Amber India Restaurant

25 Yerba Buena Ln

San Francisco

37.785772

-122.404401

[Indian, Chinese, Vegetarian, Asian, Pakistani]

2

4.5

Americano

8 Mission St

San Francisco

37.793620

-122.392915

[Italian, American, Californian, Pub Food, Cafe]

3

3.5

Anchor & Hope

83 Minna St

San Francisco

37.787848

-122.398812

[Seafood, American, Cafe, Chowder, Californian]

3

4.0

如果你想构建一个内容过滤系统,现在是将评论中的文本提取出来、合并并生成语义嵌入的好时机,就像我们在之前的文章中做的那样

这将是一个不错的方法,但为了演示双塔架构,我们将不像内容过滤那样使用预训练的嵌入模型。相反,我们将使用其他列作为原始特征——但首先我们将从评论中提取数值评分。

import numpy as np

df['min_rating'] = df['reviews'].apply(lambda x: np.min([r["review_rating"] for r in x]))
df['max_rating'] = df['reviews'].apply(lambda x: np.max([r["review_rating"] for r in x]))
df['avg_rating'] = df['reviews'].apply(lambda x: np.mean([r["review_rating"] for r in x]))
df['stddev_rating'] = df['reviews'].apply(lambda x: np.std([r["review_rating"] for r in x]))
df['price'] = df['price'].astype(int)

# now take all the features we have and build a raw feature vector for each restaurant
numerical_cols = df.select_dtypes(include=['float64', 'int64']).columns
boolean_cols = df.select_dtypes(include=['bool']).columns
df[boolean_cols] = df[boolean_cols].astype(int)

df['feature_vector'] = df[numerical_cols.tolist() + boolean_cols.tolist()].values.tolist()

现在,我们有每个餐厅包含 30 个特征的特征向量。下一步是构建用户的原始特征向量。

我们没有与此餐厅列表对应的公开用户数据,因此我们将使用流行的测试工具 Faker 生成一些数据。

from faker import Faker
from uuid import uuid4

fake = Faker()

def generate_user():
    return {
        "user_id": str(uuid4()),
        "name": fake.name(),
        "username": fake.user_name(),
        "email": fake.email(),
        "address": fake.address(),
        "phone_number": fake.phone_number(),
        "birthdate": fake.date_of_birth().isoformat(),
        "likes": fake.random_elements(elements=['burgers', 'shakes', 'pizza', 'italian', 'mexican', 'fine dining', 'bbq', 'cocktails', 'breweries', 'ethiopian', 'pasta', 'brunch','fast food'], unique=True),
        "account_created_on": fake.date() ,
        "price_bracket": fake.random_element(elements=("low", "middle", "high")),
        "newsletter": fake.boolean(),
        "notifications": fake.boolean(),
        "profile_visibility": fake.random_element(elements=("public", "private", "friends-only")),
        "data_sharing": fake.boolean()
    }

users = [generate_user() for _ in range(1000)]
users_df = pd.DataFrame(users)

用户ID

名称

用户名

电子邮件

地址

电话号码

出生日期

偏好/喜欢

账户创建日期

价格区间

订阅邮件

通知

个人资料可见性

数据共享

2d72a59b-5b12-430f-a5c3-49f8b093cb3d

Kayla Clark

debbie11

8873 Thompson Cape Osborneport, NV 34895

231.228.4452x008

1982-06-10

[pizza, pasta, shakes, brunch, bbq, ethiopian,...

2017-04-18

middle

True

True

public

True

034b2b2f-1949-478d-abd6-add4b3275efe

Leah Hopkins

williamsanchez

353 Kimberly Green Roachfort, FM 34385

4669094632

1999-03-07

[brunch, ethiopian, breweries]

1970-06-21

low

False

True

public

False

5d674492-3026-4cc9-b216-be675cf8d360

Mason Patterson

jamescurtis

945 Bryan Locks Suite 200 Valenzuelaburgh, MI...

885-983-4573

1914-02-14

[cocktails, fine dining, pizza, shakes, ethiop...

2013-03-10

high

False

False

friends-only

False

61e17d13-9e18-431f-8f06-208bd0469892

Aaron Dixon

marshallkristen

42388 Russell Harbors Suite 340 North Andrewc...

448.270.3034x583

1959-05-01

[breweries, cocktails, fine dining]

1973-12-11

middle

False

True

private

True

8cc208b6-0f4f-459c-a8f5-31d3ca6deca6

Loretta Eaton

phatfield

PSC 2899, Box 5115 APO AE 79916

663-371-4597x72295

1923-07-02

[brunch, italian, bbq, mexican, burgers, pizza]

2023-04-29

high

True

True

private

True

现在,创建用户的原始特征向量。

from sklearn.preprocessing import MultiLabelBinarizer

# use a MultiLabelBinarizer to one-hot encode our user's 'likes' column, which has a list of users' food preferences
mlb = MultiLabelBinarizer()

likes_encoded = mlb.fit_transform(users_df['likes'])
likes_df = pd.DataFrame(likes_encoded, columns=mlb.classes_)

users_df = pd.concat([users_df, likes_df], axis=1)

categorical_cols = ['price_bracket', 'profile_visibility']
users_df = pd.get_dummies(users_df, columns=categorical_cols)

boolean_cols = users_df.select_dtypes(include=['boolean']).columns
users_df[boolean_cols] = users_df[boolean_cols].astype(int)

# combine all numerical columns into a single feature vector
numerical_cols = users_df.select_dtypes(include=['int64', 'uint8']).columns
users_df['feature_vector'] = users_df[numerical_cols].values.tolist()

因为双塔模型也像我们的 SVD 协同过滤模型一样在交互数据上进行训练,所以我们需要生成一些购买数据。这将是 1 或 -1,用于指示用户之前是否在这家餐厅用餐过。在此示例中,我们将再次为随机用户生成随机标签。

import random

user_ids = users_df['user_id'].tolist()
restaurant_names = df["unique_name"].tolist()

# generate purchases by randomly selecting users and businesses
purchases = [
    (user_ids[random.randrange(0, len(user_ids))],
     restaurant_names[random.randrange(0, len(restaurant_names))]
    )
    for _ in range(200)
]

positive_labels = []
for i in range(len(purchases)):
    user_index = users_df[users_df['user_id'] == purchases[i][0]].index.item()
    restaurant_index = df[df['unique_name'] == purchases[i][1]].index.item()
    positive_labels.append((user_index, restaurant_index, 1.))

# generate an equal number of negative examples
negative_labels = []
for i in range(len(purchases)):
    user_index = random.randint(0, len(user_ids)-1)
    restaurant_index = random.randint(0, len(restaurant_names)-1)
    negative_labels.append((user_index, restaurant_index, -1.))

labels = positive_labels + negative_labels

现在我们有了所有数据。下一步是定义模型并训练它。

双塔模型究竟在做什么?#

双塔模型同时考虑项目特征和用户特征来提供推荐。它们还在用户/项目交互(如浏览、点赞或购买)上进行训练,但一旦训练完成,仍然能够为全新的用户和全新的项目提供个性化推荐。它们之所以能够做到这一点,是因为它们是深度学习模型,通过在数据子样本上进行训练来学习用户和内容的嵌入表示。它们是归纳式机器学习模型,这意味着它们可以从数据样本中形成通用规则,并将这些规则应用于从未见过的数据。有了训练好的模型,你可以获取一组全新的用户和一组全新的项目,并预测它们交互的可能性。你的模型之前是否见过这个特定的用户或项目都没关系。

典型的双塔模型架构如下图所示:

现在我们熟悉了双塔模型的理念,可以使用 PyTorch 构建我们的模型了。我们将首先定义一个自定义数据加载器,用于训练期间。这只是将我们的用户和项目特征以及标签一起打包到一个 PyTorch Tensor 中,准备使用。我们的模型定义将包含必需的 forward(...) 方法以及两个辅助方法 get_user_embedding(...)get_item_embedding(...),以便我们在训练后能够生成新的嵌入。

import torch
from torch.utils.data import DataLoader, Dataset

import torch.nn as nn
import torch.optim as optim

class PurchaseDataset(Dataset):
    def __init__(self, user_features, restaurant_features, labels):
        self.user_features = user_features
        self.restaurant_features = restaurant_features
        self.labels = labels

    def __len__(self):
        return len(self.labels)

    def __getitem__(self, idx):
        user_index, restaurant_index, label = self.labels[idx]
        return self.user_features[user_index], self.restaurant_features[restaurant_index], torch.tensor(label, dtype=torch.float32)

class TwoTowerModel(nn.Module):
    def __init__(self, user_input_dim, restaurant_input_dim, hidden_dim):
        super(TwoTowerModel, self).__init__()
        self.user_tower = nn.Sequential(
            nn.Linear(user_input_dim, hidden_dim),
            nn.ReLU(),
            nn.Dropout(p=0.5),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Dropout(p=0.5),
            nn.Linear(hidden_dim, hidden_dim),
        )
        self.restaurant_tower = nn.Sequential(
            nn.Linear(restaurant_input_dim, hidden_dim),
            nn.ReLU(),
            nn.Dropout(p=0.5),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Dropout(p=0.5),
            nn.Linear(hidden_dim, hidden_dim),
        )

    def get_user_embeddings(self, user_features):
        return nn.functional.normalize(self.user_tower(user_features), dim=1)

    def get_restaurant_embeddings(self, restaurant_features):
        return nn.functional.normalize(self.restaurant_tower(restaurant_features), dim=1)

    def forward(self, user_features, restaurant_features):
        user_embedding = self.get_user_embeddings(user_features)
        restaurant_embedding = self.get_restaurant_embeddings(restaurant_features)
        return user_embedding, restaurant_embedding

定义好模型后,下一步是准备我们的数据集并将其传递给我们的数据加载器类。

user_features = torch.tensor(users_df['feature_vector'].tolist(), dtype=torch.float32)
restaurant_features = torch.tensor(df['feature_vector'].tolist(), dtype=torch.float32)

dataset = PurchaseDataset(user_features, restaurant_features, labels)
dataloader = DataLoader(dataset, batch_size=64, shuffle=True)

最后,我们准备好训练我们的模型了。我们将通过反向传播来训练它,就像训练其他深度学习模型一样。我们选择了余弦作为损失函数,以与我们的架构图相匹配,并选择了 Adam 作为优化器。我们为训练步骤和模型隐藏维度选择了一些合理的默认值。考虑到我们数据的随机性,你的结果可能会有所不同。

# initialize the model, loss function and optimizer
model = TwoTowerModel(user_input_dim=user_features.shape[1], restaurant_input_dim=restaurant_features.shape[1], hidden_dim=128)
cosine_criterion = nn.CosineEmbeddingLoss()

optimizer = optim.Adam(model.parameters(), lr=0.001)

# train model
num_epochs = 200
losses = []
for epoch in range(num_epochs):
    for user_batch, restaurant_batch, label_batch in dataloader:
        optimizer.zero_grad()
        user_embeddings, restaurant_embeddings = model(user_batch, restaurant_batch)
        loss = cosine_criterion(user_embeddings, restaurant_embeddings, label_batch)
        loss.backward()
        optimizer.step()
    if epoch % 10 == 0:
        print(f'epoch [{epoch+1}/{num_epochs}], loss: {loss.item()}')
    losses.append(loss.item())

为什么使用双塔模型而不是内容过滤或协同过滤?#

与其他的推荐系统架构相比,这似乎相当复杂,那么为什么还要花费这么多精力呢?回答这个问题的最好方法是与其他推荐系统方法进行比较。

内容过滤的缺点

最简单的机器学习推荐方法是内容过滤。它也是一种不考虑用户行为(除了查找相似内容)的方法。这听起来可能不那么糟糕,但很快就会导致用户陷入内容茧房,一旦他们与某个项目互动(即使只是随机的),他们就只会看到类似的项目。

协同过滤的缺点

协同过滤方法,如奇异值分解 (SVD),采取相反的方法,并且考虑用户行为来提供推荐。这有明显的优势,但有一个主要缺点;SVD 无法处理全新的用户或全新的内容。每次有新用户加入或新内容添加到你的库中时,它们都不会有相关的向量。也不会有有意义的新交互数据来重新训练模型并生成向量。模型需要频繁重新训练已经够糟糕了;如果你无法为新用户和新内容提供推荐,那将是更大的问题。

双塔模型将模型训练、嵌入向量创建和推理分开#

一旦我们有了训练好的模型,就可以使用双塔模型中的每个塔为用户和项目生成嵌入。与 SVD 不同,我们无需重新训练模型来获取这些向量。我们也不需要新用户或新内容的新的交互数据。即使是嵌入创建这一步骤也只需进行一次。

user_embeddings = model.get_user_embeddings(user_features=torch.tensor(users_df['feature_vector'].tolist(), dtype=torch.float32))
restaurant_embeddings = model.get_restaurant_embeddings(restaurant_features=torch.tensor(df['feature_vector'].tolist(), dtype=torch.float32))

两全其美#

在解决上述问题方面,双塔模型堪称三重优势(triple whammy)

  • 它们在交互数据(即我们的标签)上进行训练,因此学会了不陷入内容茧房
  • 它们直接考虑用户特征和内容特征来创建个性化推荐
  • 它们可以处理还没有交互数据的全新用户和内容。无需重新训练

虽然我们最初需要一些交互数据来训练模型,但即使并非所有用户或餐厅都包含在我们的标记数据中也完全没问题。只需要一个样本即可。这就是为什么我们可以在不重新训练的情况下处理新用户和新内容的原因。只需要它们的原始特征即可生成嵌入。

加载到 Redis#

使用两组向量,我们将把餐馆元数据载入 Redis 向量数据库进行搜索,将用户向量载入常规键查找以便快速访问。我们希望在我们的 schema 中包含餐馆的营业时间和经纬度位置,因此需要将它们转换为可用格式。这需要一些数据转换。这些步骤和转换后的数据样本如下所示。

#  extract opening and closing times from the 'hours' column
def extract_opening_closing_times(hours, day):
    if day in hours:
        return int(hours[day][0][0].replace(':','')), int(hours[day][0][1].replace(':',''))
    else:
        #assume a reasonable default of 9:00am to 8:00pm
        return 900, 2000

# create new columns for open and closing times for each day of the week
for day in ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']:
    df[f'{day}_open'], df[f'{day}_close'] = zip(*df['hours'].apply(lambda x: extract_opening_closing_times(x, day)))

# combine 'longitude' and 'latitude' into a single 'location' column
df['location'] = df.apply(lambda row: f"{row['longitude']},{row['latitude']}", axis=1)
df.drop(columns=['hours', 'latitude', 'longitude'], inplace=True)

# ensure the 'embedding' column is in the correct format (list of floats)
df['embedding'] = restaurant_embeddings.detach().numpy().tolist()

# ensure all columns are in the correct order as defined in the schema
df = df[['name', 'address', 'locality', 'location', 'cuisine', 'price', 'rating', 'sunday_open', 'sunday_close', 'monday_open', 'monday_close', 'tuesday_open', 'tuesday_close', 'wednesday_open', 'wednesday_close', 'thursday_open', 'thursday_close', 'friday_open', 'friday_close', 'saturday_open', 'saturday_close', 'embedding']]

这是一个准备好载入我们 Redis 向量数据库的餐馆记录样本。

{'name': '21st Amendment Brewery & Restaurant', 'address': '563 2nd St', 'locality': 'San Francisco', 'location': '-122.392576,37.782448', 'cuisine': ['Cafe', 'Pub Food', 'American', 'Burgers', 'Pizza'], 'price': 2, 'rating': 4.0, 'sunday_open': 1000, 'sunday_close': 2359, 'monday_open': 1130, 'monday_close': 2359, 'tuesday_open': 1130, 'tuesday_close': 2359, 'wednesday_open': 1130, 'wednesday_close': 2359, 'thursday_open': 1130, 'thursday_close': 2359, 'friday_open': 1130, 'friday_close': 2359, 'saturday_open': 1130, 'saturday_close': 2359, 'embedding': [0.04085610806941986, -0.07978134602308273, ... ]}

我们用来索引餐馆嵌入的 schema 也将包括位置和营业时间,以便我们可以在搜索过滤器中使用它们。我们将从一个字典定义它,在 Redis 中创建索引,并将餐馆数据载入其中。我们将获得一个键列表。

from redis import Redis
from redisvl.schema import IndexSchema
from redisvl.index import SearchIndex

client = Redis.from_url(REDIS_URL)

restaurant_schema = IndexSchema.from_dict({
    'index': {
        'name': 'restaurants',
        'prefix': 'restaurant',
        'storage_type': 'json'
    },
    'fields': [
        {'name': 'name', 'type': 'text'},
        {'name': 'address', 'type': 'text'},
        {'name': 'locality', 'type': 'tag'},
        {'name': 'location', 'type': 'geo'},
        {'name': 'cuisine', 'type': 'tag'},
        {'name': 'price', 'type': 'numeric'},
        {'name': 'rating', 'type': 'numeric'},
        {'name': 'sunday_open', 'type': 'numeric'},
        {'name': 'sunday_close', 'type': 'numeric'},
        {'name': 'monday_open', 'type': 'numeric'},
        {'name': 'monday_close', 'type': 'numeric'},
        {'name': 'tuesday_open', 'type': 'numeric'},
        {'name': 'tuesday_close', 'type': 'numeric'},
        {'name': 'wednesday_open', 'type': 'numeric'},
        {'name': 'wednesday_close', 'type': 'numeric'},
        {'name': 'thursday_open', 'type': 'numeric'},
        {'name': 'thursday_close', 'type': 'numeric'},
        {'name': 'friday_open', 'type': 'numeric'},
        {'name': 'friday_close', 'type': 'numeric'},
        {'name': 'saturday_open', 'type': 'numeric'},
        {'name': 'saturday_close', 'type': 'numeric'},
        {
            'name': 'embedding',
            'type': 'vector',
            'attrs': {
                'dims': 128,
                'algorithm': 'flat',
                'datatype': 'float32',
                'distance_metric': 'cosine'
            }
        }
    ]
})

restaurant_index = SearchIndex(restaurant_schema, redis_client=client)
restaurant_index.create(overwrite=True, drop=True)

restaurant_keys = restaurant_index.load(df.to_dict(orient='records'))

创建好搜索索引并载入餐馆数据后,是时候将用户向量载入常规的 Redis 空间了。我们的用户向量是用于搜索的向量,因此它们不像餐馆向量那样需要索引。只需将 user_key 随手可用即可。

from redis.commands.json.path import Path

with client.pipeline(transaction=False) as pipe:
    for user_id, embedding in zip(users_df['user_id'], user_embeddings):
        user_key = f"user:{user_id}"

        user_data = {
            "user_embedding": embedding.tolist(),
        }
        pipe.json().set(user_key, Path.root_path(), user_data)
    pipe.execute()

深度学习的力量与 Redis 的速度相结合#

我能听到你在说,“深度学习是很酷,但我的系统需要快。我不想调用深度神经网络来获取推荐。”

嗯,别怕,我的朋友,你不必这样做!虽然训练我们的模型可能需要一段时间,但你不需要经常这样做。如果你仔细观察,你会发现用户和内容嵌入向量都可以生成一次并反复重用。只有在生成推荐时才会发生向量搜索。这些嵌入只会在你的用户或内容特征改变时才会改变,如果你明智地选择特征,这不会经常发生。

Redis 既充当你的用户和内容嵌入的特征存储,也充当超快速实时推荐的最终推理层。这意味着存储嵌入和执行向量比较的所有繁重工作都由你的 Redis 数据库处理,而不是由你的应用程序处理。我们使用 Pytorch 训练了我们的嵌入模型,并且我们需要它不时生成新的嵌入,但当需要获取推荐时,我们只需要用户和项目向量。这就是为什么你在训练和生成嵌入时可以使用你喜欢的任何深度神经网络,并且它不会影响推理。我们用 Redis 向量搜索取代了 Pytorch 模型的最后一层。

位置感知推荐#

我们展示了 Redis 如何在向量相似度搜索之上应用过滤器来进一步优化结果,但你知道它还可以按位置优化搜索结果吗?通过在我们的索引定义中使用 Geo 字段类型,我们可以应用 GeoRadius 过滤器来只查找附近的地方,这对于餐馆推荐系统来说非常有用。

GeoRadiusNum 标签结合使用,我们可以找到对我们个人相关、且在附近的地方,现在正在营业。我们已经准备好了所有数据和向量。现在让我们用查询逻辑将它们组合起来。

from redisvl.query.filter import Tag, Num, Geo, GeoRadius
import datetime

def get_filter(user_long,
               user_lat,
               current_date_time,
               radius=1000,
               low_price=0.0,
               high_price=5.0,
               rating=0.0,
               cuisines=[]):

    geo_filter = Geo("location") == GeoRadius(user_long, user_lat, radius, unit="m") # use a distance unit of meters

    open_filter = Num(f"{current_date_time.strftime('%A').lower()}_open") < current_date_time.hour*100 + current_date_time.minute
    close_filter = Num(f"{current_date_time.strftime('%A').lower()}_close") > current_date_time.hour*100 + current_date_time.minute
    time_filter = open_filter & close_filter

    price_filter = (Num('price') >= low_price) & (Num('price') <= high_price)

    rating_filter = Num('rating') >= rating

    cuisine_filter = Tag('cuisine') == cuisines

    return geo_filter & time_filter & price_filter & rating_filter & cuisine_filter


from redisvl.query import VectorQuery

random_user = random.choice(users_df['user_id'].tolist())
user_vector = client.json().get(f"user:{random_user}")["user_embedding"]

# get a location for this user. Your app may call an API, here we'll set one randomly to within San Francisco in the longitude and latitude bounding box of:
# Lower corner: (-122.5137, 37.7099) in (longitude, latitude) format
# Upper corner: (-122.3785, 37.8101)

longitude = random.uniform(-122.5137, -122.3785)
latitude = random.uniform(37.7099, 37.8101)
longitude, latitude = -122.439, 37.779
radius = 1500

full_filter = get_filter(user_long=longitude,
                         user_lat=latitude,
                         radius=radius,
                         current_date_time=datetime.datetime.today())

query = VectorQuery(vector=user_vector,
                    vector_field_name='embedding',
                    num_results=5,
                    return_score=False,
                    return_fields=['name', 'address', 'location', 'distance'],
                    filter_expression=full_filter,
                    )

results = restaurant_index.query(query)
[{"@type":"@builder.io/sdk:Element","@version":2,"meta":{"naturalWidth":1031},"component":{"name":"Code Snippet","options":{"heading":"","language":"json","code":"from redisvl.query.filter import Tag, Num, Geo, GeoRadius\nimport datetime\n\ndef get_filter(user_long,\n               user_lat,\n               current_date_time,\n               radius=1000,\n               low_price=0.0,\n               high_price=5.0,\n               rating=0.0,\n               cuisines=[]):\n\n    geo_filter = Geo(\"location\") == GeoRadius(user_long, user_lat, radius, unit=\"m\") # use a distance unit of meters\n\n    open_filter = Num(f\"{current_date_time.strftime('%A').lower()}_open\") < current_date_time.hour*100 + current_date_time.minute\n    close_filter = Num(f\"{current_date_time.strftime('%A').lower()}_close\") > current_date_time.hour*100 + current_date_time.minute\n    time_filter = open_filter & close_filter\n\n    price_filter = (Num('price') >= low_price) & (Num('price') <= high_price)\n\n    rating_filter = Num('rating') >= rating\n\n    cuisine_filter = Tag('cuisine') == cuisines\n\n    return geo_filter & time_filter & price_filter & rating_filter & cuisine_filter\n\n\nfrom redisvl.query import VectorQuery\n\nrandom_user = random.choice(users_df['user_id'].tolist())\nuser_vector = client.json().get(f\"user:{random_user}\")[\"user_embedding\"]\n\n# get a location for this user. Your app may call an API, here we'll set one randomly to within San Francisco in the longitude and latitude bounding box of:\n# Lower corner: (-122.5137, 37.7099) in (longitude, latitude) format\n# Upper corner: (-122.3785, 37.8101)\n\nlongitude = random.uniform(-122.5137, -122.3785)\nlatitude = random.uniform(37.7099, 37.8101)\nlongitude, latitude = -122.439, 37.779\nradius = 1500\n\nfull_filter = get_filter(user_long=longitude,\n                         user_lat=latitude,\n                         radius=radius,\n                         current_date_time=datetime.datetime.today())\n\nquery = VectorQuery(vector=user_vector,\n                    vector_field_name='embedding',\n                    num_results=5,\n                    return_score=False,\n                    return_fields=['name', 'address', 'location', 'distance'],\n                    filter_expression=full_filter,\n                    )\n\nresults = restaurant_index.query(query)","showLineNumbers":false,"wrapLongLines":false}},"responsiveStyles":{"large":{"display":"flex","flexDirection":"column","position":"relative","flexShrink":"0","boxSizing":"border-box","marginTop":"20px"}}}]

眼见为实#

载入向量并定义好辅助函数后,我们可以获得一些附近的推荐。这都很好,但你难道不想看到这些推荐吗?我当然想。所以让我们在交互式地图上将其可视化。

import folium
from IPython.display import display

# create a map centered around San Francisco
figure = folium.Figure(width=700, height=600)
sf_map = folium.Map(location=[37.7749, -122.4194],
               zoom_start=13,
               max_bounds=True,
                min_lat= 37.709 - 0.1,
                max_lat= 37.8101 + 0.1,
                min_lon= -122.3785 - 0.3,
                max_lon= -122.5137 + 0.3,
    )

sf_map.add_to(figure)

# add markers for each restaurant in blue
for idx, row in df.iterrows():
    lat, lon = map(float, row['location'].split(','))
    folium.Marker([lon, lat], popup=row['name']).add_to(sf_map)

user = users_df['user_id'].tolist()[42]
user_vector = client.json().get(f"user:{user}")["user_embedding"]

# get a location for this user. Your app may call an API, here we'll set one randomly to within San Francisco
# lower corner: (-122.5137, 37.7099) in (longitude, latitude) format
# upper corner: (-122.3785, 37.8101)
longitude, latitude = -122.439, 37.779
num_results = 25
radius = 2000

# draw a circle centered on our user
folium.Circle(
    location=[latitude, longitude],
    radius=radius,
    color="green",
    weight=3,
    fill=True,
    fill_opacity=0.3,
    opacity=1,
).add_to(sf_map)

full_filter = get_filter(user_long=longitude,
                         user_lat=latitude,
                         radius=radius,
                         current_date_time=datetime.datetime.today()
                         )

query = VectorQuery(vector=user_vector,
                    vector_field_name='embedding',
                    num_results=num_results,
                    return_score=False,
                    return_fields=['name', 'address', 'location', 'rating'],
                    filter_expression=full_filter,
                    )

results = restaurant_index.query(query)

# now show our recommended places in red
for restaurant in results:
    lat, lon = map(float, restaurant['location'].split(','))
    folium.Marker([lon, lat], popup=restaurant['name'] + ' ' + restaurant['rating'] + ' stars', icon=folium.Icon(color='red')).add_to(sf_map)

display(sf_map)

这里你可以看到我们旧金山数据集中的所有餐馆。红点是针对我们的用户的个性化推荐。它们也被缩小到目前正在营业且在我们设定的最大半径 2 公里范围内的餐馆。这正是你在需要深夜小吃或渴望清晨咖啡时想要的。

你做到了!#

就这样!你已经用 Redis 构建了一个深度学习餐馆推荐系统。它是个性化的、位置感知的、适应性强的,而且速度快。Redis 处理规模和速度问题,因此你可以专注于其他一切。

这就结束了我们关于使用 Redis 和 RedisVL 构建推荐系统的三部分系列。在此过程中,我们探讨了向量搜索、不同的相似度度量(余弦和内积)、生成用户和内容嵌入的不同方法、构建过滤器、添加位置和时间感知、分离训练与推理,甚至还融入了一些深度学习作为补充。务必查看 GitHub 上的 RedisVL,并查看我们的其他 AI 资源和示例