在 AMD GPU 上构建深度学习推荐模型

Deep Learning Recommendation Models on AMD GPUs — ROCm Blogs

2024 年 6 月 28 日 发布者 Phillip Dang

在这篇博客中,我们将演示如何在支持 ROCm 的 AMD GPU 上使用 PyTorch 构建一个简单的深度学习推荐模型 (DLRM)。

简介

DLRM 位于推荐系统和深度学习的交汇处,利用神经网络在庞大的数据集中预测用户与物品的交互。它是一种强大的工具,在各种领域中个性化推荐,从电子商务到内容流媒体平台。

正如 《深度学习推荐模型:个性化和推荐系统》 中讨论的那样,DLRM 具有几个组件:

  • 多个嵌入表将稀疏特征(每个特征一个)映射到密集表示。

  • 一个底部多层感知器 (MLP),将密集特征转换为与嵌入向量长度相同的密集表示。

  • 一个特征交互层,计算所有嵌入向量和处理后的密集特征之间的点积。

  • 一个顶部多层感知器 (MLP),输入交互特征与原始处理后的密集特征串联,并输出对数 (logits)。

以下图表总结了 DLRM 的架构,来自 《深度学习推荐模型:深度探究》

Deep learning recommendation model architecture

DLRM 与其他深度学习网络的区别之一是它“在结构上以一种模仿因子分解机的方式特定地交互嵌入,通过只考虑最终 MLP 中成对嵌入之间点积产生的交叉项来显著减少模型的维度”[参考文献]。这使 DLRM 相比于其他网络如 Deep 和 Cross 大幅减少了模型的维度。

本博客强调简单性。我们将通过一个简单的数据集来预测展示广告的点击率,构建一个基本的 DLRM 架构,并为理解其内部工作原理提供坚实的基础。鼓励用户调整和扩展模型,探索更多复杂性,以适应他们的具体需求。

前提条件

确保系统识别到你的 GPU:

! rocm-smi --showproductname
================= ROCm System Management Interface ================
========================= Product Info ============================
GPU[0] : Card series: Instinct MI210
GPU[0] : Card model: 0x0c34
GPU[0] : Card vendor: Advanced Micro Devices, Inc. [AMD/ATI]
GPU[0] : Card SKU: D67301
===================================================================
===================== End of ROCm SMI Log =========================

检查是否安装了正确版本的 ROCm.

! apt show rocm-core -a 
Package: rocm-core
Version: 5.7.0.50700-63~22.04
Priority: optional
Section: devel
Maintainer: ROCm Dev Support <rocm-dev.support@amd.com>
Installed-Size: 94.2 kB
Homepage: https://github.com/RadeonOpenCompute/ROCm
Download-Size: 7030 B
APT-Manual-Installed: no
APT-Sources: http://repo.radeon.com/rocm/apt/5.7 jammy/main amd64 Packages
Description: Radeon Open Compute (ROCm) Runtime software stack

确保 PyTorch 也识别到 GPU:

import torch
print(f"number of GPUs: {torch.cuda.device_count()}")
print([torch.cuda.get_device_name(i) for i in range(torch.cuda.device_count())])
number of GPUs: 1
['AMD Radeon Graphics']

数据集

以下内容是基于原始 DLRM 论文 中介绍的,我们将使用 Criteo 数据集 来预测广告点击率 (CTR)。我们将预测用户在访问页面时点击给定广告的概率。

数据字段包含以下内容:

  • Label - 目标变量,指示广告是否被点击(1)或未被点击(0)。

  • I1-I13 - 一共13列整数特征(主要是计数特征)。

  • C1-C26 - 一共26列分类特征。这些特征的值已被哈希成32位以进行匿名化处理。

这些特征的具体语义未被披露,这在匿名化数据集中是常见的做法,以保护隐私和专有信息。为方便起见,我们已经下载了数据并将其包含在我们的代码库中。让我们安装和导入所需的库,并加载数据集。

! pip install --upgrade pip
! pip install --upgrade pandas
! pip install --upgrade scikit-learn

import torch
import torch.nn as nn
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder, MinMaxScaler
from torch.utils.data import DataLoader, TensorDataset
from tqdm import tqdm

columns = ["label", *(f"I{i}" for i in range(1, 14)), *(f"C{i}" for i in range(1, 27))]
df = pd.read_csv(
    "../data/dac_sample.txt", sep="\t", names=columns
).fillna(0)

预处理

我们的预处理步骤包括对稀疏和类别特征进行序数编码,并对密集和数值特征进行最小-最大缩放。

sparse_cols = ["C" + str(i) for i in range(1, 27)]
dense_cols = ["I" + str(i) for i in range(1, 14)]

data = df[sparse_cols + dense_cols]
data = data.astype(str)
for feat in sparse_cols:
    lbe = LabelEncoder()
    data[feat] = lbe.fit_transform(data[feat])
