{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":75254,"databundleVersionId":8467119,"isSourceIdPinned":false}],"dockerImageVersionId":31328,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import sys\nprint(\"Python:\", sys.version)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T07:54:53.825846Z","iopub.execute_input":"2026-05-09T07:54:53.826269Z","iopub.status.idle":"2026-05-09T07:54:53.835515Z","shell.execute_reply.started":"2026-05-09T07:54:53.826229Z","shell.execute_reply":"2026-05-09T07:54:53.834277Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip install -q torch-geometric","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T07:54:53.837206Z","iopub.execute_input":"2026-05-09T07:54:53.837569Z","iopub.status.idle":"2026-05-09T07:55:00.610380Z","shell.execute_reply.started":"2026-05-09T07:54:53.837544Z","shell.execute_reply":"2026-05-09T07:55:00.609591Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport pickle as pkl\nimport random\n\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nimport tqdm\n\nfrom torch_geometric.data import HeteroData\n\ndevice = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')\nprint('torch:', torch.__version__)\nprint('torch cuda:', torch.version.cuda)\nprint('cuda available:', torch.cuda.is_available())\nprint('device:', device)\n\ndef set_seed(seed=0):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    if torch.cuda.is_available():\n        torch.cuda.manual_seed_all(seed)\n\nset_seed(0)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T07:55:00.611774Z","iopub.execute_input":"2026-05-09T07:55:00.612046Z","iopub.status.idle":"2026-05-09T07:55:20.937861Z","shell.execute_reply.started":"2026-05-09T07:55:00.612017Z","shell.execute_reply":"2026-05-09T07:55:20.937050Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\nimport os\nimport pickle as pkl\nimport numpy as np\n\n\ndef read_txt(file):\n    res_list = []\n    with open(file, \"r\") as f:\n        for line in f:\n            res_list.append(list(map(int, line.strip().split())))\n    return res_list\n\nbase_path = \"/kaggle/input/competitions/cs3319-01-project-2-recommendation-2024-spring\"\n\ncite_file = \"paper_file_ann.txt\"\ntrain_ref_file = \"bipartite_train_ann.txt\"\ntest_ref_file = \"bipartite_test_ann.txt\"\ncoauthor_file = \"author_file_ann.txt\"\nfeature_file = \"feature.pkl\"\n\ncitation = read_txt(os.path.join(base_path, cite_file))\nexisting_refs = read_txt(os.path.join(base_path, train_ref_file))\nrefs_to_pred = read_txt(os.path.join(base_path, test_ref_file))\ncoauthor = read_txt(os.path.join(base_path, coauthor_file))\n\nwith open(os.path.join(base_path, feature_file), 'rb') as f:\n    paper_feature = pkl.load(f)\n\nprint(\"base_path:\", base_path)\nprint(f\"Number of citation edges: {len(citation)}\")\nprint(f\"Number of existing references: {len(existing_refs)}\")\nprint(f\"Number of author-paper pairs to predict: {len(refs_to_pred)}\")\nprint(f\"Number of coauthor edges: {len(coauthor)}\")\nprint(f\"Shape of paper features: {paper_feature.shape}\")\n","metadata":{"execution":{"iopub.status.busy":"2026-05-09T07:55:20.939904Z","iopub.execute_input":"2026-05-09T07:55:20.940348Z","iopub.status.idle":"2026-05-09T07:55:27.998413Z","shell.execute_reply.started":"2026-05-09T07:55:20.940317Z","shell.execute_reply":"2026-05-09T07:55:27.997670Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\n\ncite_edges = pd.DataFrame(citation, columns=['source', 'target'])\ncite_edges = cite_edges.set_index(\n    \"c-\" + cite_edges.index.astype(str)\n)\n\nref_edges = pd.DataFrame(existing_refs, columns=['source', 'target'])\nref_edges = ref_edges.set_index(\n    \"r-\" + ref_edges.index.astype(str)\n)\n\ncoauthor_edges = pd.DataFrame(coauthor, columns=['source', 'target'])\ncoauthor_edges = coauthor_edges.set_index(\n    \"a-\" + coauthor_edges.index.astype(str)\n)\n\ncite_edges.head()","metadata":{"execution":{"iopub.status.busy":"2026-05-09T07:55:27.999401Z","iopub.execute_input":"2026-05-09T07:55:28.000039Z","iopub.status.idle":"2026-05-09T07:55:28.959828Z","shell.execute_reply.started":"2026-05-09T07:55:28.000003Z","shell.execute_reply":"2026-05-09T07:55:28.959171Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"node_tmp = pd.concat([cite_edges.loc[:, 'source'], cite_edges.loc[:, 'target'], ref_edges.loc[:, 'target']])\nnode_papers = pd.DataFrame(index=pd.unique(node_tmp))\n\nnode_tmp = pd.concat([ref_edges['source'], coauthor_edges['source'], coauthor_edges['target']])\nnode_authors = pd.DataFrame(index=pd.unique(node_tmp))\n\nprint(\"Number of paper nodes: {}, number of author nodes: {}\".format(len(node_papers), len(node_authors)))","metadata":{"execution":{"iopub.status.busy":"2026-05-09T07:55:28.961128Z","iopub.execute_input":"2026-05-09T07:55:28.961540Z","iopub.status.idle":"2026-05-09T07:55:29.045425Z","shell.execute_reply.started":"2026-05-09T07:55:28.961504Z","shell.execute_reply":"2026-05-09T07:55:29.044410Z"},"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Build train/test edges.\n\ntrain_refs = ref_edges.sample(frac=0.9, random_state=0, axis=0)\ntest_true_refs = ref_edges[~ref_edges.index.isin(train_refs.index)].copy()\ntest_true_refs.loc[:, 'label'] = 1\n\nexisting_ref_set = set(map(tuple, ref_edges[['source', 'target']].to_numpy().tolist()))\nnum_test_pos = len(test_true_refs)\nauthor_ids = node_authors.index.to_numpy(dtype=np.int64)\npaper_ids = node_papers.index.to_numpy(dtype=np.int64)\n\nneg_pairs = []\nrng = np.random.default_rng(0)\nwhile len(neg_pairs) < num_test_pos:\n    src = int(rng.choice(author_ids))\n    dst = int(rng.choice(paper_ids))\n    if (src, dst) not in existing_ref_set:\n        neg_pairs.append((src, dst))\n\ntest_false_refs = pd.DataFrame(neg_pairs, columns=['source', 'target'])\ntest_false_refs.loc[:, 'label'] = 0\n\ntest_refs = pd.concat([test_true_refs, test_false_refs], ignore_index=True)\ntest_refs = test_refs.sample(frac=1, random_state=0, axis=0).reset_index(drop=True)\ntest_refs.head()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T07:55:29.046561Z","iopub.execute_input":"2026-05-09T07:55:29.046920Z","iopub.status.idle":"2026-05-09T07:55:32.104009Z","shell.execute_reply.started":"2026-05-09T07:55:29.046873Z","shell.execute_reply":"2026-05-09T07:55:32.103224Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"train_ref_tensor = torch.as_tensor(train_refs[['source', 'target']].to_numpy(), dtype=torch.long)\ncite_tensor = torch.as_tensor(cite_edges[['source', 'target']].to_numpy(), dtype=torch.long)\ncoauthor_tensor = torch.as_tensor(coauthor_edges[['source', 'target']].to_numpy(), dtype=torch.long)\n\ntest_ref_arr = np.array(refs_to_pred, dtype=np.int64) if len(refs_to_pred) > 0 else np.zeros((0, 2), dtype=np.int64)\nnum_authors = int(max(\n    ref_edges['source'].max(),\n    coauthor_edges['source'].max(),\n    coauthor_edges['target'].max(),\n    test_ref_arr[:, 0].max() if len(test_ref_arr) else 0,\n) + 1)\nnum_papers = int(max(\n    cite_edges['source'].max(),\n    cite_edges['target'].max(),\n    ref_edges['target'].max(),\n    test_ref_arr[:, 1].max() if len(test_ref_arr) else 0,\n    paper_feature.shape[0] - 1,\n) + 1)\n\npaper_x = torch.as_tensor(paper_feature, dtype=torch.float)\nif paper_x.size(0) < num_papers:\n    pad = torch.zeros(num_papers - paper_x.size(0), paper_x.size(1), dtype=paper_x.dtype)\n    paper_x = torch.cat([paper_x, pad], dim=0)\nelif paper_x.size(0) > num_papers:\n    paper_x = paper_x[:num_papers]\n\ndata = HeteroData()\ndata['author'].num_nodes = num_authors\ndata['paper'].x = paper_x\ndata['paper'].num_nodes = num_papers\n\n# author -> paper reference edges\ndata['author', 'ref', 'paper'].edge_index = train_ref_tensor.t().contiguous()\n# reverse relation for message passing from paper back to author\ndata['paper', 'beref', 'author'].edge_index = train_ref_tensor[:, [1, 0]].t().contiguous()\n\n# citation and coauthor edges are treated as undirected by adding reverse edges,\n# matching the original DGL code.\ndata['paper', 'cite', 'paper'].edge_index = torch.cat([\n    cite_tensor,\n    cite_tensor[:, [1, 0]],\n], dim=0).t().contiguous()\ndata['author', 'coauthor', 'author'].edge_index = torch.cat([\n    coauthor_tensor,\n    coauthor_tensor[:, [1, 0]],\n], dim=0).t().contiguous()\n\ndata = data.to(device)\nprint(data)\nprint('metadata:', data.metadata())\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T07:55:32.105133Z","iopub.execute_input":"2026-05-09T07:55:32.105698Z","iopub.status.idle":"2026-05-09T07:55:33.145824Z","shell.execute_reply.started":"2026-05-09T07:55:32.105664Z","shell.execute_reply":"2026-05-09T07:55:33.144780Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Optional: MetaPath2Vec pretraining","metadata":{}},{"cell_type":"code","source":"# Optional PyG MetaPath2Vec version. It is not required for the final model below.\n# from torch_geometric.nn import MetaPath2Vec\n#\n# metapath = [\n#     ('paper', 'cite', 'paper'),\n#     ('paper', 'beref', 'author'),\n#     ('author', 'ref', 'paper'),\n# ]\n# mp2v = MetaPath2Vec(\n#     data.edge_index_dict,\n#     embedding_dim=512,\n#     metapath=metapath,\n#     walk_length=3,\n#     context_size=2,\n#     walks_per_node=1,\n#     num_negative_samples=5,\n#     sparse=True,\n# ).to(device)\n# loader = mp2v.loader(batch_size=8192, shuffle=True, num_workers=0)\n# optimizer = torch.optim.SparseAdam(list(mp2v.parameters()), lr=0.025)\n#\n# for epoch in range(100):\n#     total_loss = 0\n#     for pos_rw, neg_rw in tqdm.tqdm(loader):\n#         optimizer.zero_grad()\n#         loss = mp2v.loss(pos_rw.to(device), neg_rw.to(device))\n#         loss.backward()\n#         optimizer.step()\n#         total_loss += float(loss)\n#     print(f'Epoch {epoch:03d}, loss={total_loss / len(loader):.4f}')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T07:55:33.147057Z","iopub.execute_input":"2026-05-09T07:55:33.147431Z","iopub.status.idle":"2026-05-09T07:55:33.152217Z","shell.execute_reply.started":"2026-05-09T07:55:33.147382Z","shell.execute_reply":"2026-05-09T07:55:33.151083Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n\nclass HeteroMeanConv(nn.Module):\n    \"\"\"A lightweight hetero mean-aggregation layer implemented with pure PyTorch.\n\n    The layer consumes PyG's HeteroData / edge_index_dict format but avoids compiled\n    PyG extensions. For efficiency, it first averages source node features at each\n    destination node and then applies relation-specific linear projections.\n    \"\"\"\n    def __init__(self, metadata, in_dims, out_dim):\n        super().__init__()\n        node_types, edge_types = metadata\n        self.node_types = list(node_types)\n        self.edge_types = list(edge_types)\n        self.rel_lins = nn.ModuleDict({\n            self._key(edge_type): nn.Linear(in_dims[edge_type[0]], out_dim, bias=False)\n            for edge_type in self.edge_types\n        })\n        self.self_lins = nn.ModuleDict({\n            node_type: nn.Linear(in_dims[node_type], out_dim)\n            for node_type in self.node_types\n        })\n\n    @staticmethod\n    def _key(edge_type):\n        return '__'.join(edge_type)\n\n    def reset_parameters(self):\n        for layer in self.rel_lins.values():\n            layer.reset_parameters()\n        for layer in self.self_lins.values():\n            layer.reset_parameters()\n\n    def forward(self, x_dict, edge_index_dict, num_nodes_dict):\n        out_dict = {\n            node_type: self.self_lins[node_type](x_dict[node_type])\n            for node_type in self.node_types\n        }\n        rel_count = {node_type: 1 for node_type in self.node_types}  # self term\n\n        for edge_type, edge_index in edge_index_dict.items():\n            src_type, _, dst_type = edge_type\n            src, dst = edge_index\n            src_x = x_dict[src_type]\n            agg = src_x.new_zeros((num_nodes_dict[dst_type], src_x.size(-1)))\n            deg = src_x.new_zeros((num_nodes_dict[dst_type], 1))\n            agg.index_add_(0, dst, src_x[src])\n            deg.index_add_(\n                0,\n                dst,\n                torch.ones((dst.numel(), 1), dtype=src_x.dtype, device=src_x.device),\n            )\n            agg = agg / deg.clamp(min=1.0)\n            out_dict[dst_type] = out_dict[dst_type] + self.rel_lins[self._key(edge_type)](agg)\n            rel_count[dst_type] += 1\n\n        return {\n            node_type: out_dict[node_type] / rel_count[node_type]\n            for node_type in self.node_types\n        }\n\n\nclass HeteroRecommender(nn.Module):\n    def __init__(self, metadata, paper_in_dim, hidden_dim=64, out_dim=10, author_in_dim=512):\n        super().__init__()\n        self.author_emb = nn.Embedding(num_authors, author_in_dim)\n        self.paper_lin = nn.Linear(paper_in_dim, author_in_dim)\n        self.num_nodes_dict = {\n            'author': num_authors,\n            'paper': num_papers,\n        }\n\n        self.conv1 = HeteroMeanConv(\n            metadata,\n            in_dims={'author': author_in_dim, 'paper': author_in_dim},\n            out_dim=hidden_dim,\n        )\n        self.conv2 = HeteroMeanConv(\n            metadata,\n            in_dims={'author': hidden_dim, 'paper': hidden_dim},\n            out_dim=out_dim,\n        )\n\n        self.reset_parameters()\n\n    def reset_parameters(self):\n        nn.init.xavier_uniform_(self.author_emb.weight)\n        self.paper_lin.reset_parameters()\n        self.conv1.reset_parameters()\n        self.conv2.reset_parameters()\n\n    def encode(self, data):\n        x_dict = {\n            'author': self.author_emb.weight,\n            'paper': self.paper_lin(data['paper'].x),\n        }\n        x_dict = self.conv1(x_dict, data.edge_index_dict, self.num_nodes_dict)\n        x_dict = {k: F.relu(v) for k, v in x_dict.items()}\n        x_dict = self.conv2(x_dict, data.edge_index_dict, self.num_nodes_dict)\n        return x_dict\n\n    def decode(self, z_dict, edge_label_index):\n        src, dst = edge_label_index\n        return (z_dict['author'][src] * z_dict['paper'][dst]).sum(dim=-1)\n\n\ndef sample_negative_edges(num_samples, num_authors, num_papers, existing_edges, device):\n    # Ensures sampled pairs are not known positive refs.\n    neg_edges = []\n    while len(neg_edges) < num_samples:\n        need = num_samples - len(neg_edges)\n        src = torch.randint(0, num_authors, (need * 2,), device='cpu')\n        dst = torch.randint(0, num_papers, (need * 2,), device='cpu')\n        for s, d in zip(src.tolist(), dst.tolist()):\n            if (s, d) not in existing_edges:\n                neg_edges.append((s, d))\n                if len(neg_edges) == num_samples:\n                    break\n    return torch.tensor(neg_edges, dtype=torch.long, device=device).t().contiguous()\n\n\nmodel = HeteroRecommender(\n    data.metadata(),\n    paper_in_dim=data['paper'].x.size(-1),\n    hidden_dim=64,\n    out_dim=10,\n    author_in_dim=512,\n).to(device)\n\noptimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=1e-5)\n\npos_edge_index = data['author', 'ref', 'paper'].edge_index\nexisting_train_set = set(map(tuple, train_refs[['source', 'target']].to_numpy().tolist()))\n\n# Each epoch performs one full-graph message-passing pass, then trains on a sampled\n# set of positive/negative author-paper edges.\ntrain_edge_batch_size = min(32768, pos_edge_index.size(1))\nnum_epochs = 50\n\nfor epoch in range(num_epochs):\n    model.train()\n    optimizer.zero_grad()\n\n    batch_perm = torch.randperm(pos_edge_index.size(1), device=device)[:train_edge_batch_size]\n    pos_batch = pos_edge_index[:, batch_perm]\n    neg_batch = sample_negative_edges(\n        pos_batch.size(1), num_authors, num_papers, existing_train_set, device\n    )\n\n    z_dict = model.encode(data)\n    pos_score = model.decode(z_dict, pos_batch)\n    neg_score = model.decode(z_dict, neg_batch)\n\n    loss = (1.0 - pos_score + neg_score).clamp(min=0).mean()\n    loss.backward()\n    optimizer.step()\n\n    print(f'Epoch {epoch:03d}, loss={loss.item():.4f}')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T07:57:32.293288Z","iopub.execute_input":"2026-05-09T07:57:32.293729Z","iopub.status.idle":"2026-05-09T07:57:44.898495Z","shell.execute_reply.started":"2026-05-09T07:57:32.293695Z","shell.execute_reply":"2026-05-09T07:57:44.897532Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n# Reuse the latest graph embeddings from training to avoid an additional full-graph\n# forward pass. If this cell is run independently, it falls back to recomputing them.\nwith torch.no_grad():\n    model.eval()\n    if 'z_dict' in globals():\n        node_embeddings = {k: v.detach().cpu() for k, v in z_dict.items()}\n    else:\n        node_embeddings = model.encode(data)\n        node_embeddings = {k: v.detach().cpu() for k, v in node_embeddings.items()}\n    print(node_embeddings['author'].shape, node_embeddings['paper'].shape)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T07:57:48.612868Z","iopub.execute_input":"2026-05-09T07:57:48.614003Z","iopub.status.idle":"2026-05-09T07:57:48.621794Z","shell.execute_reply.started":"2026-05-09T07:57:48.613928Z","shell.execute_reply":"2026-05-09T07:57:48.620798Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from numpy.linalg import norm\n\n# Use cosine similarity to measure recommendation probability.\ndef cos_sim(a, b, eps=1e-12):\n    return np.sum(a * b, axis=1) / (norm(a, axis=1) * norm(b, axis=1) + eps)\n\ntest_arr = test_refs[['source', 'target']].to_numpy(dtype=np.int64)\nres = cos_sim(\n    node_embeddings['author'][test_arr[:, 0]].numpy(),\n    node_embeddings['paper'][test_arr[:, 1]].numpy(),\n)\n\nlbl_true = test_refs['label'].to_numpy().flatten()\n\nimport matplotlib.pyplot as plt\nfrom sklearn.metrics import precision_recall_curve, PrecisionRecallDisplay\n\nprecision, recall, _ = precision_recall_curve(lbl_true, np.array(res))\ndisp = PrecisionRecallDisplay(precision=precision, recall=recall)\ndisp.plot()\nplt.show()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T07:57:53.601407Z","iopub.execute_input":"2026-05-09T07:57:53.601723Z","iopub.status.idle":"2026-05-09T07:57:53.784681Z","shell.execute_reply.started":"2026-05-09T07:57:53.601693Z","shell.execute_reply":"2026-05-09T07:57:53.783757Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.metrics import f1_score\n\nlbl_pred = np.array(res).copy()\nthreshold = 0.1\nlbl_pred[lbl_pred >= threshold] = 1\nlbl_pred[lbl_pred < threshold] = 0\n\nprint('F1-Score: {:.3f}'.format(f1_score(lbl_true, lbl_pred)))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T08:00:45.669051Z","iopub.execute_input":"2026-05-09T08:00:45.669855Z","iopub.status.idle":"2026-05-09T08:00:45.691103Z","shell.execute_reply.started":"2026-05-09T08:00:45.669792Z","shell.execute_reply":"2026-05-09T08:00:45.690339Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n# Output your prediction\nif 'threshold' not in globals():\n    threshold = 0.5\nif 'cos_sim' not in globals():\n    from numpy.linalg import norm\n    def cos_sim(a, b, eps=1e-12):\n        return np.sum(a * b, axis=1) / (norm(a, axis=1) * norm(b, axis=1) + eps)\n\noutput_path = '/kaggle/working/Submission.csv'\n\ntest_arr = np.array(refs_to_pred, dtype=np.int64)\nres = cos_sim(\n    node_embeddings['author'][test_arr[:, 0]].numpy(),\n    node_embeddings['paper'][test_arr[:, 1]].numpy(),\n)\nres[res >= threshold] = 1\nres[res < threshold] = 0\n\ndata_out = []\nfor index, p in enumerate(list(res)):\n    data_out.append([index, str(int(p))])\n\ndf = pd.DataFrame(data_out, columns=['Index', 'Predicted'], dtype=object)\ndf.to_csv(output_path, index=False)\nprint('Saved to:', output_path)\ndf.head()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-09T08:00:53.087833Z","iopub.execute_input":"2026-05-09T08:00:53.088283Z","iopub.status.idle":"2026-05-09T08:00:58.017422Z","shell.execute_reply.started":"2026-05-09T08:00:53.088246Z","shell.execute_reply":"2026-05-09T08:00:58.016580Z"}},"outputs":[],"execution_count":null}]}