# Copyright 2021 NVIDIA Corporation. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ================================

# Each user is responsible for checking the content of datasets and the
# applicable licenses and determining if suitable for the intended use.
https://developer.download.nvidia.com/notebooks/dlsw-notebooks/merlin_merlin_01-building-recommender-systems-with-merlin/nvidia_logo.png

Building Intelligent Recommender Systems with Merlin#

This notebook is created using the latest stable merlin-tensorflow container.

Overview#

Recommender Systems (RecSys) are the engine of the modern internet and the catalyst for human decisions. Building a recommendation system is challenging because it requires multiple stages (data preprocessing, offline training, item retrieval, filtering, ranking, ordering, etc.) to work together seamlessly and efficiently. The biggest challenges for new practitioners are the lack of understanding around what RecSys look like in the real world, and the gap between examples of simple models and a production-ready end-to-end recommender systems.

The figure below represents a four-stage recommender systems. This is a more complex process than only training a single model and deploying it, and it is much more realistic and closer to what’s happening in the real-world recommender production systems.

fourstage

In these series of notebooks, we are going to showcase how we can deploy a four-stage recommender systems using Merlin Systems library easily on Triton Inference Server. Let’s go over the concepts in the figure briefly.

  • Retrieval: This is the step to narrow down millions of items into thousands of candidates. We are going to train a Two-Tower item retrieval model to retrieve the relevant top-K candidate items.

  • Filtering: This step is to exclude the already interacted or undesirable items from the candidate items set or to apply business logic rules. Although this is an important step, for this example we skip this step.

  • Scoring: This is also known as ranking. Here the retrieved and filtered candidate items are being scored. We are going to train a ranking model to be able to use at our scoring step.

  • Ordering: At this stage, we can order the final set of items that we want to recommend to the user. Here, we’re able to align the output of the model with business needs, constraints, or criteria.

To learn more about the four-stage recommender systems, you can listen to Even Oldridge’s Moving Beyond Recommender Models talk at KDD’21 and read more in this blog post.

Learning objectives#

  • Understanding four stages of recommender systems

  • Training retrieval and ranking models with Merlin Models

  • Setting up feature store and approximate nearest neighbours (ANN) search libraries

  • Deploying trained models to Triton Inference Server with Merlin Systems

In addition to NVIDIA Merlin libraries and the Triton Inference Server client library, we use two external libraries in these series of examples:

  • Feast: an end-to-end open source feature store library for machine learning

  • Faiss: a library for efficient similarity search and clustering of dense vectors

You can find more information about Feast feature store and Faiss libraries in the next notebook.

Import required libraries and functions#

Compatibility:

This notebook is developed and tested using the latest merlin-tensorflow container from the NVIDIA NGC catalog. To find the tag for the most recently-released container, refer to the Merlin TensorFlow page.

# for running this example on GPU, install the following libraries
# %pip install "feast<0.20" faiss-gpu

# for running this example on CPU, uncomment the following lines
# %pip install tensorflow-cpu "feast<0.20" faiss-cpu
# %pip uninstall cudf
import os
import nvtabular as nvt
from nvtabular.ops import Rename, Filter, Dropna, LambdaOp, Categorify, \
    TagAsUserFeatures, TagAsUserID, TagAsItemFeatures, TagAsItemID, AddMetadata

from merlin.schema.tags import Tags

import merlin.models.tf as mm
from merlin.io.dataset import Dataset
from merlin.datasets.ecommerce import transform_aliccp
import tensorflow as tf

# for running this example on CPU, comment out the line below
os.environ["TF_GPU_ALLOCATOR"] = "cuda_malloc_async"
2022-09-22 23:24:35.828030: I tensorflow/core/platform/cpu_feature_guard.cc:194] This TensorFlow binary is optimized with oneAPI Deep Neural Network Library (oneDNN) to use the following CPU instructions in performance-critical operations:  SSE3 SSE4.1 SSE4.2 AVX
To enable them in other operations, rebuild TensorFlow with the appropriate compiler flags.
2022-09-22 23:24:36.997030: I tensorflow/core/common_runtime/gpu/gpu_device.cc:1532] Created device /job:localhost/replica:0/task:0/device:GPU:0 with 16249 MB memory:  -> device: 0, name: Quadro GV100, pci bus id: 0000:2d:00.0, compute capability: 7.0
# disable INFO and DEBUG logging everywhere
import logging