mms = MinMaxScaler(feature_range=(0, 1))
data[dense_cols] = mms.fit_transform(data[dense_cols])
print(data.sample(5))
C1	C2	C3	C4	C5	C6	C7	C8	C9	C10	...	I4	I5	I6	I7	I8	I9	I10	I11	I12	I13
33714	8	289	12798	19697	23	10	2279	86	2	2505	...	0.000000	0.001729	0.013812	0.000000	0.000000	0.001027	0.000000	0.000000	0.0	0.000000
41376	8	21	6057	20081	23	5	6059	27	2	2505	...	0.011990	0.002585	0.008840	0.000795	0.001283	0.020220	0.000000	0.028846	0.0	0.000762
21202	8	62	23836	24608	41	5	2305	8	2	2933	...	0.004796	0.000136	0.002701	0.000568	0.005987	0.003633	0.166667	0.019231	0.0	0.003355
89866	8	113	42768	7421	23	11	827	48	2	2505	...	0.002398	0.000118	0.000491	0.000114	0.002138	0.002133	0.166667	0.009615	0.0	0.000152
19993	327	78	31306	11660	23	0	6837	27	2	8905	...	0.007194	0.001740	0.001105	0.002612	0.001497	0.002448	0.000000	0.038462	0.0	0.000457

我们还移除了一些类别数过多的稀疏特征,在这个数据集中大约有10,000个类别。按照Kaggle的社区讨论中的推荐,当稀疏特征的类别数超过了10,000时,模型性能的提升是微乎其微的,而且这只会不必要地增加参数的数量。

# 获取每个分类特征的类别数
num_categories = [len(data[c].unique()) for c in sparse_cols]

# 只保留类别数少于10K的分类特征
indices_to_keep = [i for i, num in enumerate(num_categories) if num <= 10000]
num_categories_kept = [num_categories[i] for i in indices_to_keep]
sparse_cols_kept = [sparse_cols[i] for i in indices_to_keep]

最后,我们将数据拆分为训练集和测试集,然后将它们转换成 torch 张量并创建相应的数据加载器。

device = "cuda" if torch.cuda.is_available() else "cpu"
batch_size = 128

# 拆分数据集
X_train, X_test, y_train, y_test = train_test_split(
    data, df["label"], test_size=0.2, random_state=42
)

# 转换成张量
# 训练集
X_train_sparse = torch.tensor(X_train[sparse_cols_kept].values, dtype=torch.long).to(
    device
)
X_train_dense = torch.tensor(X_train[dense_cols].values, dtype=torch.float).to(device)
y_train = torch.tensor(y_train.values, dtype=torch.float).unsqueeze(1).to(device)

# 测试集
X_test_sparse = torch.tensor(X_test[sparse_cols_kept].values, dtype=torch.long).to(device)
X_test_dense = torch.tensor(X_test[dense_cols].values, dtype=torch.float).to(device)
y_test = torch.tensor(y_test.values, dtype=torch.float).unsqueeze(1).to(device)

# 创建训练数据的DataLoader 
train_dataset = TensorDataset(X_train_sparse, X_train_dense, y_train)
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
# 创建测试数据的DataLoader
test_dataset = TensorDataset(X_test_sparse, X_test_dense, y_test)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)

建模

让我们为我们的DLRM设置一些超参数:

device = "cuda" if torch.cuda.is_available() else "cpu"
num_epochs = 10
lr = 3e-4
batch_size = 128
hidden_size = 32
embd_dim = 16

现在我们准备创建我们的DLRM。为了简化,底部和顶部的MLP将是一个简单的3层(输入、隐藏、输出)神经网络,并使用ReLU激活函数。

class FeatureInteraction(nn.Module):
    def __init__(self):
        super(FeatureInteraction, self).__init__()

    def forward(self, x):
        feature_dim = x.shape[1]

        concat_features = x.view(-1, feature_dim, 1)
        dot_products = torch.matmul(concat_features, concat_features.transpose(1, 2))
        ones = torch.ones_like(dot_products)

        mask = torch.triu(ones)
        out_dim = feature_dim * (feature_dim + 1) // 2

        flat_result = dot_products[mask.bool()]
        reshape_result = flat_result.view(-1, out_dim)

        return reshape_result

