Implicit-feedback dataset

Neural Collaborative Filtering for Personalized Ranking

Neural Matrix Factorization

NeuMF (He et al., 2017) — neural collaborative filtering with implicit feedback. Two parallel pathways fed into one prediction:

  • GMF (Generalized Matrix Factorization) — element-wise product of user and item embeddings. The “linear” pathway.
  • MLP — concat of user and item embeddings, fed through a fully connected MLP. The “nonlinear” pathway.

Concatenate the two pathway outputs and project to a scalar score. Train with BPR loss + sampled negatives.

\mathcal{L}_{BPR} = -\sum_{(u,i,j)} \log \sigma(\hat y_{ui} - \hat y_{uj}), \quad j \notin I_u^+.

This deck pulls together: NeuMF model + a custom dataset with negative sampling + leave-one-out ranking evaluator (Hit@50, AUC) — the recommender-systems evaluation classic.

Model architecture

Two embedding tables per side (one for GMF, one for MLP); elementwise product on one side, concat→MLP on the other; final concat → linear → sigmoid score:

NeuMF architecture: GMF and MLP pathways are fused before scoring a user-item pair.

class NeuMF(nn.Block):
    def __init__(self, num_factors, num_users, num_items, nums_hiddens):
        super().__init__()
        self.P = nn.Embedding(num_users, num_factors)
        self.Q = nn.Embedding(num_items, num_factors)
        self.U = nn.Embedding(num_users, num_factors)
        self.V = nn.Embedding(num_items, num_factors)
        self.mlp = nn.Sequential()
        for num_hiddens in nums_hiddens:
            self.mlp.add(nn.Dense(num_hiddens, activation='relu',
                                  use_bias=True))
        # Output raw logits; BPRLoss applies sigmoid internally, so adding
        # a sigmoid here would compose with it and squash gradients.
        self.prediction_layer = nn.Dense(1, use_bias=False)

    def forward(self, user_id, item_id):
        p_mf = self.P(user_id)
        q_mf = self.Q(item_id)
        gmf = p_mf * q_mf
        p_mlp = self.U(user_id)
        q_mlp = self.V(item_id)
        mlp = self.mlp(np.concatenate([p_mlp, q_mlp], axis=1))
        con_res = np.concatenate([gmf, mlp], axis=1)
        return self.prediction_layer(con_res)

Each training instance: a (user, positive item) plus sampled negatives. The dataset class handles negative sampling on the fly:

class PRDataset(gluon.data.Dataset):
    def __init__(self, users, items, candidates, num_items, test_items=None):
        self.users = users
        self.items = items
        # Precompute each user's negative pool once: items the user has not
        # interacted with in train AND not held out as a test positive
        # (excluding test positives prevents leakage into the BPR loss).
        all_items = set(range(num_items))
        test_items = test_items or {}
        self.neg_pool = {
            u: list(all_items - set(candidates.get(u, [])) - set(test_items.get(u, [])))
            for u in candidates}

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

    def __getitem__(self, idx):
        neg_items = self.neg_pool[int(self.users[idx])]
        indices = random.randint(0, len(neg_items) - 1)
        return self.users[idx], self.items[idx], neg_items[indices]

Hit@50 and AUC

Standard ranking metrics:

  • Hit@50 — does the held-out positive land in the top 50 recommendations?
  • AUC — is the held-out positive ranked above the unobserved items? This is a pairwise ranking view, not a calibrated-rating metric.

\textrm{Hit@}K = \mathbf{1}\{\textrm{rank}(i^+) \le K\}.

def hit_and_auc(rankedlist, test_matrix, k):
    hits_k = [(idx, val) for idx, val in enumerate(rankedlist[:k])
              if val in set(test_matrix)]
    hits_all = [(idx, val) for idx, val in enumerate(rankedlist)
                if val in set(test_matrix)]
    max = len(rankedlist) - 1
    auc = 1.0 * (max - hits_all[0][0]) / max if len(hits_all) > 0 else 0
    return len(hits_k), auc