logging.disable(logging.WARNING)

In this example notebook, we will generate the synthetic train and test datasets mimicking the real Ali-CCP: Alibaba Click and Conversion Prediction dataset to build our recommender system models.

First, we define our input path and feature repo path.

DATA_FOLDER = os.environ.get("DATA_FOLDER", "/workspace/data/")
# set up the base dir for feature store
BASE_DIR = os.environ.get(
    "BASE_DIR", "/Merlin/examples/Building-and-deploying-multi-stage-RecSys/"
)

Then, we use generate_data utility function to generate synthetic dataset.

from merlin.datasets.synthetic import generate_data

NUM_ROWS = os.environ.get("NUM_ROWS", 100_000)
train_raw, valid_raw = generate_data("aliccp-raw", int(NUM_ROWS), set_sizes=(0.7, 0.3))
/usr/local/lib/python3.8/dist-packages/merlin/schema/tags.py:148: UserWarning: Compound tags like Tags.USER_ID have been deprecated and will be removed in a future version. Please use the atomic versions of these tags, like [<Tags.USER: 'user'>, <Tags.ID: 'id'>].
  warnings.warn(
/usr/local/lib/python3.8/dist-packages/merlin/schema/tags.py:148: UserWarning: Compound tags like Tags.ITEM_ID have been deprecated and will be removed in a future version. Please use the atomic versions of these tags, like [<Tags.ITEM: 'item'>, <Tags.ID: 'id'>].
  warnings.warn(

If you would like to use the real ALI-CCP dataset, you can use get_aliccp() function instead. This function takes the raw csv files, and generate parquet files that can be directly fed to NVTabular workflow above.

Feature Engineering with NVTabular#

output_path = os.path.join(DATA_FOLDER, "processed_nvt")

In the following NVTabular workflow, notice that we apply the Dropna() Operator at the end. We add the Operator to remove rows with missing values in the final DataFrame after the preceding transformations. Although, the synthetic dataset that we generate and use in this notebook does not have null entries, you might have null entries in your user_id and item_id columns in your own custom dataset. Therefore, while applying Dropna() we will not be registering null user_id_raw and item_id_raw values in the feature store, and will be avoiding potential issues that can occur because of any null entries.

user_id_raw = ["user_id"] >> Rename(postfix='_raw') >> LambdaOp(lambda col: col.astype("int32")) >> TagAsUserFeatures()
item_id_raw = ["item_id"] >> Rename(postfix='_raw') >> LambdaOp(lambda col: col.astype("int32")) >> TagAsItemFeatures()

user_id = ["user_id"] >> Categorify(dtype="int32") >> TagAsUserID()
item_id = ["item_id"] >> Categorify(dtype="int32") >> TagAsItemID()

item_features = (
    ["item_category", "item_shop", "item_brand"] >> Categorify(dtype="int32") >> TagAsItemFeatures()
)

user_features = (
    [
        "user_shops",
        "user_profile",
        "user_group",
        "user_gender",
        "user_age",
        "user_consumption_2",
        "user_is_occupied",
        "user_geography",
        "user_intentions",
        "user_brands",
        "user_categories",
    ] >> Categorify(dtype="int32") >> TagAsUserFeatures()
)

targets = ["click"] >> AddMetadata(tags=[Tags.BINARY_CLASSIFICATION, "target"])

outputs = user_id + item_id + item_features + user_features + user_id_raw + item_id_raw + targets

# add dropna op to filter rows with nulls
outputs = outputs >> Dropna()

Let’s call transform_aliccp utility function to be able to perform fit and transform steps on the raw dataset applying the operators defined in the NVTabular workflow pipeline below, and also save our workflow model. After fit and transform, the processed parquet files are saved to output_path.

transform_aliccp(
    (train_raw, valid_raw), output_path, nvt_workflow=outputs, workflow_name="workflow"
)
/usr/local/lib/python3.8/dist-packages/cudf/core/frame.py:384: UserWarning: The deep parameter is ignored and is only included for pandas compatibility.
  warnings.warn(

Training a Retrieval Model with Two-Tower Model#

We start with the offline candidate retrieval stage. We are going to train a Two-Tower model for item retrieval. To learn more about the Two-tower model you can visit 05-Retrieval-Model.ipynb.

Feature Engineering with NVTabular#

We are going to process our raw categorical features by encoding them using Categorify() operator and tag the features with user or item tags in the schema file. To learn more about NVTabular and the schema object visit this example notebook in the Merlin Models repo.

Define a new output path to store the filtered datasets and schema files.

output_path2 = os.path.join(DATA_FOLDER, "processed/retrieval")
train_tt = Dataset(os.path.join(output_path, "train", "*.parquet"))
valid_tt = Dataset(os.path.join(output_path, "valid", "*.parquet"))

We select only positive interaction rows where click==1 in the dataset with Filter() operator.

inputs = train_tt.schema.column_names
outputs = inputs >> Filter(f=lambda df: df["click"] == 1)

workflow2 = nvt.Workflow(outputs)

workflow2.fit(train_tt)

workflow2.transform(train_tt).to_parquet(
    output_path=os.path.join(output_path2, "train")
)

workflow2.transform(valid_tt).to_parquet(
    output_path=os.path.join(output_path2, "valid")
)

NVTabular exported the schema file, schema.pbtxt a protobuf text file, of our processed dataset. To learn more about the schema object and schema file you can explore 02-Merlin-Models-and-NVTabular-integration.ipynb notebook.

Read filtered parquet files as Dataset objects.

train_tt = Dataset(os.path.join(output_path2, "train", "*.parquet"), part_size="500MB")
valid_tt = Dataset(os.path.join(output_path2, "valid", "*.parquet"), part_size="500MB")
schema = train_tt.schema.select_by_tag([Tags.ITEM_ID, Tags.USER_ID, Tags.ITEM, Tags.USER]).without(['user_id_raw', 'item_id_raw', 'click'])
train_tt.schema = schema
valid_tt.schema = schema
model_tt = mm.TwoTowerModel(
    schema,
    query_tower=mm.MLPBlock([128, 64], no_activation_last_layer=True),
    samplers=[mm.InBatchSampler()],
    embedding_options=mm.EmbeddingOptions(infer_embedding_sizes=True),
)
model_tt.compile(
    optimizer="adam",
    run_eagerly=False,
    loss="categorical_crossentropy",
    metrics=[mm.RecallAt(10), mm.NDCGAt(10)],
)
model_tt.fit(train_tt, validation_data=valid_tt, batch_size=1024 * 8, epochs=1)
5/5 [==============================] - 10s 412ms/step - loss: 8.9092 - recall_at_10: 0.0117 - ndcg_at_10: 0.0075 - regularization_loss: 0.0000e+00 - val_loss: 8.9037 - val_recall_at_10: 0.0191 - val_ndcg_at_10: 0.0144 - val_regularization_loss: 0.0000e+00
<keras.callbacks.History at 0x7f52c84da9d0>

Exporting query (user) model#

We export the query tower to use it later during the model deployment stage with Merlin Systems.

query_tower = model_tt.retrieval_block.query_block()
query_tower.save(os.path.join(BASE_DIR, "query_tower"))

Training a Ranking Model with DLRM#

Now we will move onto training an offline ranking model. This ranking model will be used for scoring our retrieved items.

Read processed parquet files. We use the schema object to define our model.

# define train and valid dataset objects
train = Dataset(os.path.join(output_path, "train", "*.parquet"), part_size="500MB")
valid = Dataset(os.path.join(output_path, "valid", "*.parquet"), part_size="500MB")

# define schema object
schema = train.schema.without(['user_id_raw', 'item_id_raw'])
/usr/local/lib/python3.8/dist-packages/cudf/core/frame.py:384: UserWarning: The deep parameter is ignored and is only included for pandas compatibility.
  warnings.warn(
/usr/local/lib/python3.8/dist-packages/merlin/schema/tags.py:148: UserWarning: Compound tags like Tags.USER_ID have been deprecated and will be removed in a future version. Please use the atomic versions of these tags, like [<Tags.USER: 'user'>, <Tags.ID: 'id'>].
  warnings.warn(
/usr/local/lib/python3.8/dist-packages/merlin/schema/tags.py:148: UserWarning: Compound tags like Tags.ITEM_ID have been deprecated and will be removed in a future version. Please use the atomic versions of these tags, like [<Tags.ITEM: 'item'>, <Tags.ID: 'id'>].
  warnings.warn(
target_column = schema.select_by_tag(Tags.TARGET).column_names[0]
target_column
'click'

Deep Learning Recommendation Model (DLRM) architecture is a popular neural network model originally proposed by Facebook in 2019. The model was introduced as a personalization deep learning model that uses embeddings to process sparse features that represent categorical data and a multilayer perceptron (MLP) to process dense features, then interacts these features explicitly using the statistical techniques proposed in here. To learn more about DLRM architetcture please visit Exploring-different-models notebook in the Merlin Models GH repo.

model = mm.DLRMModel(
    schema,
    embedding_dim=64,
    bottom_block=mm.MLPBlock([128, 64]),
    top_block=mm.MLPBlock([128, 64, 32]),
    prediction_tasks=mm.BinaryClassificationTask(target_column),
)
model.compile(optimizer="adam", run_eagerly=False, metrics=[tf.keras.metrics.AUC()])
model.fit(train, validation_data=valid, batch_size=16 * 1024)
5/5 [==============================] - 5s 251ms/step - loss: 0.6932 - auc: 0.4982 - regularization_loss: 0.0000e+00 - val_loss: 0.6932 - val_auc: 0.5000 - val_regularization_loss: 0.0000e+00
<keras.callbacks.History at 0x7f52906b93a0>

Let’s save our DLRM model to be able to load back at the deployment stage.

model.save(os.path.join(BASE_DIR, "dlrm"))

In the following cells we are going to export the required user and item features files, and save the query (user) tower model and item embeddings to disk. If you want to read more about exporting retrieval models, please visit 05-Retrieval-Model.ipynb notebook in Merlin Models library repo.

Set up a feature store with Feast#

Before we move onto the next step, we need to create a Feast feature repository. Feast is an end-to-end open source feature store for machine learning. Feast (Feature Store) is a customizable operational data system that re-uses existing infrastructure to manage and serve machine learning features to real-time models.

We will create the feature repo in the current working directory, which is BASE_DIR for us.

!rm -rf $BASE_DIR/feast
!cd $BASE_DIR && feast init feast
Creating a new Feast repository in /Merlin/examples/Building-and-deploying-multi-stage-RecSys/feature_repo.

You should be seeing a message like Creating a new Feast repository in … printed out above. Now, navigate to the feature_repo folder and remove the demo parquet file created by default, and examples.py file.

feature_repo_path = os.path.join(BASE_DIR, "feast/feature_repo")
if os.path.exists(f"{feature_repo_path}/example_repo.py"):
    os.remove(f"{feature_repo_path}/example_repo.py")
if os.path.exists(f"{feature_repo_path}/data/driver_stats.parquet"):
    os.remove(f"{feature_repo_path}/data/driver_stats.parquet")

Exporting user and item features#

from merlin.models.utils.dataset import unique_rows_by_features

user_features = (
    unique_rows_by_features(train, Tags.USER, Tags.USER_ID)
    .compute()
    .reset_index(drop=True)
)
user_features.head()
user_id user_shops user_profile user_group user_gender user_age user_consumption_2 user_is_occupied user_geography user_intentions user_brands user_categories user_id_raw
0 1 1 1 1 1 1 1 1 1 1 1 1 6
1 2 2 1 1 1 1 1 1 1 2 2 2 8
2 3 3 1 1 1 1 1 1 1 3 3 3 7
3 4 4 1 1 1 1 1 1 1 4 4 4 5
4 5 5 1 1 1 1 1 1 1 5 5 5 10

We will artificially add datetime and created timestamp columns to our user_features dataframe. This required by Feast to track the user-item features and their creation time and to determine which version to use when we query Feast.

from datetime import datetime

user_features["datetime"] = datetime.now()
user_features["datetime"] = user_features["datetime"].astype("datetime64[ns]")
user_features["created"] = datetime.now()
user_features["created"] = user_features["created"].astype("datetime64[ns]")
user_features.head()
user_id user_shops user_profile user_group user_gender user_age user_consumption_2 user_is_occupied user_geography user_intentions user_brands user_categories user_id_raw datetime created
0 1 1 1 1 1 1 1 1 1 1 1 1 6 2022-09-22 23:02:17.150145 2022-09-22 23:02:17.152070
1 2 2 1 1 1 1 1 1 1 2 2 2 8 2022-09-22 23:02:17.150145 2022-09-22 23:02:17.152070
2 3 3 1 1 1 1 1 1 1 3 3 3 7 2022-09-22 23:02:17.150145 2022-09-22 23:02:17.152070
3 4 4 1 1 1 1 1 1 1 4 4 4 5 2022-09-22 23:02:17.150145 2022-09-22 23:02:17.152070
4 5 5 1 1 1 1 1 1 1 5 5 5 10 2022-09-22 23:02:17.150145 2022-09-22 23:02:17.152070
user_features.to_parquet(
    os.path.join(feature_repo_path, "data", "user_features.parquet")
)
item_features = (
    unique_rows_by_features(train, Tags.ITEM, Tags.ITEM_ID)
    .compute()
    .reset_index(drop=True)
)
item_features["datetime"] = datetime.now()
item_features["datetime"] = item_features["datetime"].astype("datetime64[ns]")
item_features["created"] = datetime.now()
item_features["created"] = item_features["created"].astype("datetime64[ns]")
item_features.head()
item_id item_category item_shop item_brand item_id_raw datetime created
0 1 1 1 1 5 2022-09-22 23:02:17.245267 2022-09-22 23:02:17.246515
1 2 2 2 2 7 2022-09-22 23:02:17.245267 2022-09-22 23:02:17.246515
2 3 3 3 3 10 2022-09-22 23:02:17.245267 2022-09-22 23:02:17.246515
3 4 4 4 4 6 2022-09-22 23:02:17.245267 2022-09-22 23:02:17.246515
4 5 5 5 5 8 2022-09-22 23:02:17.245267 2022-09-22 23:02:17.246515
# save to disk
item_features.to_parquet(
    os.path.join(feature_repo_path, "data", "item_features.parquet")
)

Extract and save Item embeddings#

item_embs = model_tt.item_embeddings(
    Dataset(item_features, schema=schema), batch_size=1024
)
item_embs_df = item_embs.compute(scheduler="synchronous")
/usr/local/lib/python3.8/dist-packages/merlin/schema/tags.py:148: UserWarning: Compound tags like Tags.ITEM_ID have been deprecated and will be removed in a future version. Please use the atomic versions of these tags, like [<Tags.ITEM: 'item'>, <Tags.ID: 'id'>].
  warnings.warn(
# select only item_id together with embedding columns
item_embeddings = item_embs_df.drop(
    columns=["item_category", "item_shop", "item_brand"]
)
item_embeddings.head()
item_id 0 1 2 3 4 5 6 7 8 ... 54 55 56 57 58 59 60 61 62 63
0 1 0.030497 0.029997 0.012621 -0.001204 0.012877 -0.031165 -0.009491 -0.024208 -0.011206 ... 0.010395 -0.044563 0.002028 -0.011641 -0.017367 -0.016538 0.003312 -0.020471 0.016938 0.037699
1 2 0.014305 0.004831 -0.006791 -0.010725 0.002375 -0.010010 -0.006006 -0.016317 0.019688 ... -0.023776 -0.028429 -0.039675 0.035854 0.007236 -0.001316 0.014094 0.024848 0.023687 0.020931
2 3 0.026491 -0.011876 0.023269 -0.004026 0.038133 0.016866 -0.037301 -0.014816 0.018586 ... -0.016928 -0.003044 0.017992 -0.043302 0.000884 -0.027940 0.005639 -0.008831 -0.009807 -0.000746
3 4 0.046828 0.017710 -0.033954 -0.039186 0.014467 -0.056866 -0.011080 0.001606 -0.000757 ... -0.014907 -0.020841 -0.039584 0.009472 -0.009085 -0.037578 0.006459 0.008231 0.010318 -0.005625
4 5 0.050902 -0.001969 -0.003946 -0.050269 -0.011292 -0.016854 -0.031103 -0.010389 0.007709 ... 0.009147 -0.000667 0.019289 -0.006992 0.018633 0.013128 -0.017529 0.040066 0.040147 0.035671

5 rows × 65 columns

# save to disk
item_embeddings.to_parquet(os.path.join(BASE_DIR, "item_embeddings.parquet"))

Create feature definitions#

Now we will create our user and item features definitions in the user_features.py and item_features.py files and save these files in the feature_repo.

file = open(os.path.join(feature_repo_path, "user_features.py"), "w")
file.write(
    """
from datetime import timedelta
from feast import Entity, Field, FeatureView, ValueType
from feast.types import Int32
from feast.infra.offline_stores.file_source import FileSource

user_features = FileSource(
    path="{}",
    timestamp_field="datetime",
    created_timestamp_column="created",
)

user_raw = Entity(name="user_id_raw", value_type=ValueType.INT32, join_keys=["user_id_raw"],)

user_features_view = FeatureView(
    name="user_features",
    entities=[user_raw],
    ttl=timedelta(0),
    schema=[
        Field(name="user_shops", dtype=Int32),
        Field(name="user_profile", dtype=Int32),
        Field(name="user_group", dtype=Int32),
        Field(name="user_gender", dtype=Int32),
        Field(name="user_age", dtype=Int32),
        Field(name="user_consumption_2", dtype=Int32),
        Field(name="user_is_occupied", dtype=Int32),
        Field(name="user_geography", dtype=Int32),
        Field(name="user_intentions", dtype=Int32),
        Field(name="user_brands", dtype=Int32),
        Field(name="user_categories", dtype=Int32),
        Field(name="user_id", dtype=Int32),
    ],
    online=True,
    source=user_features,
    tags=dict(),
)
""".format(
        os.path.join(feature_repo_path, "data/", "user_features.parquet")
    )
)
file.close()
with open(os.path.join(feature_repo_path, "item_features.py"), "w") as f:
    f.write(
        """
from datetime import timedelta
from feast import Entity, Field, FeatureView, ValueType
from feast.types import Int32
from feast.infra.offline_stores.file_source import FileSource

item_features = FileSource(
    path="{}",
    timestamp_field="datetime",
    created_timestamp_column="created",
)

item = Entity(name="item_id", value_type=ValueType.INT32, join_keys=["item_id"],)

item_features_view = FeatureView(
    name="item_features",
    entities=[item],
    ttl=timedelta(0),
    schema=[
        Field(name="item_category", dtype=Int32),
        Field(name="item_shop", dtype=Int32),
        Field(name="item_brand", dtype=Int32),
        Field(name="item_id_raw", dtype=Int32),
    ],
    online=True,
    source=item_features,
    tags=dict(),
)
""".format(
            os.path.join(feature_repo_path, "data/", "item_features.parquet")
        )
    )
file.close()

Let’s checkout our Feast feature repository structure.

# install seedir
!pip install seedir
Requirement already satisfied: seedir in /usr/local/lib/python3.8/dist-packages (0.3.1)
Requirement already satisfied: natsort in /usr/local/lib/python3.8/dist-packages (from seedir) (8.1.0)
Requirement already satisfied: emoji in /usr/local/lib/python3.8/dist-packages (from seedir) (2.0.0)
import seedir as sd

feature_repo_path = os.path.join(BASE_DIR, "feast")
sd.seedir(
    feature_repo_path,
    style="lines",
    itemlimit=10,
    depthlimit=3,
    exclude_folders=".ipynb_checkpoints",
    sort=True,
)
feature_repo/
├─__init__.py
├─data/
│ ├─item_features.parquet
│ └─user_features.parquet
├─feature_store.yaml
├─item_features.py
└─user_features.py

Next Steps#

We trained and exported our ranking and retrieval models and NVTabular workflows. In the next step, we will learn how to deploy our trained models into Triton Inference Server (TIS) with Merlin Systems library.

For the next step, move on to the 02-Deploying-multi-stage-Recsys-with-Merlin-Systems.ipynb notebook to deploy our saved models as an ensemble to TIS and obtain prediction results for a given request.