class DLRM(torch.nn.Module):

    def __init__(
        self,
        embd_dim,
        num_categories,
        num_dense_feature,
        hidden_size,
    ):
        super(DLRM, self).__init__()
        # 为每个分类特征创建具有相同嵌入维度的嵌入
        self.embeddings = nn.ModuleList(
            [nn.Embedding(num_cat, embd_dim) for num_cat in num_categories]
        )

        self.feat_interaction = FeatureInteraction()
        self.bottom_mlp = nn.Sequential(
            nn.Linear(in_features=num_dense_feature, out_features=hidden_size),
            nn.ReLU(),
            nn.Linear(hidden_size, embd_dim),
        )
        num_feat = (
            len(num_categories) * embd_dim + embd_dim
        )  # 包括分类特征和数值特征
        num_feat_interact = num_feat * (num_feat + 1) // 2  # interaction features
        top_mlp_in = (
            num_feat_interact + embd_dim
        )  # 交互特征与数值特征连接
        self.top_mlp = nn.Sequential(
            nn.Linear(in_features=top_mlp_in, out_features=hidden_size),
            nn.ReLU(),
            nn.Linear(hidden_size, 1),
        )

    def forward(self, x_cat, x_num):
        B = x_cat.shape[0]
        num_sparse_feat = x_cat.shape[1]

        # 查找分类特征的嵌入
        embed_x = torch.concat(
            [
                self.embeddings[i](x_cat[:, i]).unsqueeze(1)
                for i in range(num_sparse_feat)
            ]
        )  # B, num_sparse_feat, embedding dim
        embed_x = embed_x.view(B, -1)  # B, num_sparse_feat * embedding dim

        # 获取底部的数值特征
        dense_x = self.bottom_mlp(x_num)  # B, embedding dim
        # 与嵌入连接
        x = torch.concat(
            [embed_x, dense_x], dim=-1
        )  # B, (num_sparse_feat+1) * embedding dim
        # 获取二阶交互特征
        x = self.feat_interaction(x)  # B, n*(n+1) // 2
        # 与数值特征结合
        x = torch.concat([x, dense_x], dim=-1)
        # 通过顶部MLP
        x = self.top_mlp(x)  # B, 1
        return x

让我们实例化我们的模型,并定义我们的损失函数和优化器。

# 实例化模型、损失函数和优化器
model = DLRM(
    embd_dim=embd_dim,
    num_categories=num_categories_kept,
    num_dense_feature=len(dense_cols),
    hidden_size=hidden_size,
)
model.to(device)
criterion = torch.nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=lr)

print(f"running on {device}")
print(sum(p.numel() for p in model.parameters()) / 1e6, "M parameters")
print(model)

输出示例:

running on cuda
2.195553 M parameters
DLRM(
  (embeddings): ModuleList(
    (0): Embedding(541, 16)
    (1): Embedding(497, 16)
    (2): Embedding(145, 16)
    (3): Embedding(12, 16)
    (4): Embedding(7623, 16)
    (5): Embedding(257, 16)
    (6): Embedding(3, 16)
    (7): Embedding(3799, 16)
    (8): Embedding(2796, 16)
    (9): Embedding(26, 16)
    (10): Embedding(5238, 16)
    (11): Embedding(10, 16)
    (12): Embedding(2548, 16)
    (13): Embedding(1303, 16)
    (14): Embedding(4, 16)
    (15): Embedding(11, 16)
    (16): Embedding(14, 16)
    (17): Embedding(51, 16)
    (18): Embedding(9527, 16)
  )
  (feat_interaction): FeatureInteraction()
  (bottom_mlp): Sequential(
    (0): Linear(in_features=13, out_features=32, bias=True)
    (1): ReLU()
    (2): Linear(in_features=32, out_features=16, bias=True)
  )
  (top_mlp): Sequential(
    (0): Linear(in_features=51376, out_features=32, bias=True)
    (1): ReLU()
    (2): Linear(in_features=32, out_features=1, bias=True)
  )
)

训练

接下来,让我们编写两个工具函数,一个用于模型训练,一个用于在训练集和测试集上的模型评估。

def train_one_epoch():
    model.train()
    for i, (x_sparse, x_dense, y) in enumerate(tqdm(train_loader)):
        x_sparse = x_sparse.to(device)
        x_dense = x_dense.to(device)
        y = y.to(device)
        optimizer.zero_grad()
        logits = model(x_sparse, x_dense)
        loss = criterion(logits, y)
        loss.backward()
        optimizer.step()
def evaluate(dataloader, dataname):
    model.eval()
    total_samples = 0
    total_loss = 0
    total_correct = 0
    with torch.no_grad():
        for i, (x_sparse, x_dense, y) in enumerate(tqdm(dataloader)):
            x_sparse = x_sparse.to(device)
            x_dense = x_dense.to(device)
            y = y.to(device)
            logits = model(x_sparse, x_dense)
            probs = torch.sigmoid(logits)
            predictions = (probs > 0.5).long()

            loss = criterion(logits, y)
            total_loss += loss.item() * y.shape[0]
            total_correct += (predictions == y).sum().item()
            total_samples += y.shape[0]

    avg_loss = total_loss / total_samples
    accuracy = total_correct / total_samples * 100
    print(
        f"{dataname} accuracy = {accuracy:0.2f}%, {dataname} avg loss = {avg_loss:.6f}"
    )
    return accuracy, avg_loss

