16.7. Hệ thống Đề xuất có Nhận thức về Chuỗi

Trong phần trước, ta trừu tượng hóa tác vụ đề xuất dưới dạng một bài toán hoàn thiện ma trận mà không xét đến hành vi ngắn hạn của người dùng. Trong phần này, chúng tôi sẽ giới thiệu một mô hình đề xuất cân nhắc đến nhật ký tương tác được sắp xếp theo trình tự thời gian của người dùng. Đây là một hệ thống đề xuất có nhận thức về chuỗi (sequence-aware recommender) [Quadrana et al., 2018] với đầu vào là danh sách lịch sử thao tác của người dùng đã được sắp xếp và thường đi kèm với mốc thời gian diễn ra. Nhiều bài báo gần đây đã chứng minh được lợi ích của việc tích hợp những thông tin này vào việc mô hình hóa khuôn mẫu hành vi theo thời gian của người dùng và tìm ra được khuynh hướng sở thích của họ.

Mô hình mà chúng tôi sẽ giới thiệu, Caser [Tang & Wang, 2018], viết tắt của mô hình đề xuất embedding chuỗi tích chập (convolutional sequence embedding recommendation model), kế thừa mạng nơ-ron tích chập nhằm nắm bắt khuôn mẫu động có ảnh hưởng đến những hoạt động gần đây của người dùng. Thành phần chính của Caser bao gồm một mạng tích chập ngang và một mạng tích chập dọc, nhằm lần lượt khám phá khuôn mẫu cấp liên kết (union-level) và cấp điểm (point-level) của chuỗi. Khuôn mẫu cấp điểm ám chỉ tác động của một sản phẩm riêng lẻ trong lịch sử của chuỗi lên sản phẩm mục tiêu, trong khi khuôn mẫu cấp liên kết ám chỉ ảnh hưởng của nhiều thao tác trước đó lên các mục tiêu kế tiếp. Ví dụ, việc mua sữa cùng với bơ dẫn tới xác suất mua thêm cả bột mì cao hơn so với việc chỉ mua một trong hai. Hơn nữa, sở thích chung của người dùng, hay sở thích dài hạn cũng được mô hình hóa trong những tầng kết nối đầy đủ cuối cùng, dẫn đến sở thích của người dùng được mô hình hóa một cách toàn diện hơn. Chi tiết về mô hình này sẽ được mô tả tiếp theo.

16.7.1. Kiến trúc Mô hình

Trong hệ thống đề xuất có nhận thức về chuỗi, mỗi người dùng tương tác với một chuỗi các sản phẩm từ tập sản phẩm. \(S^u = (S_1^u, ... S_{|S_u|}^u)\) ký hiệu chuỗi có trình tự. Mục tiêu của Caser là đề xuất sản phẩm bằng cách xét thị hiếu chung của người dùng cũng như dự định ngắn hạn. Giả sử ta xét \(L\) sản phẩm trước, ma trận embedding biểu diễn những tương tác xảy ra trước bước thời gian \(t\) có thể được xây dựng như sau:

(16.7.1)\[\mathbf{E}^{(u, t)} = [ \mathbf{q}_{S_{t-L}^u} , ..., \mathbf{q}_{S_{t-2}^u}, \mathbf{q}_{S_{t-1}^u} ]^\top,\]

trong đó \(\mathbf{Q} \in \mathbb{R}^{n \times k}\) biểu diễn embedding sản phẩm và \(\mathbf{q}_i\) ký hiệu hàng thứ \(i\). \(\mathbf{E}^{(u, t)} \in \mathbb{R}^{L \times k}\) có thể được sử dụng để suy ra sở thích nhất thời của người dùng \(u\) tại bước thời gian \(t\). Ta có thể coi ma trận đầu vào \(\mathbf{E}^{(u, t)}\) như một ảnh đầu vào của hai tầng tích chập kế tiếp.

Tầng tích chập ngang có \(d\) bộ lọc ngang \(\mathbf{F}^j \in \mathbb{R}^{h \times k}, 1 \leq j \leq d, h = \{1, ..., L\}\), và tầng tích chập dọc có \(d'\) bộ lọc dọc \(\mathbf{G}^j \in \mathbb{R}^{ L \times 1}, 1 \leq j \leq d'\). Sau một chuỗi những thao tác tích chập và gộp, ta thu được hai đầu ra:

