File size: 15,416 Bytes
6de06a8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
"""Multi-task GAT for joint indirect-control-flow prediction."""

import torch
import torch.nn as nn
import dgl
from common import (
    EDGE_TYPE_MAPPING,
    PaperHeteroGATLayer,
    add_paper_data_structural_features,
    build_paper_mtl_augmented_graph,
    extract_node_features,
)

class MultiTaskGAT(nn.Module):
    def __init__(
        self,
        code_feat_dim,
        data_feat_dim,
        hidden_dim=256,
        num_heads=8,
        shared_layers=4,
        task_layers=1,
        dropout=0.4,
        use_reverse_edges=False,
        task_weights=None,
        use_gch=True,
        use_gdh=True,
        task_aware_routing=True,
        gdh_radius=2,
        **checkpoint_compatibility,
    ):
        super(MultiTaskGAT, self).__init__()
        
        assert hidden_dim % num_heads == 0, "hidden_dim must be divisible by num_heads"
        
        self.edge_type_mapping = EDGE_TYPE_MAPPING
        self.code_feat_dim = code_feat_dim
        self.data_feat_dim = data_feat_dim
        self.shared_layers = shared_layers
        self.use_reverse_edges = use_reverse_edges
        self.use_gch = use_gch
        self.use_gdh = use_gdh
        self.task_aware_routing = task_aware_routing
        # Accept the redundant field written by older checkpoints.
        checkpoint_compatibility.pop("architecture_version", None)
        if checkpoint_compatibility:
            raise TypeError(
                "Unexpected model configuration keys: {}".format(
                    ", ".join(sorted(checkpoint_compatibility))
                )
            )
        self.gdh_radius = int(gdh_radius)
        self.task_layers = task_layers
        self.hidden_dim = hidden_dim
        self.num_heads = num_heads
        self.dropout = dropout
        
        # Task weights for loss balancing
        self.task_weights = task_weights or {et: 1.0 for et in self.edge_type_mapping}
        self.task_weights["edge_type"] = self.task_weights.get("edge_type", 1.0)
        
        # Input projections
        self.code_projection = nn.Linear(code_feat_dim, hidden_dim)
        projected_data_dim = data_feat_dim + 2 if data_feat_dim > 0 else data_feat_dim
        self.data_projection = nn.Linear(projected_data_dim, hidden_dim) if projected_data_dim > 0 else None
        self.hub_embedding = nn.Embedding(2, hidden_dim)
        
        # Shared encoder layers
        self.shared_encoder = nn.ModuleList([
            PaperHeteroGATLayer(
                hidden_dim,
                hidden_dim,
                num_heads,
                dropout,
                use_reverse_edges=use_reverse_edges,
            )
            for _ in range(shared_layers)
        ])
        
        # Task-specific encoder layers
        self.task_encoders = nn.ModuleDict({
            task: nn.ModuleList([
                PaperHeteroGATLayer(
                    hidden_dim,
                    hidden_dim,
                    num_heads,
                    dropout,
                    use_reverse_edges=use_reverse_edges,
                )
                for _ in range(task_layers)
            ]) for task in self.edge_type_mapping
        })
        
        # Both endpoint matching and edge-type prediction always use two-layer
        # MLPs. Their hidden width follows the encoder width, leaving no
        # alternative scorer architecture to configure.
        self.edge_heads = nn.ModuleDict({
            task: nn.Sequential(
                nn.Linear(hidden_dim * 2, hidden_dim),
                nn.GELU(),
                nn.Linear(hidden_dim, 1),
            )
            for task in self.edge_type_mapping
        })
        self.edge_type_head = nn.Sequential(
            nn.Linear(hidden_dim * 2, hidden_dim),
            nn.GELU(),
            nn.Linear(hidden_dim, len(self.edge_type_mapping)),
        )
    
    def encode(self, g, task=None):
        """Encode graph nodes with optional task-specific layers"""
        feat_dict = extract_node_features(g)
        feat_dict = add_paper_data_structural_features(g, feat_dict)
        h_dict = {
            'code': self.code_projection(feat_dict['code'])
        }
        if 'data' in feat_dict and self.data_projection is not None:
            h_dict['data'] = self.data_projection(feat_dict['data'])
        if 'hub' in g.ntypes:
            h_dict['hub'] = self.hub_embedding(g.nodes['hub'].data['hub_kind'].long())
        
        # Apply shared layers
        for layer in self.shared_encoder:
            h_dict = layer(g, h_dict)
        
        # Apply task-specific layers if specified
        if task is not None and task in self.task_encoders:
            for layer in self.task_encoders[task]:
                h_dict = layer(g, h_dict)
        
        return h_dict
    
    def forward(self, g, edge_pairs=None, candidate_meta=None, return_embeddings=False):
        """
        Forward pass supporting multiple input formats like SingleTaskGAT
        
        Args:
            g: DGL graph
            edge_pairs: Edge pairs to predict - can be:
                       - Tuple of (src_ids, dst_ids)
                       - Tensor of shape [num_edges, 2] where each row is [src, dst]
                       - Tensor of shape [2, num_edges] where first row is src, second is dst
            return_embeddings: If True, return embeddings along with predictions
        
        Returns:
            If edge_pairs is None: node embeddings
            If return_embeddings is True: (node_embeddings, edge_logits, edge_type_logits)
            Else: (edge_logits, edge_type_logits)
        """
        model_graph = build_paper_mtl_augmented_graph(
            g,
            candidate_meta,
            use_gch=self.use_gch,
            use_gdh=self.use_gdh,
            task_aware_routing=self.task_aware_routing,
            gdh_radius=self.gdh_radius,
        )

        # Run the shared encoder once on the unified graph.
        node_embs_shared = self.encode(model_graph)
        
        if edge_pairs is None:
            return node_embs_shared
        
        # FIXED: Handle different edge_pairs formats like SingleTaskGAT
        if isinstance(edge_pairs, tuple):
            # Format: (src_ids, dst_ids)
            src_ids, dst_ids = edge_pairs
        elif isinstance(edge_pairs, torch.Tensor):
            if edge_pairs.dim() == 2:
                if edge_pairs.shape[1] == 2:
                    # Format: [num_edges, 2] where each row is [src, dst]
                    src_ids = edge_pairs[:, 0]
                    dst_ids = edge_pairs[:, 1]
                elif edge_pairs.shape[0] == 2:
                    # Format: [2, num_edges] where first row is src, second is dst
                    src_ids = edge_pairs[0, :]
                    dst_ids = edge_pairs[1, :]
                else:
                    raise ValueError(f"Unexpected edge_pairs tensor shape: {edge_pairs.shape}")
            else:
                raise ValueError(f"Unexpected edge_pairs tensor dimensions: {edge_pairs.dim()}")
        else:
            raise ValueError(f"Unexpected edge_pairs type: {type(edge_pairs)}")
        
        # Shared embeddings for edge type classification
        shared_edge_embs = torch.cat([
            node_embs_shared['code'][src_ids], 
            node_embs_shared['code'][dst_ids]
        ], dim=1)
        
        # Task-specific edge predictions
        edge_logits = {}
        for task in self.edge_type_mapping:
            node_embs_task = dict(node_embs_shared)
            for layer in self.task_encoders[task]:
                node_embs_task = layer(model_graph, node_embs_task)
            task_edge_embs = torch.cat([
                node_embs_task['code'][src_ids], 
                node_embs_task['code'][dst_ids]
            ], dim=1)
            edge_logits[task] = self.edge_heads[task](task_edge_embs)
        
        # Edge type classification
        edge_type_logits = self.edge_type_head(shared_edge_embs)
        
        if return_embeddings:
            return node_embs_shared, edge_logits, edge_type_logits
        else:
            return edge_logits, edge_type_logits
    
    def get_loss(
        self,
        edge_logits,
        edge_labels,
        edge_types,
        pos_weight=1.0,
        edge_type_logits=None,
        negative_class_weights=None,
        hard_negative_focus=None,
    ):
        """Compute multi-task loss with proper weighting"""
        bce = nn.BCEWithLogitsLoss(reduction='none')
        total_loss = 0
        losses = {}
        
        for task_name, type_id in self.edge_type_mapping.items():
            # Each classifier consumes only its own positives and independently
            # sampled negatives, as required by Section 4.4.
            type_mask = (edge_types == type_id)
            if not type_mask.any():
                continue

            task_labels = edge_labels[type_mask].float()
            logits = edge_logits[task_name].reshape(-1)[type_mask]
            
            # Calculate class weights
            n_pos = task_labels.sum().float()
            n_neg = len(task_labels) - n_pos
            
            if n_pos > 0:
                weight = (n_neg / n_pos) * pos_weight
                loss = bce(logits, task_labels)
                loss[task_labels == 1] *= weight
            else:
                loss = bce(logits, task_labels)

            negative_mask = task_labels == 0
            if negative_mask.any():
                class_weight = float(
                    (negative_class_weights or {}).get(task_name, 1.0)
                )
                if class_weight != 1.0:
                    loss[negative_mask] *= class_weight
                focus = (hard_negative_focus or {}).get(task_name)
                if focus is not None:
                    alpha, gamma = map(float, focus)
                    negative_scores = torch.sigmoid(
                        logits[negative_mask].detach()
                    )
                    loss[negative_mask] *= (
                        1.0 + alpha * negative_scores.pow(gamma)
                    )
            
            losses[task_name] = loss.mean()
            total_loss += self.task_weights[task_name] * losses[task_name]
        
        # Edge type classification loss (only for positive edges)
        if edge_type_logits is not None:
            pos_mask = edge_labels == 1
            if pos_mask.sum() > 0:
                type_loss = nn.CrossEntropyLoss()(
                    edge_type_logits[pos_mask], 
                    edge_types[pos_mask]
                )
                losses["edge_type"] = type_loss
                total_loss += self.task_weights["edge_type"] * type_loss
        
        return total_loss, losses
    
    def predict_edges(self, g, edge_pairs, threshold=0.5, candidate_meta=None):
        """Predict edges for all tasks"""
        with torch.no_grad():
            edge_logits, edge_type_logits = self.forward(
                g,
                edge_pairs,
                candidate_meta=candidate_meta,
            )
            
            predictions = {}
            probabilities = {}
            
            for task in self.edge_type_mapping:
                probs = torch.sigmoid(edge_logits[task].squeeze())
                preds = (probs >= threshold)
                predictions[task] = preds
                probabilities[task] = probs
            
            # Edge type predictions
            type_probs = torch.softmax(edge_type_logits, dim=1)
            type_preds = torch.argmax(type_probs, dim=1)
            
            return predictions, probabilities, type_preds, type_probs
    
    def evaluate(self, g, pos_edges, pos_types, neg_edges, threshold=0.5, candidate_meta=None):
        """Evaluate model performance on all tasks"""
        # Handle different input formats like SingleTaskGAT
        if isinstance(pos_edges, torch.Tensor):
            pos_edges_tensor = pos_edges
        else:
            pos_edges_tensor = torch.tensor(pos_edges, dtype=torch.long, device=g.device)
            
        if isinstance(neg_edges, torch.Tensor):
            neg_edges_tensor = neg_edges
        else:
            neg_edges_tensor = torch.tensor(neg_edges, dtype=torch.long, device=g.device)
        
        # Combine all edges
        all_edges = torch.cat([pos_edges_tensor, neg_edges_tensor], dim=0)
        all_labels = torch.cat([
            torch.ones(len(pos_edges), device=g.device),
            torch.zeros(len(neg_edges), device=g.device)
        ])
        all_types = torch.cat([
            torch.tensor(pos_types, dtype=torch.long, device=g.device),
            torch.full((len(neg_edges),), -1, dtype=torch.long, device=g.device)
        ])
        
        # Get predictions
        predictions, probabilities, type_preds, type_probs = self.predict_edges(
            g,
            all_edges,
            threshold,
            candidate_meta=candidate_meta,
        )
        
        # Calculate metrics for each task
        results = {}
        for task_name, type_id in self.edge_type_mapping.items():
            type_gt = (all_types == type_id) & (all_labels == 1)
            task_preds = predictions[task_name]
            
            tp = (task_preds & type_gt).sum().item()
            fp = (task_preds & ~type_gt).sum().item()
            fn = (~task_preds & type_gt).sum().item()
            
            precision = tp / max(tp + fp, 1)
            recall = tp / max(tp + fn, 1)
            f1 = 2 * precision * recall / max(precision + recall, 1e-6)
            
            results[task_name] = {
                "precision": precision,
                "recall": recall,
                "f1": f1,
                "support": type_gt.sum().item()
            }
        
        return results
    
    def train_step(self, g, pos_edges, pos_types, neg_edges, optimizer, pos_weight=1.0, candidate_meta=None):
        """Training step with improved input handling"""
        # Handle different input formats like SingleTaskGAT
        if isinstance(pos_edges, torch.Tensor):
            pos_edges_tensor = pos_edges
        else:
            pos_edges_tensor = torch.tensor(pos_edges, dtype=torch.long, device=g.device)
            
        if isinstance(neg_edges, torch.Tensor):
            neg_edges_tensor = neg_edges
        else:
            neg_edges_tensor = torch.tensor(neg_edges, dtype=torch.long, device=g.device)
        
        # Combine edges - use [num_edges, 2] format
        edge_pairs = torch.cat([pos_edges_tensor, neg_edges_tensor], dim=0)
        
        edge_labels = torch.cat([
            torch.ones(len(pos_edges), device=g.device),
            torch.zeros(len(neg_edges), device=g.device)
        ])
        edge_types = torch.cat([
            torch.tensor(pos_types, device=g.device),
            torch.full((len(neg_edges),), -1, dtype=torch.long, device=g.device)
        ])
        
        # Forward pass
        edge_logits, edge_type_logits = self.forward(
            g,
            edge_pairs,
            candidate_meta=candidate_meta,
        )
        
        # Compute loss
        loss, type_losses = self.get_loss(edge_logits, edge_labels, edge_types, pos_weight, edge_type_logits)
        
        # Backward pass
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        
        return loss.item(), {k: v.item() for k, v in type_losses.items()}