现在我们准备好训练我们的模型。

for epoch in range(num_epochs):
    print(f"epoch {epoch+1}")
    train_one_epoch()
    evaluate(train_loader, "train")
    evaluate(test_loader, "test")
    print()
输出结果:
epoch 1
100%|██████████| 625/625 [00:06<00:00, 92.18it/s]
100%|██████████| 625/625 [00:01<00:00, 351.70it/s]
train accuracy = 77.37%, train avg loss = 0.535811
100%|██████████| 157/157 [00:00<00:00, 354.67it/s]
test accuracy = 77.14%, test avg loss = 0.538407

...

epoch 10
100%|██████████| 625/625 [00:06<00:00, 98.00it/s]
100%|██████████| 625/625 [00:01<00:00, 351.16it/s]
train accuracy = 77.48%, train avg loss = 0.498510
100%|██████████| 157/157 [00:00<00:00, 352.91it/s]
test accuracy = 77.14%, test avg loss = 0.501544

推理

现在,我们已经有了一个DLRM模型,可以预测给定用户/广告组合的点击概率。由于Criteo数据集的特征语义未公开,我们无法重建代表真实用户或广告的有意义特征向量。出于说明目的,我们假设特征向量的一部分代表用户,其余的代表广告。特别是,为了简单起见,我们假设所有整数特征代表用户,所有分类特征代表广告。

假设在投放广告时,我们检索到了10个广告候选项。DLRM的任务是选择最佳广告候选项向用户展示。为了解释目的,我们假定检索到的广告候选项来自原始数据集的前10行,而用户是第一行中的用户。首先,我们创建一个数据加载器,将用户和广告特征组合起来,以供模型使用。

# 假设我们使用数据集的前10行作为广告候选项
num_ads = 10
df_c = pd.DataFrame(data.iloc[0:num_ads])
# 获取广告候选项特征
df_ads = df_c[df_c.columns[26:39]]
# 获取第一行的用户特征
df_user = df_c[df_c.columns[0:26]].iloc[0:1]
# 将用户特征复制到所有广告候选项行
df_user_rep = df_user
for i in range(num_ads-1): 
    df_user_rep = pd.concat([df_user_rep, df_user], ignore_index=True, sort=False)
df_candidates = pd.concat([df_user_rep, df_ads], axis=1)

# 将特征向量转换为张量
X_inf_sparse = torch.tensor(df_candidates[sparse_cols_kept].values, dtype=torch.long).to(device)
X_inf_dense = torch.tensor(df_candidates[dense_cols].values, dtype=torch.float).to(device)

# 创建用于推理的数据加载器
y_dummy = torch.tensor([0]*num_ads, dtype=torch.float).unsqueeze(1).to(device)
inf_dataset = TensorDataset(X_inf_sparse, X_inf_dense, y_dummy)
inf_loader = DataLoader(inf_dataset, batch_size=num_ads, shuffle=True)

接下来,我们创建一个函数,该函数返回具有最高点击概率的张量的索引。

def recommend():
    with torch.no_grad():
        for i, (x_sparse, x_dense, y) in enumerate(tqdm(inf_loader)):
            x_sparse = x_sparse.to(device)
            x_dense = x_dense.to(device)
            logits = model(x_sparse, x_dense)
            probs = torch.sigmoid(logits)
    print(probs)
    return torch.max(probs, dim=0).indices[0].item()

调用这个函数将得到最佳广告候选项的索引。

print('Best ad candidate is ad', recommend())

100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 1/1 [00:00<00:00, 315.29it/s]
tensor([[0.1380],
        [0.2414],
        [0.3493],
        [0.3500],
        [0.1807],
        [0.3009],
        [0.2203],
        [0.3639],
        [0.1890],
        [0.3702]], device='cuda:0')
Best ad candidate is ad 9

我们鼓励用户进一步探索优化和超参数调优,以提高模型性能。例如,可以尝试在底部和顶部MLP中添加更多层和隐藏单元,增加嵌入维度,或者包括正则化如dropout以防止过拟合。 

讨论

在这篇博客中,我们开发了一个仅用于教育目的的小规模模型。然而,在实际应用中,模型的规模要大得多。因此,高效地并行化这些模型以应对真实世界中的挑战是至关重要的。

对于DLRM模型,大多数模型参数来自embedding表,这使得在实际应用中很难实现数据并行性,因为我们需要在每个设备上复制这些表。因此,我们需要高效地将模型分布到多个设备上以解决内存限制的问题。另一方面,底层和顶层的MLP(多层感知器)参数较少,我们可以应用数据并行性,在不同设备上同时处理多个样本。

关于如何高效地实现DLRM模型的数据并行和模型并行的更多细节,请参阅原始论文《paper》以及Meta发布的这个开源库《repo》。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

109702008

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值