(16.7.2)\[\begin{split}\mathbf{o} = \text{HConv}(\mathbf{E}^{(u, t)}, \mathbf{F}) \\ \mathbf{o}'= \text{VConv}(\mathbf{E}^{(u, t)}, \mathbf{G}) ,\end{split}\]

trong đó \(\mathbf{o} \in \mathbb{R}^d\) là đầu ra của mạng tích chập ngang và \(\mathbf{o}' \in \mathbb{R}^{kd'}\) là đầu ra của mạng tích chập dọc. Để đơn giản, ta bỏ qua chi tiết của các thao tác tích chập và thao tác gộp. Chúng được nối với nhau và đưa vào một tầng nơ-ron kết nối đầy đủ để thu được dạng biểu diễn cấp cao hơn.

(16.7.3)\[\mathbf{z} = \phi(\mathbf{W}[\mathbf{o}, \mathbf{o}']^\top + \mathbf{b}),\]

trong đó \(\mathbf{W} \in \mathbb{R}^{k \times (d + kd')}\) là ma trận trọng số và \(\mathbf{b} \in \mathbb{R}^k\) là hệ số điều chỉnh. Vector học được \(\mathbf{z} \in \mathbb{R}^k\) chính là dạng biểu diễn cho sở thích ngắn hạn của người dùng.

Cuối cùng, hàm dự đoán kết hợp thị hiếu ngắn hạn và thị hiếu chung của người dùng với nhau, hàm này được định nghĩa:

(16.7.4)\[\hat{y}_{uit} = \mathbf{v}_i \cdot [\mathbf{z}, \mathbf{p}_u]^\top + \mathbf{b}'_i,\]

trong đó \(\mathbf{V} \in \mathbb{R}^{n \times 2k}\) là một ma trận embedding sản phẩm khác. \(\mathbf{b}' \in \mathbb{R}^n\) là độ chệch đặc thù của sản phẩm. \(\mathbf{P} \in \mathbb{R}^{m \times k}\) là ma trận embedding thị hiếu chung của người dùng. \(\mathbf{p}_u \in \mathbb{R}^{ k}\) là hàng thứ \(u\) của \(P\)\(\mathbf{v}_i \in \mathbb{R}^{2k}\) là hàng thứ \(i\) của \(\mathbf{V}\).

Mô hình này có thể được học với mất mát BPR hoặc mất mát Hinge. Kiến trúc của Caser được mô tả như dưới đây.

../_images/rec-caser.svg

Fig. 16.7.1 Minh họa Mô hình Caser.

Đầu tiên ta nhập vào những thư viện cần thiết.

from d2l import mxnet as d2l
from mxnet import gluon, np, npx
from mxnet.gluon import nn
import mxnet as mx
import random
import sys
npx.set_np()

16.7.2. Lập trình Mô hình

Đoạn mã dưới đây lập trình cho mô hình Caser. Nó bao gồm một tầng tích chập ngang, một tầng tích chập dọc, và một tầng kết nối đầy đủ.

class Caser(nn.Block):
    def __init__(self, num_factors, num_users, num_items, L=5, d=16,
                 d_prime=4, drop_ratio=0.05, **kwargs):
        super(Caser, self).__init__(**kwargs)
        self.P = nn.Embedding(num_users, num_factors)
        self.Q = nn.Embedding(num_items, num_factors)
        self.d_prime, self.d = d_prime, d
        # Vertical convolution layer
        self.conv_v = nn.Conv2D(d_prime, (L, 1), in_channels=1)
        # Horizontal convolution layer
        h = [i + 1 for i in range(L)]
        self.conv_h, self.max_pool = nn.Sequential(), nn.Sequential()
        for i in h:
            self.conv_h.add(nn.Conv2D(d, (i, num_factors), in_channels=1))
            self.max_pool.add(nn.MaxPool1D(L - i + 1))
        # Fully-connected layer
        self.fc1_dim_v, self.fc1_dim_h = d_prime * num_factors, d * len(h)
        self.fc = nn.Dense(in_units=d_prime * num_factors + d * L,
                           activation='relu', units=num_factors)
        self.Q_prime = nn.Embedding(num_items, num_factors * 2)
        self.b = nn.Embedding(num_items, 1)
        self.dropout = nn.Dropout(drop_ratio)

    def forward(self, user_id, seq, item_id):
        item_embs = np.expand_dims(self.Q(seq), 1)
        user_emb = self.P(user_id)
        out, out_h, out_v, out_hs = None, None, None, []
        if self.d_prime:
            out_v = self.conv_v(item_embs)
            out_v = out_v.reshape(out_v.shape[0], self.fc1_dim_v)
        if self.d:
            for conv, maxp in zip(self.conv_h, self.max_pool):
                conv_out = np.squeeze(npx.relu(conv(item_embs)), axis=3)
                t = maxp(conv_out)
                pool_out = np.squeeze(t, axis=2)
                out_hs.append(pool_out)
            out_h = np.concatenate(out_hs, axis=1)
        out = np.concatenate([out_v, out_h], axis=1)
        z = self.fc(self.dropout(out))
        x = np.concatenate([z, user_emb], axis=1)
        q_prime_i = np.squeeze(self.Q_prime(item_id))
        b = np.squeeze(self.b(item_id))
        res = (x * q_prime_i).sum(1) + b
        return res

16.7.3. Tập dữ liệu Tuần tự với phép Lấy mẫu Âm

Để xử lý dữ liệu tương tác tuần tự, ta cần lập trình lại lớp Dataset. Đoạn mã sau đây tạo một lớp dataset mới có tên là SeqDataset. Với mỗi mẫu, lớp này trả về id của người dùng, một chuỗi \(L\) sản phẩm mà người này đã tương tác trước đó và sản phẩm tiếp theo mà người này sẽ tương tác làm mục tiêu. Hình dưới đây mô tả quá trình nạp dữ liệu với một người dùng. Giả sử người dùng này thích 9 bộ phim, ta sắp xếp 9 bộ phim này theo thứ tự thời gian. Bộ phim cuối cùng được bỏ ra ngoài để làm sản phẩm kiểm tra. Với 8 bộ phim còn lại, ta có thể tạo ba mẫu huấn luyện, với mỗi mẫu bao gồm một chuỗi gồm năm (\(L=5\)) bộ phim và bộ phim kế tiếp làm mục tiêu. Các mẫu âm cũng có thể được đưa vào trong tập dữ liệu tuỳ chỉnh.

../_images/rec-seq-data.svg

Fig. 16.7.2 Minh họa quá trình sinh dữ liệu

class SeqDataset(gluon.data.Dataset):
    def __init__(self, user_ids, item_ids, L, num_users, num_items,
                 candidates):
        user_ids, item_ids = np.array(user_ids), np.array(item_ids)
        sort_idx = np.array(sorted(range(len(user_ids)),
                                   key=lambda k: user_ids[k]))
        u_ids, i_ids = user_ids[sort_idx], item_ids[sort_idx]
        temp, u_ids, self.cand = {}, u_ids.asnumpy(), candidates
        self.all_items = set([i for i in range(num_items)])
        [temp.setdefault(u_ids[i], []).append(i) for i, _ in enumerate(u_ids)]
        temp = sorted(temp.items(), key=lambda x: x[0])
        u_ids = np.array([i[0] for i in temp])
        idx = np.array([i[1][0] for i in temp])
        self.ns = ns = int(sum([c - L if c >= L + 1 else 1 for c
                                in np.array([len(i[1]) for i in temp])]))
        self.seq_items = np.zeros((ns, L))
        self.seq_users = np.zeros(ns, dtype='int32')
        self.seq_tgt = np.zeros((ns, 1))
        self.test_seq = np.zeros((num_users, L))
        test_users, _uid = np.empty(num_users), None
        for i, (uid, i_seq) in enumerate(self._seq(u_ids, i_ids, idx, L + 1)):
            if uid != _uid:
                self.test_seq[uid][:] = i_seq[-L:]
                test_users[uid], _uid = uid, uid
            self.seq_tgt[i][:] = i_seq[-1:]
            self.seq_items[i][:], self.seq_users[i] = i_seq[:L], uid

    def _win(self, tensor, window_size, step_size=1):
        if len(tensor) - window_size >= 0:
            for i in range(len(tensor), 0, - step_size):
                if i - window_size >= 0:
                    yield tensor[i - window_size:i]
                else:
                    break
        else:
            yield tensor

    def _seq(self, u_ids, i_ids, idx, max_len):
        for i in range(len(idx)):
            stop_idx = None if i >= len(idx) - 1 else int(idx[i + 1])
            for s in self._win(i_ids[int(idx[i]):stop_idx], max_len):
                yield (int(u_ids[i]), s)

    def __len__(self):
        return self.ns

    def __getitem__(self, idx):
        neg = list(self.all_items - set(self.cand[int(self.seq_users[idx])]))
        i = random.randint(0, len(neg) - 1)
        return (self.seq_users[idx], self.seq_items[idx], self.seq_tgt[idx],
                neg[i])

16.7.4. Nạp Tập dữ liệu MovieLens 100K

Kế tiếp, ta đọc và chia nhỏ tập dữ liệu MovieLens 100K ở chế độ nhận thức về chuỗi và nạp tập huấn luyện với bộ nạp dữ liệu tuần tự đã lập trình phía trên.

TARGET_NUM, L, batch_size = 1, 5, 4096
df, num_users, num_items = d2l.read_data_ml100k()
train_data, test_data = d2l.split_data_ml100k(df, num_users, num_items,
                                              'seq-aware')
users_train, items_train, ratings_train, candidates = d2l.load_data_ml100k(
    train_data, num_users, num_items, feedback="implicit")
users_test, items_test, ratings_test, test_iter = d2l.load_data_ml100k(
    test_data, num_users, num_items, feedback="implicit")
train_seq_data = SeqDataset(users_train, items_train, L, num_users,
                            num_items, candidates)
train_iter = gluon.data.DataLoader(train_seq_data, batch_size, True,
                                   last_batch="rollover",
                                   num_workers=d2l.get_dataloader_workers())
test_seq_iter = train_seq_data.test_seq
train_seq_data[0]
(array(0, dtype=int32),
 array([241., 170., 110., 255.,   4.]),
 array([101.]),
 1069)

Cấu trúc dữ liệu huấn luyện được chỉ ra như trên. Phần tử đầu tiên là id người dùng, kế tiếp là danh sách ba sản phẩm đầu tiên mà người dùng này thích (\(L=3\)), và phần tử cuối cùng là sản phẩm người dùng này thích sau ba sản phẩm trước.

16.7.5. Huấn luyện Mô hình

Giờ hãy cùng huấn luyện mô hình. Ta sử dụng thiết lập giống với NeuMF trong phần trước, bao gồm tốc độ học, bộ tối ưu, và \(k\), để có thể so sánh kết quả.

devices = d2l.try_all_gpus()
net = Caser(10, num_users, num_items, L)
net.initialize(ctx=devices, force_reinit=True, init=mx.init.Normal(0.01))
lr, num_epochs, wd, optimizer = 0.04, 8, 1e-5, 'adam'
loss = d2l.BPRLoss()
trainer = gluon.Trainer(net.collect_params(), optimizer,
                        {"learning_rate": lr, 'wd': wd})

d2l.train_ranking(net, train_iter, test_iter, loss, trainer, test_seq_iter,
                  num_users, num_items, num_epochs, devices,
                  d2l.evaluate_ranking, candidates, eval_step=1)
train loss 1.628, test hit rate 0.397, test AUC 0.754
66.2 examples/sec on [gpu(0)]
../_images/output_seqrec_vn_a01f2d_9_1.svg

16.7.6. Tóm tắt

  • Suy luận về sở thích ngắn hạn và dài hạn của một người dùng có thể giúp việc dự đoán sản phẩm tiếp theo người này thích trở nên hiệu quả hơn.
  • Mạng nơ-ron tích chập có thể được tận dụng để nắm bắt được sở thích ngắn hạn của người dùng từ chuỗi các tương tác.

16.7.7. Bài tập

  • Thực hiện một nghiên cứu loại bỏ (ablation study) bằng cách bỏ một trong hai mạng tích chập ngang hoặc dọc, thành phần nào quan trọng hơn?
  • Thay đổi siêu tham số \(L\). Liệu lịch sử tương tác dài hơn có giúp tăng độ chính xác?
  • Ngoài tác vụ đề xuất nhận thức về chuỗi như chúng tôi giới thiệu ở trên, có một loại tác vụ đề xuất nhận thức về chuỗi khác được gọi là đề xuất dựa theo phiên (session-based recommendation) [Hidasi et al., 2015]. Bạn có thể giải thích sự khác nhau giữa hai tác vụ này không?

16.7.8. Thảo luận

16.7.9. Những người thực hiện

Bản dịch trong trang này được thực hiện bởi:

  • Đoàn Võ Duy Thanh
  • Đỗ Trường Giang
  • Nguyễn Văn Cường
  • Phạm Hồng Vinh
  • Phạm Minh Đức
  • Nguyễn Lê Quang Nhật

Cập nhật lần cuối: 06/10/2020. (Cập nhật lần cuối từ nội dung gốc: 01/10/2020)