def evaluate_ranking(net, test_input, seq, candidates, num_users, num_items,
                     devices):
    ranked_list, ranked_items, hit_rate, auc = {}, {}, [], []
    all_items = set([i for i in range(num_items)])
    for u in range(num_users):
        neg_items = list(all_items - set(candidates[int(u)]))
        user_ids, item_ids, x, scores = [], [], [], []
        [item_ids.append(i) for i in neg_items]
        [user_ids.append(u) for _ in neg_items]
        x.extend([np.array(user_ids)])
        if seq is not None:
            x.append(seq[user_ids, :])
        x.extend([np.array(item_ids)])
        test_data_iter = gluon.data.DataLoader(
            gluon.data.ArrayDataset(*x), shuffle=False, last_batch="keep",
            batch_size=1024)
        for index, values in enumerate(test_data_iter):
            x = [gluon.utils.split_and_load(v, devices, even_split=False)
                 for v in values]
            scores.extend([list(net(*t).asnumpy()) for t in zip(*x)])
        scores = [item for sublist in scores for item in sublist]
        item_scores = list(zip(item_ids, scores))
        ranked_list[u] = sorted(item_scores, key=lambda t: t[1], reverse=True)
        ranked_items[u] = [r[0] for r in ranked_list[u]]
        temp = hit_and_auc(ranked_items[u], test_input[u], 50)
        hit_rate.append(temp[0])
        auc.append(temp[1])
    return np.mean(np.array(hit_rate)), np.mean(np.array(auc))

Training helper

BPR loss + Adam. Each minibatch contains a user, one positive item, and one sampled negative item; the update increases the positive score relative to the negative score:

def train_ranking(net, train_iter, test_iter, loss, trainer, test_seq_iter,
                  num_users, num_items, num_epochs, devices, evaluator,
                  candidates, eval_step=1):
    timer, hit_rate, auc = d2l.Timer(), 0, 0
    animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs], ylim=[0, 1],
                            legend=['test hit rate', 'test AUC'])
    for epoch in range(num_epochs):
        metric = d2l.Accumulator(3)
        for i, values in enumerate(train_iter):
            input_data = []
            for v in values:
                input_data.append(gluon.utils.split_and_load(v, devices))
            with autograd.record():
                p_pos = [net(*t) for t in zip(*input_data[:-1])]
                p_neg = [net(*t) for t in zip(*input_data[:-2],
                                              input_data[-1])]
                ls = [loss(p, n) for p, n in zip(p_pos, p_neg)]
            [l.backward(retain_graph=False) for l in ls]
            # Per-batch loss only; accumulating across batches inside `l`
            # turned the printed train-loss into a quadratic sum.
            batch_loss = sum([l.asnumpy() for l in ls]).mean()
            trainer.step(values[0].shape[0])
            metric.add(batch_loss, values[0].shape[0], values[0].size)
            timer.stop()
        with autograd.predict_mode():
            if (epoch + 1) % eval_step == 0:
                hit_rate, auc = evaluator(net, test_iter, test_seq_iter,
                                          candidates, num_users, num_items,
                                          devices)
                animator.add(epoch + 1, (hit_rate, auc))
    print(f'train loss {metric[0] / metric[1]:.3f}, '
          f'test hit rate {float(hit_rate):.3f}, test AUC {float(auc):.3f}')
    print(f'{metric[2] * num_epochs / timer.sum():.1f} examples/sec '
          f'on {str(devices)}')

Implicit MovieLens split

Binarize MovieLens ratings into implicit feedback, then hold out each user’s latest interaction for leave-one-out ranking:

batch_size = 1024
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_iter = gluon.data.DataLoader(
    PRDataset(users_train, items_train, candidates, num_items,
              test_items=test_iter), batch_size,
    True, last_batch="rollover", num_workers=d2l.get_dataloader_workers())

Model initialization

The model uses separate GMF and MLP embeddings. Initialization matters because a saturated sigmoid head can erase BPR gradients:

devices = d2l.try_all_gpus()
net = NeuMF(10, num_users, num_items, nums_hiddens=[10, 10, 10])
# Use Xavier so MLP weights are large enough to escape the sigmoid plateau;
# Normal(0.01) leaves the prediction layer's pre-sigmoid output near zero
# (since MX Dense biases are zeros), and BPR gradients then vanish.
net.initialize(ctx=devices, force_reinit=True, init=mx.init.Xavier())

Training and Metrics

The final printout should be read as ranking quality: higher Hit@50 and AUC mean the held-out item is placed above more unobserved candidates. They are not rating-prediction metrics:

lr, num_epochs, wd, optimizer = 0.01, 10, 1e-5, 'adam'
loss = d2l.BPRLoss()
trainer = gluon.Trainer(net.collect_params(), optimizer,
                        {"learning_rate": lr, 'wd': wd})
train_ranking(net, train_iter, test_iter, loss, trainer, None, num_users,
              num_items, num_epochs, devices, evaluate_ranking, candidates,
              eval_step=5)

Recap

  • NeuMF = GMF (elementwise product) + MLP (concat) → fused score.
  • Implicit-feedback training with BPR + negative sampling.
  • Hit@50 / AUC match ranking behavior; RMSE is a poor target when zeros mostly mean “unobserved”, not explicit dislike.
  • A standard reference for “how to combine MF and an MLP”; keep it conceptually separate from CTR architectures such as DeepFM and AutoInt, which score feature-rich impressions.