thealper2 commited on
Commit
2ef4ea4
·
verified ·
1 Parent(s): d5799cd

Add GraphCodeBERT clone-detection model

Browse files
README.md ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ library_name: transformers
4
+ pipeline_tag: text-classification
5
+ tags:
6
+ - code
7
+ - clone-detection
8
+ - graphcodebert
9
+ - code-similarity
10
+ base_model: microsoft/graphcodebert-base
11
+ datasets:
12
+ - PoolC/1-fold-clone-detection-600k-5fold
13
+ language:
14
+ - code
15
+ metrics:
16
+ - accuracy
17
+ - precision
18
+ - recall
19
+ - f1
20
+ model-index:
21
+ - name: graphcodebert-code-clone-detection
22
+ results:
23
+ - task:
24
+ type: text-classification
25
+ name: Binary code clone detection
26
+ dataset:
27
+ type: PoolC/1-fold-clone-detection-600k-5fold
28
+ name: PoolC/1-fold-clone-detection-600k-5fold
29
+ split: test (group-disjoint half of the `val` fold)
30
+ metrics:
31
+ - type: f1
32
+ value: 0.8805
33
+ - type: accuracy
34
+ value: 0.8747
35
+ - type: precision
36
+ value: 0.841
37
+ - type: recall
38
+ value: 0.924
39
+ ---
40
+
41
+ # graphcodebert-code-clone-detection
42
+
43
+ Binary code-clone detection. Full fine-tune of
44
+ [`microsoft/graphcodebert-base`](https://huggingface.co/microsoft/graphcodebert-base) on
45
+ [`PoolC/1-fold-clone-detection-600k-5fold`](https://huggingface.co/datasets/PoolC/1-fold-clone-detection-600k-5fold),
46
+ using GraphCodeBERT's data-flow-aware pairwise architecture.
47
+
48
+ Output labels: `0 = not clone`, `1 = clone`.
49
+
50
+ ## Architecture
51
+
52
+ Not a generic sequence-pair classifier. The two snippets are encoded
53
+ **separately** by one shared GraphCodeBERT encoder, each with its own
54
+ graph-guided masked attention, and the two `<s>` vectors are concatenated for
55
+ classification:
56
+
57
+ ```
58
+ Linear(2 x 768 -> 768) -> tanh -> Linear(768 -> 2)
59
+ ```
60
+
61
+ Per-snippet input layout (length 640):
62
+
63
+ | segment | length | content | `position_idx` |
64
+ |---|---|---|---|
65
+ | code tokens | 512 | `<s>` + BPE code tokens + `</s>` | `2 .. n+1` |
66
+ | data-flow nodes | 128 | one slot per DFG variable node (`<unk>` id) | `0` |
67
+ | padding | remainder | `<pad>` | `1` |
68
+
69
+ A data-flow node's input embedding is the **average of the embeddings of the
70
+ code tokens it was identified from**. Graph-guided attention allows: code to
71
+ code; `<s>`/`</s>` to everything; node to the code tokens it comes from (and
72
+ back); node to adjacent nodes.
73
+
74
+ ## Preprocessing
75
+
76
+ The dataset contains **Python** snippets, so data flow is extracted with the
77
+ `tree-sitter-python` grammar via a port of GraphCodeBERT's `DFG_python`
78
+ extractor (comment/docstring stripping -> AST -> variable states ->
79
+ `comesFrom` / `computedFrom` edges).
80
+
81
+ | | |
82
+ |---|---|
83
+ | `code_length` | 512 |
84
+ | `data_flow_length` | 128 |
85
+ | total sequence length | 640 |
86
+ | distinct snippets featurised | 44,950 |
87
+ | mean data-flow nodes / snippet | 44.22 |
88
+ | snippets with empty data flow | 263 |
89
+ | total data-flow edges | 2,481,388 |
90
+ | extraction status counts | `{"ok": 44930, "comment_strip_failed": 13, "dfg_failed": 7}` |
91
+
92
+ No example was dropped: a snippet whose data flow could not be extracted is
93
+ kept with an empty graph and counted above.
94
+
95
+ ## Data splits
96
+
97
+ The repository provides one of 5 predefined folds as `train` + `val`; those groups are disjoint and are kept as-is. `val` is partitioned further into validation/test along problem-group boundaries.
98
+
99
+ `similar` equals `(code1_group == code2_group)` for every row, so the group
100
+ columns are a perfect label proxy and are never used as features.
101
+
102
+ | split | source | pairs | positives | negatives | groups |
103
+ |---|---|---:|---:|---:|---:|
104
+ | train | `train` fold | 50,000 | 25,000 | 25,000 | 240 |
105
+ | validation | half of `val` by group | 20,000 | 10,000 | 10,000 | 29 |
106
+ | test | other half of `val` by group | 20,000 | 10,000 | 10,000 | 30 |
107
+
108
+ Train/validation/test share **no problem group and no code snippet**; this is
109
+ asserted at runtime before training starts. 337,398 pairs of the
110
+ held-out fold were dropped because their two snippets fell on opposite sides of
111
+ the validation/test group boundary.
112
+
113
+ Class weighting: Measured majority-class share 0.5000 is within the 0.6 threshold, so weighted cross entropy is NOT used.
114
+
115
+ ## Training
116
+
117
+ | | |
118
+ |---|---|
119
+ | optimizer | adamw_torch |
120
+ | learning rate | 2e-05 |
121
+ | scheduler | linear with 0.1 warmup ratio (938 steps) |
122
+ | epochs | 3.0 |
123
+ | per-device batch size | 16 |
124
+ | gradient accumulation | 1 |
125
+ | effective batch size | 16 |
126
+ | weight decay | 0.01 |
127
+ | gradient clipping | 1.0 |
128
+ | mixed precision | fp16 |
129
+ | gradient checkpointing | False |
130
+ | seed | 42 |
131
+ | trainable parameters | 125,236,994 |
132
+ | training time | 1.923 h |
133
+ | GPU | NVIDIA GeForce RTX 5060 Ti (15.9 GB) |
134
+ | torch / transformers | 2.11.0+cu128 / 5.17.0 |
135
+
136
+ Checkpoint selection: best validation **F1** (`load_best_model_at_end=True`,
137
+ `metric_for_best_model="f1"`). Best validation F1 = **0.8672**.
138
+ The test split was scored once, after selection.
139
+
140
+ ## Results
141
+
142
+ | split | accuracy | precision | recall | F1 | TP | TN | FP | FN |
143
+ |---|---:|---:|---:|---:|---:|---:|---:|---:|
144
+ | validation | 0.8557 | 0.8032 | 0.9422 | 0.8672 | 9,422 | 7,692 | 2,308 | 578 |
145
+ | test | 0.8747 | 0.8410 | 0.9240 | 0.8805 | 9,240 | 8,253 | 1,747 | 760 |
146
+
147
+ Test confusion matrix (`[[TN, FP], [FN, TP]]`): `[[8253, 1747], [760, 9240]]`
148
+
149
+ ## Usage
150
+
151
+ This checkpoint uses a **custom pairwise head and a graph-guided attention
152
+ mask**, so `AutoModelForSequenceClassification` will not reproduce these
153
+ results. Use the repository's own model class and preprocessing:
154
+
155
+ ```python
156
+ import torch
157
+ from transformers import AutoTokenizer
158
+ from modeling import load_model # from this project
159
+ from preprocess import build_snippet_features, CloneCollator
160
+ from config import Config
161
+
162
+ cfg = Config()
163
+ tokenizer = AutoTokenizer.from_pretrained("thealper2/graphcodebert-code-clone-detection")
164
+ model = load_model("thealper2/graphcodebert-code-clone-detection").eval()
165
+
166
+ features = build_snippet_features(cfg, [code_a, code_b], tokenizer, num_proc=1)
167
+ collator = CloneCollator(features)
168
+ batch = collator([(0, 1, 0)]) # (snippet_a, snippet_b, dummy label)
169
+ with torch.no_grad():
170
+ logits = model(**{k: v for k, v in batch.items() if k != "labels"}).logits
171
+ label = int(logits.argmax(-1)) # 0 = not clone, 1 = clone
172
+ ```
173
+
174
+ ## Limitations
175
+
176
+ - Trained on competitive-programming Python solutions grouped by problem;
177
+ "clone" therefore means *solves the same problem*, which is closer to
178
+ semantic (Type-4) similarity than to syntactic copy-paste detection.
179
+ - Data flow is extracted with the Python grammar only. Other languages need the
180
+ matching `tree_sitter_<lang>` grammar and `DFG_<lang>` function.
181
+ - Snippets longer than 512 BPE tokens are truncated; 885 of
182
+ 44,950 distinct snippets hit that limit.
183
+ - Both directions of a pair are not explicitly symmetrised; the head sees
184
+ `concat(<s>_1, <s>_2)` in the given order.
code/config.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Central configuration for GraphCodeBERT binary code-clone detection.
2
+
3
+ Every tunable lives in :class:`Config`. ``Config.from_cli`` turns the dataclass
4
+ fields into ``argparse`` flags automatically, so ``train.py`` / ``evaluate.py``
5
+ never drift apart from this file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import dataclasses
12
+ import json
13
+ from dataclasses import dataclass, field, fields
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ # --------------------------------------------------------------------------- #
18
+ # Facts discovered by inspecting the dataset (see README "Dataset structure").
19
+ # They are kept here as documented defaults, *not* as blind assumptions:
20
+ # preprocess.py re-verifies every one of them at runtime and raises if the
21
+ # remote dataset ever changes.
22
+ # --------------------------------------------------------------------------- #
23
+ CODE1_COLUMN = "code1"
24
+ CODE2_COLUMN = "code2"
25
+ LABEL_COLUMN = "similar"
26
+ GROUP1_COLUMN = "code1_group"
27
+ GROUP2_COLUMN = "code2_group"
28
+ #: Metadata columns that MUST NOT reach the model. ``code1_group``/``code2_group``
29
+ #: determine the label exactly (``similar == (code1_group == code2_group)``), so
30
+ #: feeding them in any form would be a 100 % label leak.
31
+ FORBIDDEN_FEATURE_COLUMNS = (
32
+ GROUP1_COLUMN,
33
+ GROUP2_COLUMN,
34
+ "pair_id",
35
+ "question_pair_id",
36
+ )
37
+ #: The source language of the snippets, needed to pick the tree-sitter grammar.
38
+ DATASET_LANGUAGE = "python"
39
+
40
+
41
+ @dataclass
42
+ class Config:
43
+ """All knobs for preprocessing, training and evaluation."""
44
+
45
+ # ---------------- dataset ---------------- #
46
+ dataset_name: str = "PoolC/1-fold-clone-detection-600k-5fold"
47
+ #: HF split that becomes the training set (group-disjoint from ``val``).
48
+ train_split: str = "train"
49
+ #: HF split that is partitioned by *group* into validation and test.
50
+ heldout_split: str = "val"
51
+ #: Fraction of the held-out split's groups reserved for the test set.
52
+ test_group_fraction: float = 0.5
53
+
54
+ #: Cap on the number of pairs per split. ``-1`` = use everything.
55
+ #: The full training split has 5.39 M pairs; see README for why the default
56
+ #: is a subsample and how to raise it.
57
+ max_train_samples: int = 50_000
58
+ max_eval_samples: int = 20_000
59
+ max_test_samples: int = 20_000
60
+ #: Keep the 50/50 label balance exactly when subsampling.
61
+ balance_subsamples: bool = True
62
+
63
+ # ---------------- model ---------------- #
64
+ model_name_or_path: str = "microsoft/graphcodebert-base"
65
+ #: GraphCodeBERT clone-detection defaults from the original paper/repo.
66
+ code_length: int = 512
67
+ data_flow_length: int = 128
68
+ attn_implementation: str = "sdpa"
69
+
70
+ # ---------------- training ---------------- #
71
+ learning_rate: float = 2e-5
72
+ num_train_epochs: float = 3.0
73
+ per_device_train_batch_size: int = 4
74
+ per_device_eval_batch_size: int = 4
75
+ gradient_accumulation_steps: int = 4
76
+ weight_decay: float = 0.01
77
+ warmup_ratio: float = 0.1
78
+ max_grad_norm: float = 1.0
79
+ fp16: bool = True
80
+ bf16: bool = False
81
+ gradient_checkpointing: bool = False
82
+ optim: str = "adamw_torch"
83
+ lr_scheduler_type: str = "linear"
84
+
85
+ #: Class-weighted cross entropy. ``"auto"`` enables it only when the
86
+ #: measured training distribution is more skewed than
87
+ #: ``class_weight_threshold``; ``"off"`` never, ``"on"`` always.
88
+ class_weighting: str = "auto"
89
+ class_weight_threshold: float = 0.6
90
+
91
+ # ---------------- evaluation / checkpointing ---------------- #
92
+ eval_strategy: str = "steps"
93
+ eval_steps: int = 1000
94
+ save_strategy: str = "steps"
95
+ save_steps: int = 1000
96
+ save_total_limit: int = 2
97
+ logging_steps: int = 100
98
+ metric_for_best_model: str = "f1"
99
+ greater_is_better: bool = True
100
+ load_best_model_at_end: bool = True
101
+
102
+ # ---------------- runtime ---------------- #
103
+ seed: int = 42
104
+ full_determinism: bool = False
105
+ dataloader_num_workers: int = 4
106
+ #: Processes used for tree-sitter data-flow extraction.
107
+ preprocessing_num_workers: int = 8
108
+ output_dir: str = "./outputs"
109
+ model_dir: str = "./models/graphcodebert-clone-detection"
110
+ logging_dir: str = "./logs"
111
+ cache_dir: str = "./outputs/feature_cache"
112
+ report_to: str = "none"
113
+ run_sanity_check: bool = True
114
+ sanity_check_samples: int = 64
115
+
116
+ # ---------------- derived ---------------- #
117
+ @property
118
+ def total_sequence_length(self) -> int:
119
+ """Length of one encoded snippet: code tokens + data-flow nodes."""
120
+ return self.code_length + self.data_flow_length
121
+
122
+ @property
123
+ def effective_batch_size(self) -> int:
124
+ return self.per_device_train_batch_size * self.gradient_accumulation_steps
125
+
126
+ # ---------------- (de)serialisation ---------------- #
127
+ def to_dict(self) -> dict[str, Any]:
128
+ d = dataclasses.asdict(self)
129
+ d["total_sequence_length"] = self.total_sequence_length
130
+ d["effective_batch_size"] = self.effective_batch_size
131
+ return d
132
+
133
+ def save(self, path: str | Path) -> None:
134
+ path = Path(path)
135
+ path.parent.mkdir(parents=True, exist_ok=True)
136
+ path.write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
137
+
138
+ @classmethod
139
+ def from_json(cls, path: str | Path) -> "Config":
140
+ raw = json.loads(Path(path).read_text(encoding="utf-8"))
141
+ known = {f.name for f in fields(cls)}
142
+ return cls(**{k: v for k, v in raw.items() if k in known})
143
+
144
+ # ---------------- CLI ---------------- #
145
+ @classmethod
146
+ def build_parser(cls, description: str = "") -> argparse.ArgumentParser:
147
+ parser = argparse.ArgumentParser(
148
+ description=description,
149
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
150
+ )
151
+ parser.add_argument(
152
+ "--config_json",
153
+ type=str,
154
+ default=None,
155
+ help="Load defaults from a saved training_config.json, then apply CLI overrides.",
156
+ )
157
+ for f in fields(cls):
158
+ flag = f"--{f.name}"
159
+ if f.type is bool or f.type == "bool":
160
+ # Accept --flag / --flag true / --flag false.
161
+ parser.add_argument(
162
+ flag,
163
+ type=_str2bool,
164
+ nargs="?",
165
+ const=True,
166
+ default=None,
167
+ help=f"(bool) default: {f.default}",
168
+ )
169
+ else:
170
+ parser.add_argument(flag, type=type(f.default), default=None)
171
+ return parser
172
+
173
+ @classmethod
174
+ def from_cli(cls, argv: list[str] | None = None, description: str = "") -> "Config":
175
+ parser = cls.build_parser(description)
176
+ args, unknown = parser.parse_known_args(argv)
177
+ if unknown:
178
+ raise SystemExit(f"Unrecognised arguments: {unknown}")
179
+ cfg = cls.from_json(args.config_json) if args.config_json else cls()
180
+ for f in fields(cls):
181
+ value = getattr(args, f.name, None)
182
+ if value is not None:
183
+ setattr(cfg, f.name, value)
184
+ cfg.validate()
185
+ return cfg
186
+
187
+ def validate(self) -> None:
188
+ """Fail fast on impossible combinations instead of dying mid-training."""
189
+ if self.fp16 and self.bf16:
190
+ raise ValueError("Enable at most one of fp16 / bf16.")
191
+ if not 0.0 < self.test_group_fraction < 1.0:
192
+ raise ValueError("test_group_fraction must lie strictly between 0 and 1.")
193
+ if self.code_length <= 3:
194
+ raise ValueError("code_length must leave room for <s> and </s>.")
195
+ if self.data_flow_length < 0:
196
+ raise ValueError("data_flow_length must be >= 0.")
197
+ # GraphCodeBERT position ids run up to code_length + 1; RoBERTa's
198
+ # embedding table holds 514 slots (512 + <pad> + offset).
199
+ if self.code_length > 512:
200
+ raise ValueError(
201
+ "code_length > 512 exceeds GraphCodeBERT's position embeddings (514 slots)."
202
+ )
203
+ if self.class_weighting not in {"auto", "on", "off"}:
204
+ raise ValueError("class_weighting must be one of: auto, on, off.")
205
+ if self.metric_for_best_model not in {
206
+ "f1",
207
+ "accuracy",
208
+ "precision",
209
+ "recall",
210
+ "loss",
211
+ }:
212
+ raise ValueError(f"Unsupported metric_for_best_model: {self.metric_for_best_model}")
213
+ if self.load_best_model_at_end and self.eval_strategy != self.save_strategy:
214
+ raise ValueError("load_best_model_at_end requires eval_strategy == save_strategy.")
215
+ if (
216
+ self.load_best_model_at_end
217
+ and self.eval_strategy == "steps"
218
+ and self.save_steps % self.eval_steps != 0
219
+ ):
220
+ raise ValueError("save_steps must be a multiple of eval_steps.")
221
+
222
+
223
+ #: Placeholder namespace in the Makefile default; replaced by the logged-in user.
224
+ PLACEHOLDER_HUB_NAMESPACE = "your-username"
225
+ DEFAULT_HUB_MODEL_NAME = "graphcodebert-clone-detection"
226
+
227
+
228
+ def resolve_hub_repo_id(repo_id: str | None, token: str | None = None) -> str:
229
+ """Expand a bare model name into ``<namespace>/<name>`` for the Hub.
230
+
231
+ Accepts ``None``, a bare name, or a full ``user/name``. The namespace is
232
+ taken from the caller's Hugging Face credential (``huggingface-cli login``,
233
+ ``HF_TOKEN``, or an explicit ``token``), so the token never has to be typed
234
+ on the command line.
235
+
236
+ Raises:
237
+ RuntimeError: if no namespace is given and no credential is available.
238
+ """
239
+ from huggingface_hub import HfApi, get_token
240
+
241
+ name = (repo_id or DEFAULT_HUB_MODEL_NAME).strip().strip("/")
242
+ if "/" in name:
243
+ namespace, _, model_name = name.partition("/")
244
+ if namespace != PLACEHOLDER_HUB_NAMESPACE:
245
+ return f"{namespace}/{model_name}"
246
+ name = model_name or DEFAULT_HUB_MODEL_NAME
247
+
248
+ effective = token or get_token()
249
+ if not effective:
250
+ raise RuntimeError(
251
+ "No Hugging Face credential found, so the namespace for "
252
+ f"{name!r} cannot be resolved. Run `huggingface-cli login`, export "
253
+ "HF_TOKEN, or pass the full repo id as HF_REPO=user/name."
254
+ )
255
+ try:
256
+ who = HfApi().whoami(token=effective)
257
+ except Exception as exc:
258
+ raise RuntimeError(
259
+ f"Could not identify the logged-in Hugging Face account: {exc}. "
260
+ "Pass the full repo id as HF_REPO=user/name."
261
+ ) from exc
262
+ namespace = who.get("name")
263
+ if not namespace:
264
+ raise RuntimeError("Hugging Face account has no username; pass HF_REPO=user/name.")
265
+ return f"{namespace}/{name}"
266
+
267
+
268
+ def _str2bool(value: str | bool) -> bool:
269
+ if isinstance(value, bool):
270
+ return value
271
+ if value.lower() in {"true", "t", "yes", "y", "1"}:
272
+ return True
273
+ if value.lower() in {"false", "f", "no", "n", "0"}:
274
+ return False
275
+ raise argparse.ArgumentTypeError(f"Expected a boolean, got {value!r}")
code/dfg_parser.py ADDED
@@ -0,0 +1,421 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data-flow graph (DFG) extraction for GraphCodeBERT.
2
+
3
+ This is a faithful port of Microsoft's GraphCodeBERT ``parser/`` package
4
+ (``utils.py`` + the ``DFG_python`` extractor from ``DFG.py``), adapted to the
5
+ modern ``py-tree-sitter`` API (>= 0.22, ``Language(tree_sitter_python.language())``)
6
+ instead of the original hand-compiled ``my-languages.so``.
7
+
8
+ The dataset used in this project contains **Python** snippets (verified in
9
+ ``preprocess.py``), so only the Python extractor is ported; adding another
10
+ language means adding its ``DFG_<lang>`` function and grammar package here.
11
+
12
+ A DFG entry is the 5-tuple used throughout GraphCodeBERT::
13
+
14
+ (variable_name, token_index, edge_type, source_variable_names, source_token_indices)
15
+
16
+ ``edge_type`` is ``"comesFrom"`` (value flows from a previous definition) or
17
+ ``"computedFrom"`` (value is computed from the right-hand side of an assignment).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import io
23
+ import re
24
+ import sys
25
+ import tokenize
26
+ from typing import Any
27
+
28
+ from tree_sitter import Language, Node, Parser
29
+
30
+ __all__ = [
31
+ "get_parser",
32
+ "extract_dataflow",
33
+ "remove_comments_and_docstrings",
34
+ "DataFlowExtractionError",
35
+ ]
36
+
37
+ #: tree-sitter recursion is mirrored by the recursive Python walkers below.
38
+ #: Competitive-programming snippets can nest deeply, so raise the ceiling but
39
+ #: keep it bounded so a pathological file raises RecursionError instead of
40
+ #: segfaulting the worker.
41
+ _RECURSION_LIMIT = 10_000
42
+
43
+
44
+ class DataFlowExtractionError(RuntimeError):
45
+ """Raised when a snippet cannot be turned into code tokens at all."""
46
+
47
+
48
+ _PARSER_CACHE: dict[str, Parser] = {}
49
+
50
+
51
+ def get_parser(language: str = "python") -> Parser:
52
+ """Return a cached tree-sitter parser for ``language``.
53
+
54
+ Cached per process so that ``datasets.map(num_proc=...)`` workers each build
55
+ the parser once rather than once per snippet.
56
+ """
57
+ if language in _PARSER_CACHE:
58
+ return _PARSER_CACHE[language]
59
+ if language != "python":
60
+ raise ValueError(
61
+ f"Only the Python grammar is wired up (requested {language!r}). "
62
+ "Add the matching tree_sitter_<lang> package and DFG_<lang> function."
63
+ )
64
+ try:
65
+ import tree_sitter_python
66
+ except ImportError as exc: # pragma: no cover - environment problem
67
+ raise ImportError(
68
+ "tree_sitter_python is required for GraphCodeBERT data-flow extraction. "
69
+ "Install it with `pip install tree-sitter tree-sitter-python`."
70
+ ) from exc
71
+ parser = Parser(Language(tree_sitter_python.language()))
72
+ _PARSER_CACHE[language] = parser
73
+ return parser
74
+
75
+
76
+ # --------------------------------------------------------------------------- #
77
+ # parser/utils.py
78
+ # --------------------------------------------------------------------------- #
79
+ def remove_comments_and_docstrings(source: str, lang: str = "python") -> str:
80
+ """Strip comments and docstrings, preserving token columns.
81
+
82
+ Column positions are preserved because the DFG indices are ``(row, column)``
83
+ points into the *cleaned* source.
84
+ """
85
+ if lang == "python":
86
+ io_obj = io.StringIO(source)
87
+ out = ""
88
+ prev_toktype = tokenize.INDENT
89
+ last_lineno = -1
90
+ last_col = 0
91
+ for tok in tokenize.generate_tokens(io_obj.readline):
92
+ token_type, token_string = tok[0], tok[1]
93
+ start_line, start_col = tok[2]
94
+ end_line, end_col = tok[3]
95
+ if start_line > last_lineno:
96
+ last_col = 0
97
+ if start_col > last_col:
98
+ out += " " * (start_col - last_col)
99
+ if token_type == tokenize.COMMENT:
100
+ pass
101
+ elif token_type == tokenize.STRING:
102
+ # A string that starts a logical line is a docstring -> drop it.
103
+ if prev_toktype != tokenize.INDENT and prev_toktype != tokenize.NEWLINE:
104
+ if start_col > 0:
105
+ out += token_string
106
+ else:
107
+ out += token_string
108
+ prev_toktype = token_type
109
+ last_col = end_col
110
+ last_lineno = end_line
111
+ return "\n".join(x for x in out.split("\n") if x.strip() != "")
112
+
113
+ def _replacer(match: re.Match[str]) -> str:
114
+ s = match.group(0)
115
+ return " " if s.startswith("/") else s
116
+
117
+ pattern = re.compile(
118
+ r"//.*?$|/\*.*?\*/|\'(?:\\.|[^\\\'])*\'|\"(?:\\.|[^\\\"])*\"",
119
+ re.DOTALL | re.MULTILINE,
120
+ )
121
+ cleaned = re.sub(pattern, _replacer, source)
122
+ return "\n".join(x for x in cleaned.split("\n") if x.strip() != "")
123
+
124
+
125
+ def tree_to_token_index(root_node: Node) -> list[tuple[Any, Any]]:
126
+ """Collect ``(start_point, end_point)`` spans of every leaf token."""
127
+ if (len(root_node.children) == 0 or root_node.type == "string") and root_node.type != "comment":
128
+ return [(root_node.start_point, root_node.end_point)]
129
+ spans: list[tuple[Any, Any]] = []
130
+ for child in root_node.children:
131
+ spans += tree_to_token_index(child)
132
+ return spans
133
+
134
+
135
+ def tree_to_variable_index(root_node: Node, index_to_code: dict) -> list[tuple[Any, Any]]:
136
+ """Collect spans of leaves that are *variables* (token text != node type)."""
137
+ if (len(root_node.children) == 0 or root_node.type == "string") and root_node.type != "comment":
138
+ index = (root_node.start_point, root_node.end_point)
139
+ _, code = index_to_code[index]
140
+ return [] if root_node.type == code else [index]
141
+ spans: list[tuple[Any, Any]] = []
142
+ for child in root_node.children:
143
+ spans += tree_to_variable_index(child, index_to_code)
144
+ return spans
145
+
146
+
147
+ def index_to_code_token(index: tuple[Any, Any], code: list[str]) -> str:
148
+ """Slice the source text covered by a ``(start_point, end_point)`` span."""
149
+ start_point, end_point = index
150
+ if start_point[0] == end_point[0]:
151
+ return code[start_point[0]][start_point[1] : end_point[1]]
152
+ s = code[start_point[0]][start_point[1] :]
153
+ for i in range(start_point[0] + 1, end_point[0]):
154
+ s += code[i]
155
+ s += code[end_point[0]][: end_point[1]]
156
+ return s
157
+
158
+
159
+ # --------------------------------------------------------------------------- #
160
+ # parser/DFG.py :: DFG_python
161
+ # --------------------------------------------------------------------------- #
162
+ _ASSIGNMENT = ("assignment", "augmented_assignment", "for_in_clause")
163
+ _IF_STATEMENT = ("if_statement",)
164
+ _FOR_STATEMENT = ("for_statement",)
165
+ _WHILE_STATEMENT = ("while_statement",)
166
+ _DO_FIRST_STATEMENT = ("for_in_clause",)
167
+ _DEF_STATEMENT = ("default_parameter",)
168
+
169
+
170
+ def DFG_python(root_node: Node, index_to_code: dict, states: dict) -> tuple[list, dict]:
171
+ """Build the data-flow graph of a Python AST subtree.
172
+
173
+ Returns ``(dfg_edges, variable_states)`` where ``variable_states`` maps a
174
+ variable name to the token indices that currently define it.
175
+ """
176
+ states = states.copy()
177
+
178
+ if (len(root_node.children) == 0 or root_node.type == "string") and root_node.type != "comment":
179
+ idx, code = index_to_code[(root_node.start_point, root_node.end_point)]
180
+ if root_node.type == code: # a keyword/operator, not a variable
181
+ return [], states
182
+ if code in states:
183
+ return [(code, idx, "comesFrom", [code], states[code].copy())], states
184
+ if root_node.type == "identifier":
185
+ states[code] = [idx]
186
+ return [(code, idx, "comesFrom", [], [])], states
187
+
188
+ if root_node.type in _DEF_STATEMENT:
189
+ name = root_node.child_by_field_name("name")
190
+ value = root_node.child_by_field_name("value")
191
+ dfg: list = []
192
+ if value is None:
193
+ for index in tree_to_variable_index(name, index_to_code):
194
+ idx, code = index_to_code[index]
195
+ dfg.append((code, idx, "comesFrom", [], []))
196
+ states[code] = [idx]
197
+ return sorted(dfg, key=lambda x: x[1]), states
198
+ name_indexs = tree_to_variable_index(name, index_to_code)
199
+ value_indexs = tree_to_variable_index(value, index_to_code)
200
+ temp, states = DFG_python(value, index_to_code, states)
201
+ dfg += temp
202
+ for index1 in name_indexs:
203
+ idx1, code1 = index_to_code[index1]
204
+ for index2 in value_indexs:
205
+ idx2, code2 = index_to_code[index2]
206
+ dfg.append((code1, idx1, "comesFrom", [code2], [idx2]))
207
+ states[code1] = [idx1]
208
+ return sorted(dfg, key=lambda x: x[1]), states
209
+
210
+ if root_node.type in _ASSIGNMENT:
211
+ if root_node.type == "for_in_clause":
212
+ right_nodes = [root_node.children[-1]]
213
+ left_nodes = [root_node.child_by_field_name("left")]
214
+ else:
215
+ if root_node.child_by_field_name("right") is None:
216
+ return [], states
217
+ left_nodes = [x for x in root_node.child_by_field_name("left").children if x.type != ","]
218
+ right_nodes = [
219
+ x for x in root_node.child_by_field_name("right").children if x.type != ","
220
+ ]
221
+ if len(right_nodes) != len(left_nodes):
222
+ left_nodes = [root_node.child_by_field_name("left")]
223
+ right_nodes = [root_node.child_by_field_name("right")]
224
+ if len(left_nodes) == 0:
225
+ left_nodes = [root_node.child_by_field_name("left")]
226
+ if len(right_nodes) == 0:
227
+ right_nodes = [root_node.child_by_field_name("right")]
228
+ dfg = []
229
+ for node in right_nodes:
230
+ temp, states = DFG_python(node, index_to_code, states)
231
+ dfg += temp
232
+ for left_node, right_node in zip(left_nodes, right_nodes):
233
+ left_tokens_index = tree_to_variable_index(left_node, index_to_code)
234
+ right_tokens_index = tree_to_variable_index(right_node, index_to_code)
235
+ for token1_index in left_tokens_index:
236
+ idx1, code1 = index_to_code[token1_index]
237
+ dfg.append(
238
+ (
239
+ code1,
240
+ idx1,
241
+ "computedFrom",
242
+ [index_to_code[x][1] for x in right_tokens_index],
243
+ [index_to_code[x][0] for x in right_tokens_index],
244
+ )
245
+ )
246
+ states[code1] = [idx1]
247
+ return sorted(dfg, key=lambda x: x[1]), states
248
+
249
+ if root_node.type in _IF_STATEMENT:
250
+ dfg = []
251
+ current_states = states.copy()
252
+ others_states = []
253
+ tag = "else" in root_node.type
254
+ for child in root_node.children:
255
+ if "else" in child.type:
256
+ tag = True
257
+ if child.type not in ("elif_clause", "else_clause"):
258
+ temp, current_states = DFG_python(child, index_to_code, current_states)
259
+ dfg += temp
260
+ else:
261
+ temp, new_states = DFG_python(child, index_to_code, states)
262
+ dfg += temp
263
+ others_states.append(new_states)
264
+ others_states.append(current_states)
265
+ if tag is False:
266
+ others_states.append(states)
267
+ merged: dict = {}
268
+ for dic in others_states:
269
+ for key in dic:
270
+ merged.setdefault(key, [])
271
+ merged[key] += dic[key]
272
+ for key in merged:
273
+ merged[key] = sorted(set(merged[key]))
274
+ return sorted(dfg, key=lambda x: x[1]), merged
275
+
276
+ if root_node.type in _FOR_STATEMENT:
277
+ dfg = []
278
+ # Two passes: loop bodies can consume values defined later in the loop.
279
+ for _ in range(2):
280
+ right_nodes = [x for x in root_node.child_by_field_name("right").children if x.type != ","]
281
+ left_nodes = [x for x in root_node.child_by_field_name("left").children if x.type != ","]
282
+ if len(right_nodes) != len(left_nodes):
283
+ left_nodes = [root_node.child_by_field_name("left")]
284
+ right_nodes = [root_node.child_by_field_name("right")]
285
+ if len(left_nodes) == 0:
286
+ left_nodes = [root_node.child_by_field_name("left")]
287
+ if len(right_nodes) == 0:
288
+ right_nodes = [root_node.child_by_field_name("right")]
289
+ for node in right_nodes:
290
+ temp, states = DFG_python(node, index_to_code, states)
291
+ dfg += temp
292
+ for left_node, right_node in zip(left_nodes, right_nodes):
293
+ left_tokens_index = tree_to_variable_index(left_node, index_to_code)
294
+ right_tokens_index = tree_to_variable_index(right_node, index_to_code)
295
+ for token1_index in left_tokens_index:
296
+ idx1, code1 = index_to_code[token1_index]
297
+ dfg.append(
298
+ (
299
+ code1,
300
+ idx1,
301
+ "computedFrom",
302
+ [index_to_code[x][1] for x in right_tokens_index],
303
+ [index_to_code[x][0] for x in right_tokens_index],
304
+ )
305
+ )
306
+ states[code1] = [idx1]
307
+ if root_node.children[-1].type == "block":
308
+ temp, states = DFG_python(root_node.children[-1], index_to_code, states)
309
+ dfg += temp
310
+ return _merge_duplicate_edges(dfg), states
311
+
312
+ if root_node.type in _WHILE_STATEMENT:
313
+ dfg = []
314
+ for _ in range(2):
315
+ for child in root_node.children:
316
+ temp, states = DFG_python(child, index_to_code, states)
317
+ dfg += temp
318
+ return _merge_duplicate_edges(dfg), states
319
+
320
+ dfg = []
321
+ for child in root_node.children:
322
+ if child.type in _DO_FIRST_STATEMENT:
323
+ temp, states = DFG_python(child, index_to_code, states)
324
+ dfg += temp
325
+ for child in root_node.children:
326
+ if child.type not in _DO_FIRST_STATEMENT:
327
+ temp, states = DFG_python(child, index_to_code, states)
328
+ dfg += temp
329
+ return sorted(dfg, key=lambda x: x[1]), states
330
+
331
+
332
+ def _merge_duplicate_edges(dfg: list) -> list:
333
+ """Collapse the duplicate edges produced by the two-pass loop handling."""
334
+ dic: dict = {}
335
+ for x in dfg:
336
+ key = (x[0], x[1], x[2])
337
+ if key not in dic:
338
+ dic[key] = [x[3], x[4]]
339
+ else:
340
+ dic[key][0] = list(set(dic[key][0] + x[3]))
341
+ dic[key][1] = sorted(set(dic[key][1] + x[4]))
342
+ merged = [(k[0], k[1], k[2], v[0], v[1]) for k, v in sorted(dic.items(), key=lambda t: t[0][1])]
343
+ return sorted(merged, key=lambda x: x[1])
344
+
345
+
346
+ # --------------------------------------------------------------------------- #
347
+ # Public entry point (GraphCodeBERT's `extract_dataflow`)
348
+ # --------------------------------------------------------------------------- #
349
+ def extract_dataflow(code: str, language: str = "python") -> tuple[list[str], list, dict]:
350
+ """Tokenise ``code`` and extract its data-flow graph.
351
+
352
+ Returns ``(code_tokens, dfg, status)``. ``status`` records *why* a stage
353
+ degraded so callers can report it instead of hiding it:
354
+
355
+ ``comment_strip`` : ``"ok"`` | ``"failed"``
356
+ ``parse`` : ``"ok"`` | ``"failed"``
357
+ ``dfg`` : ``"ok"`` | ``"failed"`` | ``"recursion_limit"``
358
+ ``error`` : ``None`` or ``"<ExcType>: <message>"``
359
+
360
+ A degraded DFG yields an **empty** data-flow component -- the snippet is
361
+ still trained on (GraphCodeBERT tolerates zero nodes), it is never dropped.
362
+ """
363
+ status: dict[str, Any] = {"comment_strip": "ok", "parse": "ok", "dfg": "ok", "error": None}
364
+
365
+ try:
366
+ cleaned = remove_comments_and_docstrings(code, language)
367
+ except Exception as exc:
368
+ # Syntactically broken snippets are common in the wild; fall back to the
369
+ # raw source rather than discarding the example.
370
+ status["comment_strip"] = "failed"
371
+ status["error"] = f"{type(exc).__name__}: {exc}"
372
+ cleaned = code
373
+
374
+ parser = get_parser(language)
375
+ try:
376
+ tree = parser.parse(bytes(cleaned, "utf8"))
377
+ root_node = tree.root_node
378
+ except Exception as exc:
379
+ raise DataFlowExtractionError(f"tree-sitter failed to parse snippet: {exc}") from exc
380
+
381
+ old_limit = sys.getrecursionlimit()
382
+ sys.setrecursionlimit(_RECURSION_LIMIT)
383
+ try:
384
+ try:
385
+ tokens_index = tree_to_token_index(root_node)
386
+ except RecursionError as exc:
387
+ status["parse"] = "failed"
388
+ status["dfg"] = "recursion_limit"
389
+ status["error"] = f"{type(exc).__name__}: token index recursion limit"
390
+ raise DataFlowExtractionError("snippet nests deeper than the recursion limit") from exc
391
+
392
+ lines = cleaned.split("\n")
393
+ code_tokens = [index_to_code_token(x, lines) for x in tokens_index]
394
+ index_to_code = {
395
+ index: (idx, token) for idx, (index, token) in enumerate(zip(tokens_index, code_tokens))
396
+ }
397
+
398
+ try:
399
+ dfg, _ = DFG_python(root_node, index_to_code, {})
400
+ except RecursionError as exc:
401
+ status["dfg"] = "recursion_limit"
402
+ status["error"] = f"{type(exc).__name__}: DFG recursion limit"
403
+ dfg = []
404
+ except Exception as exc:
405
+ status["dfg"] = "failed"
406
+ status["error"] = f"{type(exc).__name__}: {exc}"
407
+ dfg = []
408
+ finally:
409
+ sys.setrecursionlimit(old_limit)
410
+
411
+ # Keep only nodes that participate in at least one edge (GraphCodeBERT does
412
+ # the same: isolated nodes carry no data-flow signal).
413
+ dfg = sorted(dfg, key=lambda x: x[1])
414
+ keep: set[int] = set()
415
+ for d in dfg:
416
+ if len(d[-1]) != 0:
417
+ keep.add(d[1])
418
+ keep.update(d[-1])
419
+ dfg = [d for d in dfg if d[1] in keep]
420
+
421
+ return code_tokens, dfg, status
code/modeling.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GraphCodeBERT clone-detection model.
2
+
3
+ Reimplements the architecture from Microsoft's ``GraphCodeBERT/clonedetection``
4
+ on top of ``transformers`` v5:
5
+
6
+ * the two snippets are encoded **separately** by one shared GraphCodeBERT
7
+ encoder, each with its own graph-guided masked attention;
8
+ * a data-flow node's input embedding is the average of the embeddings of the
9
+ code tokens it was identified from;
10
+ * the two ``<s>`` representations are concatenated and fed to a
11
+ ``Linear(2H -> H) -> tanh -> Linear(H -> 2)`` head.
12
+
13
+ The only real adaptation is the attention mask: ``transformers`` v5 builds masks
14
+ through ``masking_utils`` and only forwards a mask untouched when it is already
15
+ 4-D, so the boolean ``[B, L, L]`` graph mask is expanded to an additive
16
+ ``[B, 1, L, L]`` mask here.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import torch
22
+ import torch.nn as nn
23
+ from transformers import RobertaConfig, RobertaModel, RobertaPreTrainedModel
24
+ from transformers.modeling_outputs import SequenceClassifierOutput
25
+
26
+ __all__ = ["GraphCodeBERTForCloneDetection", "CloneClassificationHead"]
27
+
28
+
29
+ def _autocast_dtype(device: torch.device, fallback: torch.dtype) -> torch.dtype:
30
+ """Dtype the attention scores will actually have, honouring autocast.
31
+
32
+ SDPA requires an additive ``attn_mask`` whose dtype matches the query, so a
33
+ hard-coded float32 mask would break under ``fp16=True``.
34
+ """
35
+ try:
36
+ if torch.is_autocast_enabled(device.type):
37
+ return torch.get_autocast_dtype(device.type)
38
+ except TypeError: # older signature without a device argument
39
+ if device.type == "cuda" and torch.is_autocast_enabled():
40
+ return torch.get_autocast_gpu_dtype()
41
+ return fallback
42
+
43
+
44
+ def _to_additive_mask(bool_mask: torch.Tensor, dtype: torch.dtype) -> torch.Tensor:
45
+ """``[B, L, L]`` boolean -> ``[B, 1, L, L]`` additive mask (0 / -inf)."""
46
+ additive = torch.zeros(bool_mask.shape, dtype=dtype, device=bool_mask.device)
47
+ additive.masked_fill_(~bool_mask, torch.finfo(dtype).min)
48
+ return additive.unsqueeze(1)
49
+
50
+
51
+ class CloneClassificationHead(nn.Module):
52
+ """Pairwise head over the two ``<s>`` vectors (GraphCodeBERT's own head)."""
53
+
54
+ def __init__(self, config: RobertaConfig) -> None:
55
+ super().__init__()
56
+ self.dense = nn.Linear(config.hidden_size * 2, config.hidden_size)
57
+ self.dropout = nn.Dropout(config.hidden_dropout_prob)
58
+ self.out_proj = nn.Linear(config.hidden_size, 2)
59
+
60
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
61
+ """``hidden_states``: ``[B*2, L, H]`` -> logits ``[B, 2]``."""
62
+ x = hidden_states[:, 0, :] # <s> of each snippet
63
+ x = x.reshape(-1, x.size(-1) * 2) # pair the two snippets back up
64
+ x = self.dropout(x)
65
+ x = torch.tanh(self.dense(x))
66
+ x = self.dropout(x)
67
+ return self.out_proj(x)
68
+
69
+
70
+ class GraphCodeBERTForCloneDetection(RobertaPreTrainedModel):
71
+ """Binary clone classifier: ``0 = not clone``, ``1 = clone``."""
72
+
73
+ config_class = RobertaConfig
74
+ base_model_prefix = "roberta"
75
+ supports_gradient_checkpointing = True
76
+
77
+ def __init__(self, config: RobertaConfig, class_weights: list[float] | None = None) -> None:
78
+ super().__init__(config)
79
+ config.num_labels = 2
80
+ self.roberta = RobertaModel(config, add_pooling_layer=False)
81
+ self.classifier = CloneClassificationHead(config)
82
+ self.register_buffer(
83
+ "class_weights",
84
+ torch.tensor(class_weights, dtype=torch.float32) if class_weights else None,
85
+ persistent=False,
86
+ )
87
+ self.post_init()
88
+
89
+ # ------------------------------------------------------------------ #
90
+ def _embed_with_dataflow(
91
+ self, input_ids: torch.Tensor, position_idx: torch.Tensor, attn_mask: torch.Tensor
92
+ ) -> torch.Tensor:
93
+ """Word embeddings where each data-flow node averages its code tokens.
94
+
95
+ ``position_idx`` encodes the role of every slot: ``0`` = data-flow node,
96
+ ``1`` (= ``<pad>``) = padding, ``>= 2`` = real code token.
97
+ """
98
+ nodes_mask = position_idx.eq(0)
99
+ token_mask = position_idx.ge(2)
100
+
101
+ embeddings = self.roberta.embeddings.word_embeddings(input_ids)
102
+ # For every node row, the code-token columns it may look at.
103
+ nodes_to_token = nodes_mask[:, :, None] & token_mask[:, None, :] & attn_mask
104
+ nodes_to_token = nodes_to_token.to(embeddings.dtype)
105
+ nodes_to_token = nodes_to_token / (nodes_to_token.sum(-1) + 1e-10)[:, :, None]
106
+ averaged = torch.einsum("abc,acd->abd", nodes_to_token, embeddings)
107
+ return embeddings * (~nodes_mask)[:, :, None] + averaged * nodes_mask[:, :, None]
108
+
109
+ def _encode(
110
+ self, input_ids: torch.Tensor, position_idx: torch.Tensor, attn_mask: torch.Tensor
111
+ ) -> torch.Tensor:
112
+ embeddings = self._embed_with_dataflow(input_ids, position_idx, attn_mask)
113
+ dtype = _autocast_dtype(input_ids.device, embeddings.dtype)
114
+ outputs = self.roberta(
115
+ inputs_embeds=embeddings,
116
+ attention_mask=_to_additive_mask(attn_mask, dtype),
117
+ position_ids=position_idx,
118
+ token_type_ids=torch.zeros_like(position_idx),
119
+ )
120
+ return outputs.last_hidden_state
121
+
122
+ # ------------------------------------------------------------------ #
123
+ def forward(
124
+ self,
125
+ input_ids_1: torch.Tensor,
126
+ position_idx_1: torch.Tensor,
127
+ attn_mask_1: torch.Tensor,
128
+ input_ids_2: torch.Tensor,
129
+ position_idx_2: torch.Tensor,
130
+ attn_mask_2: torch.Tensor,
131
+ labels: torch.Tensor | None = None,
132
+ ) -> SequenceClassifierOutput:
133
+ """Encode both snippets with the shared encoder and classify the pair.
134
+
135
+ Args:
136
+ input_ids_*: ``[B, L]`` token ids; data-flow slots hold ``<unk>``.
137
+ position_idx_*: ``[B, L]`` role/position ids (see ``_embed_with_dataflow``).
138
+ attn_mask_*: ``[B, L, L]`` boolean graph-guided attention mask.
139
+ labels: ``[B]`` with values in ``{0, 1}``.
140
+ """
141
+ batch_size, seq_len = input_ids_1.shape
142
+ # Stack both snippets into one encoder call: [B, L] x2 -> [B*2, L].
143
+ input_ids = torch.cat((input_ids_1[:, None], input_ids_2[:, None]), 1).view(-1, seq_len)
144
+ position_idx = torch.cat((position_idx_1[:, None], position_idx_2[:, None]), 1).view(
145
+ -1, seq_len
146
+ )
147
+ attn_mask = torch.cat((attn_mask_1[:, None], attn_mask_2[:, None]), 1).view(
148
+ -1, seq_len, seq_len
149
+ )
150
+
151
+ hidden = self._encode(input_ids, position_idx, attn_mask)
152
+ logits = self.classifier(hidden)
153
+
154
+ loss = None
155
+ if labels is not None:
156
+ weight = None
157
+ if self.class_weights is not None:
158
+ weight = self.class_weights.to(device=logits.device, dtype=logits.dtype)
159
+ loss = nn.functional.cross_entropy(logits, labels.view(-1), weight=weight)
160
+
161
+ return SequenceClassifierOutput(loss=loss, logits=logits)
162
+
163
+
164
+ def load_model(
165
+ model_name_or_path: str,
166
+ attn_implementation: str = "sdpa",
167
+ class_weights: list[float] | None = None,
168
+ gradient_checkpointing: bool = False,
169
+ ) -> GraphCodeBERTForCloneDetection:
170
+ """Load GraphCodeBERT weights into the pairwise clone-detection head."""
171
+ model = GraphCodeBERTForCloneDetection.from_pretrained(
172
+ model_name_or_path,
173
+ attn_implementation=attn_implementation,
174
+ )
175
+ # Set after loading: `from_pretrained` should not have to carry runtime-only
176
+ # arguments, and the weights are a training artefact, not part of the config.
177
+ model.class_weights = (
178
+ torch.tensor(class_weights, dtype=torch.float32) if class_weights else None
179
+ )
180
+ if model.config.model_type != "roberta":
181
+ raise ValueError(
182
+ f"Expected a RoBERTa-architecture checkpoint (GraphCodeBERT), "
183
+ f"got model_type={model.config.model_type!r}."
184
+ )
185
+ if gradient_checkpointing:
186
+ model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
187
+ return model
188
+
189
+
190
+ def count_parameters(model: nn.Module) -> dict[str, int]:
191
+ trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
192
+ total = sum(p.numel() for p in model.parameters())
193
+ return {"trainable_parameters": trainable, "total_parameters": total}
code/preprocess.py ADDED
@@ -0,0 +1,839 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dataset loading, leakage-safe splitting and GraphCodeBERT feature building.
2
+
3
+ Design notes
4
+ ------------
5
+ The PoolC fold contains 6.7 M *pairs* but only ~45 k *distinct snippets* -- the
6
+ pairs are combinations of a small pool of solutions. So the expensive work
7
+ (tree-sitter parsing, data-flow extraction, BPE tokenisation) is done **once per
8
+ distinct snippet** and cached on disk; a pair is then just two integer indices
9
+ plus a label. Nothing proportional to 6.7 M rows is ever tokenised, and the
10
+ graph-guided attention masks are materialised lazily in the collator.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import logging
18
+ import time
19
+ from collections import Counter
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any, Iterator
23
+
24
+ import numpy as np
25
+ import pyarrow.parquet as pq
26
+ import torch
27
+ from torch.utils.data import Dataset
28
+
29
+ from config import (
30
+ CODE1_COLUMN,
31
+ CODE2_COLUMN,
32
+ DATASET_LANGUAGE,
33
+ FORBIDDEN_FEATURE_COLUMNS,
34
+ GROUP1_COLUMN,
35
+ GROUP2_COLUMN,
36
+ LABEL_COLUMN,
37
+ Config,
38
+ )
39
+ from dfg_parser import DataFlowExtractionError, extract_dataflow
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ _META_COLUMNS = [LABEL_COLUMN, GROUP1_COLUMN, GROUP2_COLUMN]
44
+
45
+
46
+ # --------------------------------------------------------------------------- #
47
+ # 1. Schema verification
48
+ # --------------------------------------------------------------------------- #
49
+ def verify_dataset_schema(dataset_name: str) -> dict[str, Any]:
50
+ """Check the remote dataset still matches what this pipeline expects.
51
+
52
+ Raises immediately (rather than silently mis-reading columns) if the schema
53
+ drifts. Returns a report dict for the experiment log.
54
+ """
55
+ from datasets import load_dataset_builder
56
+
57
+ builder = load_dataset_builder(dataset_name)
58
+ features = builder.info.features
59
+ splits = {k: v.num_examples for k, v in (builder.info.splits or {}).items()}
60
+
61
+ missing = [
62
+ c
63
+ for c in (CODE1_COLUMN, CODE2_COLUMN, LABEL_COLUMN, GROUP1_COLUMN, GROUP2_COLUMN)
64
+ if c not in features
65
+ ]
66
+ if missing:
67
+ raise ValueError(
68
+ f"{dataset_name} is missing expected columns {missing}. "
69
+ f"Available: {sorted(features)}. Adapt config.py before continuing."
70
+ )
71
+ for col in (CODE1_COLUMN, CODE2_COLUMN):
72
+ if features[col].dtype != "string":
73
+ raise ValueError(f"Column {col!r} must be a string, got {features[col]}.")
74
+
75
+ report = {
76
+ "dataset_name": dataset_name,
77
+ "features": {k: str(v) for k, v in features.items()},
78
+ "splits": splits,
79
+ "forbidden_feature_columns": list(FORBIDDEN_FEATURE_COLUMNS),
80
+ "language": DATASET_LANGUAGE,
81
+ }
82
+ logger.info("Dataset schema OK: %s", json.dumps(report["splits"]))
83
+ return report
84
+
85
+
86
+ def _parquet_files(dataset_name: str, split: str) -> list[str]:
87
+ """Resolve the local parquet shards for one split (downloads on first use)."""
88
+ from huggingface_hub import snapshot_download
89
+
90
+ root = Path(
91
+ snapshot_download(dataset_name, repo_type="dataset", allow_patterns=["data/*", "*.json"])
92
+ )
93
+ files = sorted(root.glob(f"data/{split}-*.parquet"))
94
+ if not files:
95
+ files = sorted(root.glob(f"**/{split}-*.parquet"))
96
+ if not files:
97
+ raise FileNotFoundError(
98
+ f"No parquet shards for split {split!r} under {root}. "
99
+ f"Found: {[p.name for p in root.rglob('*.parquet')]}"
100
+ )
101
+ return [str(p) for p in files]
102
+
103
+
104
+ # --------------------------------------------------------------------------- #
105
+ # 2. Snippet pool + pair index (cached)
106
+ # --------------------------------------------------------------------------- #
107
+ @dataclass
108
+ class SplitIndex:
109
+ """A split reduced to integer indices into the shared snippet pool."""
110
+
111
+ name: str
112
+ snippet_id1: np.ndarray # int32 [n_pairs]
113
+ snippet_id2: np.ndarray # int32 [n_pairs]
114
+ labels: np.ndarray # int8 [n_pairs]
115
+ group1: np.ndarray # int32 [n_pairs]
116
+ group2: np.ndarray # int32 [n_pairs]
117
+
118
+ def __len__(self) -> int:
119
+ return int(self.labels.shape[0])
120
+
121
+ def select(self, rows: np.ndarray, name: str | None = None) -> "SplitIndex":
122
+ return SplitIndex(
123
+ name=name or self.name,
124
+ snippet_id1=self.snippet_id1[rows],
125
+ snippet_id2=self.snippet_id2[rows],
126
+ labels=self.labels[rows],
127
+ group1=self.group1[rows],
128
+ group2=self.group2[rows],
129
+ )
130
+
131
+ def class_distribution(self) -> dict[str, Any]:
132
+ counts = Counter(self.labels.tolist())
133
+ n = max(len(self), 1)
134
+ return {
135
+ "num_examples": len(self),
136
+ "negatives_label_0": int(counts.get(0, 0)),
137
+ "positives_label_1": int(counts.get(1, 0)),
138
+ "positive_ratio": round(counts.get(1, 0) / n, 6),
139
+ "num_groups": int(np.unique(np.concatenate([self.group1, self.group2])).size),
140
+ }
141
+
142
+ def snippet_ids(self) -> np.ndarray:
143
+ return np.unique(np.concatenate([self.snippet_id1, self.snippet_id2]))
144
+
145
+
146
+ def _hash_code(text: str) -> bytes:
147
+ return hashlib.blake2b(text.encode("utf-8", "ignore"), digest_size=16).digest()
148
+
149
+
150
+ def build_snippet_pool(
151
+ cfg: Config, splits: tuple[str, ...]
152
+ ) -> tuple[list[str], dict[str, SplitIndex], dict[str, Any]]:
153
+ """Deduplicate every snippet across ``splits`` and index the pairs.
154
+
155
+ Cached under ``cfg.cache_dir`` -- the scan over the parquet shards is the
156
+ only pass that ever touches all 6.7 M rows, and it runs once.
157
+ """
158
+ cache = Path(cfg.cache_dir) / "pairs" / _fingerprint(cfg.dataset_name, splits)
159
+ if (cache / "meta.json").exists():
160
+ logger.info("Reusing cached snippet pool at %s", cache)
161
+ return _load_pool(cache)
162
+
163
+ cache.mkdir(parents=True, exist_ok=True)
164
+ t0 = time.time()
165
+ code_to_id: dict[bytes, int] = {}
166
+ snippets: list[str] = []
167
+ indices: dict[str, SplitIndex] = {}
168
+ #: A snippet appearing under two different group ids is a (rare) duplicate
169
+ #: solution; we log it because it is the only within-split ambiguity.
170
+ snippet_groups: dict[int, set[int]] = {}
171
+
172
+ for split in splits:
173
+ files = _parquet_files(cfg.dataset_name, split)
174
+ id1_chunks, id2_chunks, lab_chunks, g1_chunks, g2_chunks = [], [], [], [], []
175
+ for path in files:
176
+ pf = pq.ParquetFile(path)
177
+ for batch in pf.iter_batches(
178
+ batch_size=50_000,
179
+ columns=[CODE1_COLUMN, CODE2_COLUMN, *_META_COLUMNS],
180
+ ):
181
+ cols = batch.to_pydict()
182
+ for code_col, group_col, out in (
183
+ (CODE1_COLUMN, GROUP1_COLUMN, id1_chunks),
184
+ (CODE2_COLUMN, GROUP2_COLUMN, id2_chunks),
185
+ ):
186
+ ids = np.empty(len(cols[code_col]), dtype=np.int32)
187
+ for i, (text, group) in enumerate(zip(cols[code_col], cols[group_col])):
188
+ key = _hash_code(text)
189
+ sid = code_to_id.get(key)
190
+ if sid is None:
191
+ sid = len(snippets)
192
+ code_to_id[key] = sid
193
+ snippets.append(text)
194
+ ids[i] = sid
195
+ snippet_groups.setdefault(sid, set()).add(int(group))
196
+ out.append(ids)
197
+ lab_chunks.append(np.asarray(cols[LABEL_COLUMN], dtype=np.int8))
198
+ g1_chunks.append(np.asarray(cols[GROUP1_COLUMN], dtype=np.int32))
199
+ g2_chunks.append(np.asarray(cols[GROUP2_COLUMN], dtype=np.int32))
200
+ logger.info(" scanned %s", Path(path).name)
201
+
202
+ idx = SplitIndex(
203
+ name=split,
204
+ snippet_id1=np.concatenate(id1_chunks),
205
+ snippet_id2=np.concatenate(id2_chunks),
206
+ labels=np.concatenate(lab_chunks),
207
+ group1=np.concatenate(g1_chunks),
208
+ group2=np.concatenate(g2_chunks),
209
+ )
210
+ _assert_label_matches_groups(idx)
211
+ indices[split] = idx
212
+ logger.info("Split %s: %s", split, json.dumps(idx.class_distribution()))
213
+
214
+ ambiguous = sorted(sid for sid, gs in snippet_groups.items() if len(gs) > 1)
215
+ leakage = _cross_split_leakage(indices)
216
+ stats = {
217
+ "num_unique_snippets": len(snippets),
218
+ "scan_seconds": round(time.time() - t0, 1),
219
+ "snippets_with_multiple_groups": len(ambiguous),
220
+ "cross_split_snippet_overlap": leakage,
221
+ "per_split": {k: v.class_distribution() for k, v in indices.items()},
222
+ }
223
+ _save_pool(cache, snippets, indices, stats)
224
+ logger.info("Snippet pool: %s", json.dumps(stats, indent=2))
225
+ return snippets, indices, stats
226
+
227
+
228
+ def _assert_label_matches_groups(idx: SplitIndex) -> None:
229
+ """The label is exactly ``group1 == group2``; assert it and shout about it.
230
+
231
+ This is why ``code1_group``/``code2_group`` are on the forbidden list: they
232
+ are a perfect proxy for the target.
233
+ """
234
+ implied = (idx.group1 == idx.group2).astype(np.int8)
235
+ mismatches = int((implied != idx.labels).sum())
236
+ if mismatches:
237
+ raise ValueError(
238
+ f"Split {idx.name}: {mismatches} rows where `similar` disagrees with "
239
+ "(code1_group == code2_group). The dataset changed; revisit the split logic."
240
+ )
241
+
242
+
243
+ def _cross_split_leakage(indices: dict[str, SplitIndex]) -> dict[str, int]:
244
+ """Count snippets and groups shared between splits (must be zero)."""
245
+ out: dict[str, int] = {}
246
+ names = list(indices)
247
+ for i, a in enumerate(names):
248
+ for b in names[i + 1 :]:
249
+ sa, sb = set(indices[a].snippet_ids().tolist()), set(indices[b].snippet_ids().tolist())
250
+ ga = set(np.unique(np.concatenate([indices[a].group1, indices[a].group2])).tolist())
251
+ gb = set(np.unique(np.concatenate([indices[b].group1, indices[b].group2])).tolist())
252
+ out[f"{a}|{b}:snippets"] = len(sa & sb)
253
+ out[f"{a}|{b}:groups"] = len(ga & gb)
254
+ return out
255
+
256
+
257
+ def _fingerprint(*parts: Any) -> str:
258
+ return hashlib.blake2b(repr(parts).encode(), digest_size=8).hexdigest()
259
+
260
+
261
+ def _save_pool(
262
+ cache: Path, snippets: list[str], indices: dict[str, SplitIndex], stats: dict
263
+ ) -> None:
264
+ import pyarrow as pa
265
+
266
+ pq.write_table(pa.table({"code": snippets}), cache / "snippets.parquet")
267
+ for name, idx in indices.items():
268
+ np.savez(
269
+ cache / f"{name}.npz",
270
+ snippet_id1=idx.snippet_id1,
271
+ snippet_id2=idx.snippet_id2,
272
+ labels=idx.labels,
273
+ group1=idx.group1,
274
+ group2=idx.group2,
275
+ )
276
+ (cache / "meta.json").write_text(
277
+ json.dumps({"splits": list(indices), "stats": stats}, indent=2), encoding="utf-8"
278
+ )
279
+
280
+
281
+ def _load_pool(cache: Path) -> tuple[list[str], dict[str, SplitIndex], dict[str, Any]]:
282
+ meta = json.loads((cache / "meta.json").read_text(encoding="utf-8"))
283
+ snippets = pq.read_table(cache / "snippets.parquet").column("code").to_pylist()
284
+ indices = {}
285
+ for name in meta["splits"]:
286
+ z = np.load(cache / f"{name}.npz")
287
+ indices[name] = SplitIndex(
288
+ name=name,
289
+ snippet_id1=z["snippet_id1"],
290
+ snippet_id2=z["snippet_id2"],
291
+ labels=z["labels"],
292
+ group1=z["group1"],
293
+ group2=z["group2"],
294
+ )
295
+ return snippets, indices, meta["stats"]
296
+
297
+
298
+ # --------------------------------------------------------------------------- #
299
+ # 3. Leakage-safe splitting
300
+ # --------------------------------------------------------------------------- #
301
+ def split_heldout_by_group(
302
+ heldout: SplitIndex, test_group_fraction: float, seed: int
303
+ ) -> tuple[SplitIndex, SplitIndex, dict[str, Any]]:
304
+ """Partition the held-out split into validation/test along *group* boundaries.
305
+
306
+ The dataset ships only ``train`` and ``val``; ``val`` is carved into a
307
+ validation and a test half by assigning whole problem groups to one side.
308
+ Pairs whose two snippets straddle the boundary are dropped -- keeping them
309
+ would put the same group on both sides.
310
+ """
311
+ groups = np.unique(np.concatenate([heldout.group1, heldout.group2]))
312
+ rng = np.random.default_rng(seed)
313
+ shuffled = groups.copy()
314
+ rng.shuffle(shuffled)
315
+ n_test = max(1, int(round(len(shuffled) * test_group_fraction)))
316
+ if n_test >= len(shuffled):
317
+ raise ValueError("test_group_fraction leaves no groups for validation.")
318
+ test_groups = set(shuffled[:n_test].tolist())
319
+ val_groups = set(shuffled[n_test:].tolist())
320
+
321
+ in_test = np.isin(heldout.group1, list(test_groups)) & np.isin(
322
+ heldout.group2, list(test_groups)
323
+ )
324
+ in_val = np.isin(heldout.group1, list(val_groups)) & np.isin(heldout.group2, list(val_groups))
325
+ dropped = int(len(heldout) - in_test.sum() - in_val.sum())
326
+
327
+ validation = heldout.select(np.flatnonzero(in_val), name="validation")
328
+ test = heldout.select(np.flatnonzero(in_test), name="test")
329
+
330
+ overlap = set(validation.snippet_ids().tolist()) & set(test.snippet_ids().tolist())
331
+ if overlap:
332
+ raise AssertionError(f"{len(overlap)} snippets leaked between validation and test.")
333
+
334
+ report = {
335
+ "heldout_groups": int(len(groups)),
336
+ "validation_groups": len(val_groups),
337
+ "test_groups": len(test_groups),
338
+ "dropped_cross_boundary_pairs": dropped,
339
+ "validation": validation.class_distribution(),
340
+ "test": test.class_distribution(),
341
+ }
342
+ return validation, test, report
343
+
344
+
345
+ def subsample(
346
+ idx: SplitIndex, max_samples: int, seed: int, balanced: bool = True
347
+ ) -> tuple[SplitIndex, dict[str, Any]]:
348
+ """Take at most ``max_samples`` rows, optionally keeping the classes balanced.
349
+
350
+ Subsampling never crosses group boundaries (it only removes rows), so it
351
+ cannot introduce leakage.
352
+ """
353
+ if max_samples < 0 or max_samples >= len(idx):
354
+ return idx, {"subsampled": False, "kept": len(idx)}
355
+
356
+ rng = np.random.default_rng(seed)
357
+ if balanced:
358
+ per_class = max_samples // 2
359
+ chosen = []
360
+ for label in (0, 1):
361
+ rows = np.flatnonzero(idx.labels == label)
362
+ take = min(per_class, len(rows))
363
+ chosen.append(rng.choice(rows, size=take, replace=False))
364
+ rows = np.sort(np.concatenate(chosen))
365
+ else:
366
+ rows = np.sort(rng.choice(len(idx), size=max_samples, replace=False))
367
+
368
+ out = idx.select(rows)
369
+ return out, {"subsampled": True, "kept": len(out), "balanced": balanced}
370
+
371
+
372
+ def decide_class_weights(
373
+ train: SplitIndex, mode: str, threshold: float
374
+ ) -> tuple[list[float] | None, dict[str, Any]]:
375
+ """Decide whether class-weighted cross entropy is warranted.
376
+
377
+ Weighting is *not* applied by default: it is enabled only when the measured
378
+ majority-class share exceeds ``threshold``. The rationale is recorded in the
379
+ returned report and written to ``training_config.json``.
380
+ """
381
+ dist = train.class_distribution()
382
+ n0, n1 = dist["negatives_label_0"], dist["positives_label_1"]
383
+ total = max(n0 + n1, 1)
384
+ majority_share = max(n0, n1) / total
385
+
386
+ if mode == "off":
387
+ apply = False
388
+ reason = "class_weighting=off (forced by config)."
389
+ elif mode == "on":
390
+ apply = True
391
+ reason = "class_weighting=on (forced by config)."
392
+ else:
393
+ apply = majority_share > threshold
394
+ reason = (
395
+ f"Measured majority-class share {majority_share:.4f} "
396
+ f"{'exceeds' if apply else 'is within'} the {threshold} threshold, "
397
+ f"so weighted cross entropy is {'enabled' if apply else 'NOT used'}."
398
+ )
399
+
400
+ weights = None
401
+ if apply and n0 > 0 and n1 > 0:
402
+ # Inverse-frequency weights normalised to mean 1.
403
+ w = np.array([total / (2 * n0), total / (2 * n1)], dtype=np.float64)
404
+ weights = (w / w.mean()).tolist()
405
+
406
+ report = {
407
+ "mode": mode,
408
+ "threshold": threshold,
409
+ "majority_class_share": round(majority_share, 6),
410
+ "applied": weights is not None,
411
+ "weights": weights,
412
+ "reason": reason,
413
+ }
414
+ logger.info("Class weighting decision: %s", reason)
415
+ return weights, report
416
+
417
+
418
+ # --------------------------------------------------------------------------- #
419
+ # 4. GraphCodeBERT snippet features
420
+ # --------------------------------------------------------------------------- #
421
+ @dataclass
422
+ class SnippetFeatures:
423
+ """Pre-tokenised snippet pool, laid out as flat numpy arrays.
424
+
425
+ ``dfg_adj_*`` store the (ragged) node adjacency lists so that no data-flow
426
+ edge is ever clipped away silently.
427
+ """
428
+
429
+ input_ids: np.ndarray # int32 [N, L]
430
+ position_idx: np.ndarray # int16 [N, L]
431
+ dfg_to_code: np.ndarray # int32 [N, max_nodes, 2] (offsets into the
432
+ #: *untruncated* sub-token stream, so they can exceed the sequence length)
433
+ num_nodes: np.ndarray # int16 [N]
434
+ node_index: np.ndarray # int16 [N] number of real code tokens (incl. <s>/</s>)
435
+ max_length: np.ndarray # int16 [N] code tokens + data-flow nodes
436
+ dfg_adj_values: np.ndarray # int16 [total_edges]
437
+ dfg_adj_offsets: np.ndarray # int64 [N, max_nodes + 1]
438
+ seq_length: int
439
+ stats: dict[str, Any]
440
+
441
+ def __len__(self) -> int:
442
+ return int(self.input_ids.shape[0])
443
+
444
+
445
+ def build_snippet_features(
446
+ cfg: Config, snippets: list[str], tokenizer: Any, num_proc: int | None = None
447
+ ) -> SnippetFeatures:
448
+ """Run data-flow extraction + tokenisation over every distinct snippet.
449
+
450
+ Uses ``datasets.map`` (batched, multi-process, Arrow-cached) so that a rerun
451
+ with the same config costs nothing.
452
+ """
453
+ from datasets import Dataset as HFDataset
454
+
455
+ seq_len = cfg.total_sequence_length
456
+ max_nodes = seq_len - 3 # hard upper bound; the real cap is computed per snippet
457
+ cache = Path(cfg.cache_dir) / "features"
458
+ cache.mkdir(parents=True, exist_ok=True)
459
+ key = _fingerprint(
460
+ cfg.dataset_name, cfg.model_name_or_path, cfg.code_length, cfg.data_flow_length, len(snippets)
461
+ )
462
+ npz_path = cache / f"snippets_{key}.npz"
463
+ stats_path = cache / f"snippets_{key}.stats.json"
464
+
465
+ if npz_path.exists() and stats_path.exists():
466
+ logger.info("Reusing cached snippet features at %s", npz_path)
467
+ z = np.load(npz_path)
468
+ return SnippetFeatures(
469
+ input_ids=z["input_ids"],
470
+ position_idx=z["position_idx"],
471
+ dfg_to_code=z["dfg_to_code"],
472
+ num_nodes=z["num_nodes"],
473
+ node_index=z["node_index"],
474
+ max_length=z["max_length"],
475
+ dfg_adj_values=z["dfg_adj_values"],
476
+ dfg_adj_offsets=z["dfg_adj_offsets"],
477
+ seq_length=seq_len,
478
+ stats=json.loads(stats_path.read_text(encoding="utf-8")),
479
+ )
480
+
481
+ ds = HFDataset.from_dict({"code": snippets})
482
+ num_proc = num_proc if num_proc and num_proc > 1 else None
483
+ t0 = time.time()
484
+ ds = ds.map(
485
+ _make_feature_fn(tokenizer, cfg.code_length, cfg.data_flow_length),
486
+ batched=True,
487
+ batch_size=256,
488
+ num_proc=num_proc,
489
+ remove_columns=["code"],
490
+ desc="GraphCodeBERT data-flow + tokenisation",
491
+ )
492
+
493
+ n = len(ds)
494
+ cols = ds.with_format(None)
495
+ status_counter: Counter[str] = Counter()
496
+ n_nodes_all: list[int] = []
497
+
498
+ input_ids = np.full((n, seq_len), tokenizer.pad_token_id, dtype=np.int32)
499
+ position_idx = np.full((n, seq_len), tokenizer.pad_token_id, dtype=np.int16)
500
+ num_nodes = np.zeros(n, dtype=np.int16)
501
+ node_index = np.zeros(n, dtype=np.int16)
502
+ max_length = np.zeros(n, dtype=np.int16)
503
+ dfg_to_code_list: list[list[list[int]]] = []
504
+ adj_values: list[int] = []
505
+ adj_offsets = np.zeros((n, max_nodes + 1), dtype=np.int64)
506
+
507
+ observed_max_nodes = 0
508
+ for i, row in enumerate(cols):
509
+ ids = row["input_ids"]
510
+ pos = row["position_idx"]
511
+ input_ids[i, : len(ids)] = ids
512
+ position_idx[i, : len(pos)] = pos
513
+ d2c = row["dfg_to_code"]
514
+ adj = row["dfg_to_dfg"]
515
+ num_nodes[i] = len(d2c)
516
+ observed_max_nodes = max(observed_max_nodes, len(d2c))
517
+ node_index[i] = int(np.sum(np.asarray(pos) > 1))
518
+ max_length[i] = int(np.sum(np.asarray(pos) != tokenizer.pad_token_id))
519
+ dfg_to_code_list.append(d2c)
520
+ base = len(adj_values)
521
+ adj_offsets[i, 0] = base
522
+ for j, nb in enumerate(adj):
523
+ adj_values.extend(nb)
524
+ adj_offsets[i, j + 1] = len(adj_values)
525
+ adj_offsets[i, len(adj) + 1 :] = len(adj_values)
526
+ status_counter.update(row["status_flags"])
527
+ n_nodes_all.append(len(d2c))
528
+
529
+ observed_max_nodes = max(observed_max_nodes, 1)
530
+ # int32: these are offsets into the untruncated sub-token stream, which for a
531
+ # pathologically long snippet reaches ~1e5 -- well past int16.
532
+ dfg_to_code = np.zeros((n, observed_max_nodes, 2), dtype=np.int32)
533
+ for i, d2c in enumerate(dfg_to_code_list):
534
+ if d2c:
535
+ dfg_to_code[i, : len(d2c)] = np.asarray(d2c, dtype=np.int32)
536
+ adj_offsets = adj_offsets[:, : observed_max_nodes + 1]
537
+
538
+ nodes_arr = np.asarray(n_nodes_all)
539
+ stats = {
540
+ "num_snippets": n,
541
+ "extraction_seconds": round(time.time() - t0, 1),
542
+ "status_counts": dict(status_counter),
543
+ "snippets_with_empty_dataflow": int((nodes_arr == 0).sum()),
544
+ "dataflow_nodes_mean": round(float(nodes_arr.mean()), 2),
545
+ "dataflow_nodes_p50": int(np.percentile(nodes_arr, 50)),
546
+ "dataflow_nodes_p95": int(np.percentile(nodes_arr, 95)),
547
+ "dataflow_nodes_max": int(nodes_arr.max()),
548
+ "code_tokens_mean": round(float(node_index.mean()), 2),
549
+ "code_tokens_truncated": int((node_index >= cfg.code_length - 1).sum()),
550
+ "total_dataflow_edges": len(adj_values),
551
+ "sequence_length": seq_len,
552
+ }
553
+ logger.info("Snippet features: %s", json.dumps(stats, indent=2))
554
+ if status_counter.get("dfg_failed", 0) or status_counter.get("dfg_recursion_limit", 0):
555
+ logger.warning(
556
+ "Data-flow extraction degraded for %d snippets (kept with an empty graph, not dropped).",
557
+ status_counter.get("dfg_failed", 0) + status_counter.get("dfg_recursion_limit", 0),
558
+ )
559
+
560
+ features = SnippetFeatures(
561
+ input_ids=input_ids,
562
+ position_idx=position_idx,
563
+ dfg_to_code=dfg_to_code,
564
+ num_nodes=num_nodes,
565
+ node_index=node_index,
566
+ max_length=max_length,
567
+ dfg_adj_values=np.asarray(adj_values, dtype=np.int16),
568
+ dfg_adj_offsets=adj_offsets,
569
+ seq_length=seq_len,
570
+ stats=stats,
571
+ )
572
+ np.savez_compressed(
573
+ npz_path,
574
+ input_ids=features.input_ids,
575
+ position_idx=features.position_idx,
576
+ dfg_to_code=features.dfg_to_code,
577
+ num_nodes=features.num_nodes,
578
+ node_index=features.node_index,
579
+ max_length=features.max_length,
580
+ dfg_adj_values=features.dfg_adj_values,
581
+ dfg_adj_offsets=features.dfg_adj_offsets,
582
+ )
583
+ stats_path.write_text(json.dumps(stats, indent=2), encoding="utf-8")
584
+ return features
585
+
586
+
587
+ def _make_feature_fn(tokenizer: Any, code_length: int, data_flow_length: int):
588
+ """Build the batched ``datasets.map`` function (picklable via closure)."""
589
+ seq_len = code_length + data_flow_length
590
+ cls_id, sep_id = tokenizer.cls_token_id, tokenizer.sep_token_id
591
+ pad_id, unk_id = tokenizer.pad_token_id, tokenizer.unk_token_id
592
+
593
+ def fn(batch: dict[str, list]) -> dict[str, list]:
594
+ out_ids, out_pos, out_d2c, out_d2d, out_status = [], [], [], [], []
595
+ for code in batch["code"]:
596
+ flags: list[str] = []
597
+ try:
598
+ code_tokens, dfg, status = extract_dataflow(code, DATASET_LANGUAGE)
599
+ except DataFlowExtractionError as exc:
600
+ # Never drop the example: fall back to a plain tokenisation with
601
+ # no data-flow component, and record the reason.
602
+ logger.warning("Data-flow extraction failed, using empty graph: %s", exc)
603
+ code_tokens, dfg = code.split(), []
604
+ status = {"comment_strip": "n/a", "parse": "failed", "dfg": "failed", "error": str(exc)}
605
+ for stage in ("comment_strip", "parse", "dfg"):
606
+ if status[stage] not in ("ok", "n/a"):
607
+ flags.append(f"{stage}_{status[stage]}")
608
+ if not flags:
609
+ flags.append("ok")
610
+
611
+ # GraphCodeBERT tokenises each code token separately; the '@ ' prefix
612
+ # trick forces a word-boundary BPE split for non-initial tokens.
613
+ sub_tokens = [
614
+ tokenizer.tokenize("@ " + t)[1:] if i != 0 else tokenizer.tokenize(t)
615
+ for i, t in enumerate(code_tokens)
616
+ ]
617
+ ori2cur = {-1: (0, 0)}
618
+ for i in range(len(sub_tokens)):
619
+ prev_end = ori2cur[i - 1][1]
620
+ ori2cur[i] = (prev_end, prev_end + len(sub_tokens[i]))
621
+ flat = [y for x in sub_tokens for y in x]
622
+
623
+ # Reserve room for the data-flow nodes, then for <s>/</s>.
624
+ keep = seq_len - 3 - min(len(dfg), data_flow_length)
625
+ flat = flat[:keep][: code_length - 3]
626
+
627
+ source_tokens = [tokenizer.cls_token] + flat + [tokenizer.sep_token]
628
+ source_ids = tokenizer.convert_tokens_to_ids(source_tokens)
629
+ # Code tokens get positions 2..; data-flow nodes get 0; padding gets
630
+ # pad_token_id (1). This is what the model uses to tell them apart.
631
+ position_idx = [i + pad_id + 1 for i in range(len(source_tokens))]
632
+
633
+ dfg = dfg[: seq_len - len(source_tokens)]
634
+ source_ids += [unk_id] * len(dfg)
635
+ position_idx += [0] * len(dfg)
636
+ padding = seq_len - len(source_ids)
637
+ source_ids += [pad_id] * padding
638
+ position_idx += [pad_id] * padding
639
+
640
+ # Re-index edges so they point at node slots, not original token ids.
641
+ reverse = {x[1]: i for i, x in enumerate(dfg)}
642
+ dfg_to_dfg = [[reverse[i] for i in x[-1] if i in reverse] for x in dfg]
643
+ dfg_to_code = [[ori2cur[x[1]][0] + 1, ori2cur[x[1]][1] + 1] for x in dfg]
644
+
645
+ out_ids.append(source_ids)
646
+ out_pos.append(position_idx)
647
+ out_d2c.append(dfg_to_code)
648
+ out_d2d.append(dfg_to_dfg)
649
+ out_status.append(flags)
650
+
651
+ return {
652
+ "input_ids": out_ids,
653
+ "position_idx": out_pos,
654
+ "dfg_to_code": out_d2c,
655
+ "dfg_to_dfg": out_d2d,
656
+ "status_flags": out_status,
657
+ }
658
+
659
+ return fn
660
+
661
+
662
+ # --------------------------------------------------------------------------- #
663
+ # 5. Torch dataset + graph-guided attention-mask collator
664
+ # --------------------------------------------------------------------------- #
665
+ class ClonePairDataset(Dataset):
666
+ """Pairs as ``(snippet_id1, snippet_id2, label)`` over a shared feature pool."""
667
+
668
+ def __init__(self, index: SplitIndex, features: SnippetFeatures) -> None:
669
+ self.index = index
670
+ self.features = features
671
+
672
+ def __len__(self) -> int:
673
+ return len(self.index)
674
+
675
+ def __getitem__(self, i: int) -> tuple[int, int, int]:
676
+ return (
677
+ int(self.index.snippet_id1[i]),
678
+ int(self.index.snippet_id2[i]),
679
+ int(self.index.labels[i]),
680
+ )
681
+
682
+
683
+ def build_graph_attention_mask(features: SnippetFeatures, sid: int) -> np.ndarray:
684
+ """Construct GraphCodeBERT's graph-guided masked attention for one snippet.
685
+
686
+ Four rules, exactly as in the paper:
687
+
688
+ 1. code tokens attend to code tokens;
689
+ 2. the special tokens ``<s>``/``</s>`` attend to everything real;
690
+ 3. a data-flow node attends to (and is attended by) the code tokens it was
691
+ identified from;
692
+ 4. a data-flow node attends to its adjacent nodes in the graph.
693
+ """
694
+ L = features.seq_length
695
+ mask = np.zeros((L, L), dtype=bool)
696
+ node_index = int(features.node_index[sid])
697
+ max_length = int(features.max_length[sid])
698
+ n_nodes = int(features.num_nodes[sid])
699
+
700
+ # (1) sequence attends to sequence
701
+ mask[:node_index, :node_index] = True
702
+ # (2) special tokens attend to all real positions
703
+ ids = features.input_ids[sid]
704
+ for pos in np.flatnonzero((ids == 0) | (ids == 2)):
705
+ if pos < node_index:
706
+ mask[pos, :max_length] = True
707
+ # (3) nodes <-> the code tokens they come from
708
+ d2c = features.dfg_to_code[sid]
709
+ for j in range(n_nodes):
710
+ a, b = int(d2c[j, 0]), int(d2c[j, 1])
711
+ if a < node_index and b < node_index:
712
+ mask[j + node_index, a:b] = True
713
+ mask[a:b, j + node_index] = True
714
+ # (4) nodes <-> adjacent nodes
715
+ offsets = features.dfg_adj_offsets[sid]
716
+ for j in range(n_nodes):
717
+ nbrs = features.dfg_adj_values[offsets[j] : offsets[j + 1]]
718
+ for a in nbrs:
719
+ if int(a) + node_index < L:
720
+ mask[j + node_index, int(a) + node_index] = True
721
+ return mask
722
+
723
+
724
+ @dataclass
725
+ class CloneCollator:
726
+ """Collate pairs into the tensors ``GraphCodeBERTForCloneDetection`` expects."""
727
+
728
+ features: SnippetFeatures
729
+
730
+ def __call__(self, batch: list[tuple[int, int, int]]) -> dict[str, torch.Tensor]:
731
+ ids1 = [b[0] for b in batch]
732
+ ids2 = [b[1] for b in batch]
733
+ labels = [b[2] for b in batch]
734
+ f = self.features
735
+ return {
736
+ "input_ids_1": torch.from_numpy(f.input_ids[ids1].astype(np.int64)),
737
+ "position_idx_1": torch.from_numpy(f.position_idx[ids1].astype(np.int64)),
738
+ "attn_mask_1": torch.from_numpy(
739
+ np.stack([build_graph_attention_mask(f, i) for i in ids1])
740
+ ),
741
+ "input_ids_2": torch.from_numpy(f.input_ids[ids2].astype(np.int64)),
742
+ "position_idx_2": torch.from_numpy(f.position_idx[ids2].astype(np.int64)),
743
+ "attn_mask_2": torch.from_numpy(
744
+ np.stack([build_graph_attention_mask(f, i) for i in ids2])
745
+ ),
746
+ "labels": torch.tensor(labels, dtype=torch.long),
747
+ }
748
+
749
+
750
+ # --------------------------------------------------------------------------- #
751
+ # 6. One-call pipeline used by train.py / evaluate.py
752
+ # --------------------------------------------------------------------------- #
753
+ @dataclass
754
+ class PreparedData:
755
+ train: ClonePairDataset | None
756
+ validation: ClonePairDataset
757
+ test: ClonePairDataset
758
+ features: SnippetFeatures
759
+ collator: CloneCollator
760
+ class_weights: list[float] | None
761
+ report: dict[str, Any]
762
+
763
+
764
+ def prepare_data(cfg: Config, tokenizer: Any, with_train: bool = True) -> PreparedData:
765
+ """Load, split, verify and featurise the dataset end to end."""
766
+ schema = verify_dataset_schema(cfg.dataset_name)
767
+ snippets, indices, pool_stats = build_snippet_pool(
768
+ cfg, (cfg.train_split, cfg.heldout_split)
769
+ )
770
+
771
+ train_idx = indices[cfg.train_split]
772
+ validation_idx, test_idx, split_report = split_heldout_by_group(
773
+ indices[cfg.heldout_split], cfg.test_group_fraction, cfg.seed
774
+ )
775
+
776
+ train_idx, train_sub = subsample(
777
+ train_idx, cfg.max_train_samples, cfg.seed, cfg.balance_subsamples
778
+ )
779
+ validation_idx, val_sub = subsample(
780
+ validation_idx, cfg.max_eval_samples, cfg.seed, cfg.balance_subsamples
781
+ )
782
+ test_idx, test_sub = subsample(
783
+ test_idx, cfg.max_test_samples, cfg.seed, cfg.balance_subsamples
784
+ )
785
+
786
+ _assert_no_leakage({"train": train_idx, "validation": validation_idx, "test": test_idx})
787
+
788
+ class_weights, weight_report = decide_class_weights(
789
+ train_idx, cfg.class_weighting, cfg.class_weight_threshold
790
+ )
791
+
792
+ features = build_snippet_features(cfg, snippets, tokenizer, cfg.preprocessing_num_workers)
793
+ collator = CloneCollator(features)
794
+
795
+ report = {
796
+ "schema": schema,
797
+ "snippet_pool": pool_stats,
798
+ "heldout_split_strategy": split_report,
799
+ "subsampling": {"train": train_sub, "validation": val_sub, "test": test_sub},
800
+ "class_distribution": {
801
+ "train": train_idx.class_distribution(),
802
+ "validation": validation_idx.class_distribution(),
803
+ "test": test_idx.class_distribution(),
804
+ },
805
+ "class_weighting": weight_report,
806
+ "feature_extraction": features.stats,
807
+ }
808
+
809
+ return PreparedData(
810
+ train=ClonePairDataset(train_idx, features) if with_train else None,
811
+ validation=ClonePairDataset(validation_idx, features),
812
+ test=ClonePairDataset(test_idx, features),
813
+ features=features,
814
+ collator=collator,
815
+ class_weights=class_weights,
816
+ report=report,
817
+ )
818
+
819
+
820
+ def _assert_no_leakage(splits: dict[str, SplitIndex]) -> None:
821
+ """Hard gate: no snippet and no group may appear in two splits."""
822
+ names = list(splits)
823
+ for i, a in enumerate(names):
824
+ sa = set(splits[a].snippet_ids().tolist())
825
+ ga = set(np.unique(np.concatenate([splits[a].group1, splits[a].group2])).tolist())
826
+ for b in names[i + 1 :]:
827
+ sb = set(splits[b].snippet_ids().tolist())
828
+ gb = set(np.unique(np.concatenate([splits[b].group1, splits[b].group2])).tolist())
829
+ if sa & sb:
830
+ raise AssertionError(f"LEAK: {len(sa & sb)} snippets shared by {a} and {b}.")
831
+ if ga & gb:
832
+ raise AssertionError(f"LEAK: {len(ga & gb)} groups shared by {a} and {b}.")
833
+ logger.info("Leakage check passed: splits share no snippet and no problem group.")
834
+
835
+
836
+ def iter_batches(dataset: Dataset, collator: CloneCollator, batch_size: int) -> Iterator[dict]:
837
+ """Small helper for scripts that need batches without a Trainer."""
838
+ for start in range(0, len(dataset), batch_size):
839
+ yield collator([dataset[i] for i in range(start, min(start + batch_size, len(dataset)))])
code/requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core
2
+ torch>=2.4
3
+ transformers>=5.0
4
+ datasets>=3.0
5
+ accelerate>=1.0
6
+ tokenizers>=0.20
7
+
8
+ # GraphCodeBERT data-flow extraction (tree-sitter is required by the original
9
+ # GraphCodeBERT preprocessing pipeline -- it is not an optional extra).
10
+ tree-sitter>=0.21
11
+ tree-sitter-python>=0.21
12
+
13
+ # Data / metrics
14
+ numpy>=1.24
15
+ pyarrow>=14
16
+ scikit-learn>=1.3
17
+ huggingface-hub>=0.26
config.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_cross_attention": false,
3
+ "architectures": [
4
+ "GraphCodeBERTForCloneDetection"
5
+ ],
6
+ "attention_probs_dropout_prob": 0.1,
7
+ "bos_token_id": 0,
8
+ "classifier_dropout": null,
9
+ "dtype": "float32",
10
+ "eos_token_id": 2,
11
+ "gradient_checkpointing": false,
12
+ "hidden_act": "gelu",
13
+ "hidden_dropout_prob": 0.1,
14
+ "hidden_size": 768,
15
+ "initializer_range": 0.02,
16
+ "intermediate_size": 3072,
17
+ "is_decoder": false,
18
+ "layer_norm_eps": 1e-05,
19
+ "max_position_embeddings": 514,
20
+ "model_type": "roberta",
21
+ "num_attention_heads": 12,
22
+ "num_hidden_layers": 12,
23
+ "output_past": true,
24
+ "pad_token_id": 1,
25
+ "tie_word_embeddings": true,
26
+ "transformers_version": "5.17.0",
27
+ "type_vocab_size": 1,
28
+ "use_cache": false,
29
+ "vocab_size": 50265
30
+ }
experiment_record.json ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "microsoft/graphcodebert-base",
3
+ "model_dir": "/mnt/d/work2/graphcodebert-code-clone-detection/models/graphcodebert-clone-detection",
4
+ "dataset_name": "PoolC/1-fold-clone-detection-600k-5fold",
5
+ "dataset_split_strategy": {
6
+ "train_split": "train",
7
+ "heldout_split": "val",
8
+ "note": "The repository provides one of 5 predefined folds as `train` + `val`; those groups are disjoint and are kept as-is. `val` is partitioned further into validation/test along problem-group boundaries.",
9
+ "heldout_groups": 59,
10
+ "validation_groups": 29,
11
+ "test_groups": 30,
12
+ "dropped_cross_boundary_pairs": 337398,
13
+ "validation": {
14
+ "num_examples": 483738,
15
+ "negatives_label_0": 157558,
16
+ "positives_label_1": 326180,
17
+ "positive_ratio": 0.674291,
18
+ "num_groups": 29
19
+ },
20
+ "test": {
21
+ "num_examples": 503224,
22
+ "negatives_label_0": 167224,
23
+ "positives_label_1": 336000,
24
+ "positive_ratio": 0.667695,
25
+ "num_groups": 30
26
+ }
27
+ },
28
+ "num_train_examples": 50000,
29
+ "num_validation_examples": 20000,
30
+ "num_test_examples": 20000,
31
+ "class_distributions": {
32
+ "train": {
33
+ "num_examples": 50000,
34
+ "negatives_label_0": 25000,
35
+ "positives_label_1": 25000,
36
+ "positive_ratio": 0.5,
37
+ "num_groups": 240
38
+ },
39
+ "validation": {
40
+ "num_examples": 20000,
41
+ "negatives_label_0": 10000,
42
+ "positives_label_1": 10000,
43
+ "positive_ratio": 0.5,
44
+ "num_groups": 29
45
+ },
46
+ "test": {
47
+ "num_examples": 20000,
48
+ "negatives_label_0": 10000,
49
+ "positives_label_1": 10000,
50
+ "positive_ratio": 0.5,
51
+ "num_groups": 30
52
+ }
53
+ },
54
+ "class_weighting": {
55
+ "mode": "auto",
56
+ "threshold": 0.6,
57
+ "majority_class_share": 0.5,
58
+ "applied": false,
59
+ "weights": null,
60
+ "reason": "Measured majority-class share 0.5000 is within the 0.6 threshold, so weighted cross entropy is NOT used."
61
+ },
62
+ "sequence_length": 512,
63
+ "data_flow_length": 128,
64
+ "total_sequence_length": 640,
65
+ "per_device_train_batch_size": 16,
66
+ "gradient_accumulation_steps": 1,
67
+ "effective_batch_size": 16,
68
+ "learning_rate": 2e-05,
69
+ "num_train_epochs": 3.0,
70
+ "optimizer": "adamw_torch",
71
+ "scheduler": "linear",
72
+ "warmup_ratio": 0.1,
73
+ "warmup_steps": 938,
74
+ "weight_decay": 0.01,
75
+ "max_grad_norm": 1.0,
76
+ "mixed_precision": "fp16",
77
+ "gradient_checkpointing": false,
78
+ "seed": 42,
79
+ "training_time_seconds": 6921.9,
80
+ "training_time_hours": 1.923,
81
+ "train_runtime_metrics": {
82
+ "train_runtime": 6918.8074,
83
+ "train_samples_per_second": 21.68,
84
+ "train_steps_per_second": 1.355,
85
+ "total_flos": 0.0,
86
+ "train_loss": 0.43145505716959637,
87
+ "epoch": 3.0
88
+ },
89
+ "gpu": {
90
+ "cuda_available": true,
91
+ "torch_version": "2.11.0+cu128",
92
+ "transformers_version": "5.17.0",
93
+ "python_version": "3.12.3",
94
+ "platform": "Linux-6.18.33.2-microsoft-standard-WSL2-x86_64-with-glibc2.39",
95
+ "gpu_name": "NVIDIA GeForce RTX 5060 Ti",
96
+ "gpu_count": 1,
97
+ "gpu_total_memory_gb": 15.9,
98
+ "gpu_capability": "12.0",
99
+ "cuda_version": "12.8"
100
+ },
101
+ "parameters": {
102
+ "trainable_parameters": 125236994,
103
+ "total_parameters": 125236994
104
+ },
105
+ "best_validation_f1": 0.8671882190520018,
106
+ "best_checkpoint": "./outputs/checkpoint-9000",
107
+ "validation_metrics": {
108
+ "loss": 0.34459105134010315,
109
+ "accuracy": 0.8557,
110
+ "precision": 0.8032395566922421,
111
+ "recall": 0.9422,
112
+ "f1": 0.8671882190520018,
113
+ "macro_f1": 0.8546121719233737,
114
+ "tp": 9422,
115
+ "tn": 7692,
116
+ "fp": 2308,
117
+ "fn": 578,
118
+ "confusion_matrix": [
119
+ [
120
+ 7692,
121
+ 2308
122
+ ],
123
+ [
124
+ 578,
125
+ 9422
126
+ ]
127
+ ],
128
+ "confusion_matrix_layout": "[[TN, FP], [FN, TP]]",
129
+ "support": {
130
+ "num_examples": 20000,
131
+ "label_0": 10000,
132
+ "label_1": 10000
133
+ },
134
+ "runtime": 173.9737,
135
+ "samples_per_second": 114.96,
136
+ "steps_per_second": 3.592,
137
+ "num_examples": 20000,
138
+ "eval_seconds": 174.0
139
+ },
140
+ "test_metrics": {
141
+ "loss": 0.3165612816810608,
142
+ "model_preparation_time": 0.0022,
143
+ "accuracy": 0.87465,
144
+ "precision": 0.8409939018840448,
145
+ "recall": 0.924,
146
+ "f1": 0.8805450993472149,
147
+ "macro_f1": 0.874343974488208,
148
+ "tp": 9240,
149
+ "tn": 8253,
150
+ "fp": 1747,
151
+ "fn": 760,
152
+ "confusion_matrix": [
153
+ [
154
+ 8253,
155
+ 1747
156
+ ],
157
+ [
158
+ 760,
159
+ 9240
160
+ ]
161
+ ],
162
+ "confusion_matrix_layout": "[[TN, FP], [FN, TP]]",
163
+ "support": {
164
+ "num_examples": 20000,
165
+ "label_0": 10000,
166
+ "label_1": 10000
167
+ },
168
+ "runtime": 181.6455,
169
+ "samples_per_second": 110.105,
170
+ "steps_per_second": 3.441,
171
+ "num_examples": 20000,
172
+ "eval_seconds": 181.7
173
+ },
174
+ "preprocessing": {
175
+ "snippet_pool": {
176
+ "num_unique_snippets": 44950,
177
+ "scan_seconds": 46.3,
178
+ "snippets_with_multiple_groups": 1,
179
+ "cross_split_snippet_overlap": {
180
+ "train|val:snippets": 0,
181
+ "train|val:groups": 0
182
+ },
183
+ "per_split": {
184
+ "train": {
185
+ "num_examples": 5388622,
186
+ "negatives_label_0": 2694311,
187
+ "positives_label_1": 2694311,
188
+ "positive_ratio": 0.5,
189
+ "num_groups": 240
190
+ },
191
+ "val": {
192
+ "num_examples": 1324360,
193
+ "negatives_label_0": 662180,
194
+ "positives_label_1": 662180,
195
+ "positive_ratio": 0.5,
196
+ "num_groups": 59
197
+ }
198
+ }
199
+ },
200
+ "feature_extraction": {
201
+ "num_snippets": 44950,
202
+ "extraction_seconds": 64.1,
203
+ "status_counts": {
204
+ "ok": 44930,
205
+ "comment_strip_failed": 13,
206
+ "dfg_failed": 7
207
+ },
208
+ "snippets_with_empty_dataflow": 263,
209
+ "dataflow_nodes_mean": 44.22,
210
+ "dataflow_nodes_p50": 33,
211
+ "dataflow_nodes_p95": 127,
212
+ "dataflow_nodes_max": 193,
213
+ "code_tokens_mean": 137.9,
214
+ "code_tokens_truncated": 885,
215
+ "total_dataflow_edges": 2481388,
216
+ "sequence_length": 640
217
+ },
218
+ "subsampling": {
219
+ "train": {
220
+ "subsampled": true,
221
+ "kept": 50000,
222
+ "balanced": true
223
+ },
224
+ "validation": {
225
+ "subsampled": true,
226
+ "kept": 20000,
227
+ "balanced": true
228
+ },
229
+ "test": {
230
+ "subsampled": true,
231
+ "kept": 20000,
232
+ "balanced": true
233
+ }
234
+ }
235
+ },
236
+ "sanity_check": {
237
+ "passed": true,
238
+ "device": "cuda",
239
+ "checks": [
240
+ {
241
+ "check": "1_dataset_loading",
242
+ "passed": true,
243
+ "detail": "train=50000 val=20000 test=20000"
244
+ },
245
+ {
246
+ "check": "2_column_detection",
247
+ "passed": true,
248
+ "detail": "code columns=code1/code2, label=similar; excluded from features: ['code1_group', 'code2_group', 'pair_id', 'question_pair_id']"
249
+ },
250
+ {
251
+ "check": "3_label_correctness",
252
+ "passed": true,
253
+ "detail": "labels in {0,1}; validation positive ratio=0.5"
254
+ },
255
+ {
256
+ "check": "4_tokenisation",
257
+ "passed": true,
258
+ "detail": "<s>...</s> wrapping OK, 93 code tokens, decodes to 'import math a , b , c = map ( int , input ( ) . split ( ) ) '"
259
+ },
260
+ {
261
+ "check": "5_dataflow_extraction",
262
+ "passed": true,
263
+ "detail": "44687/44950 snippets have a non-empty data-flow graph, 2481388 edges, mean nodes=44.22"
264
+ },
265
+ {
266
+ "check": "6_attention_mask",
267
+ "passed": true,
268
+ "detail": "shape=(640, 640) (code_length 512 + data_flow_length 128), 93 code tokens, 30 nodes, density=0.0215, padding rows empty"
269
+ },
270
+ {
271
+ "check": "7_forward_pass",
272
+ "passed": true,
273
+ "detail": "logits shape=(16, 2) for batch of 16 pairs; mask tensor=(16, 640, 640)"
274
+ },
275
+ {
276
+ "check": "8_finite_loss",
277
+ "passed": true,
278
+ "detail": "loss=0.6978 (chance level ~0.6931)"
279
+ },
280
+ {
281
+ "check": "9_training_step",
282
+ "passed": true,
283
+ "detail": "loss=0.6798, grad_norm=2.4929, weights updated (amp=on, dtype=torch.float16)"
284
+ }
285
+ ],
286
+ "parameters": {
287
+ "trainable_parameters": 125236994,
288
+ "total_parameters": 125236994
289
+ },
290
+ "sequence_length": 640
291
+ },
292
+ "test_metrics_provenance": {
293
+ "note": "Re-scored after fixing a transformers v5 dataloader-caching bug: Trainer.get_eval_dataloader caches under the key 'eval' when a Dataset object is passed and dataloader_persistent_workers=True, so the original in-training test evaluation silently re-scored the validation split. evaluate_split now uses Trainer.predict and verifies the returned labels.",
294
+ "superseded_test_f1": 0.8671882190520018,
295
+ "corrected_test_f1": 0.8805450993472149,
296
+ "validation_f1_unchanged": 0.8671882190520018
297
+ }
298
+ }
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1bb8855bff29d33fc1d8d32f58798090a0e1ce3844d01de4c4c7774367caf59e
3
+ size 500972120
tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer_config.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": false,
3
+ "backend": "tokenizers",
4
+ "bos_token": "<s>",
5
+ "cls_token": "<s>",
6
+ "eos_token": "</s>",
7
+ "errors": "replace",
8
+ "is_local": false,
9
+ "local_files_only": false,
10
+ "mask_token": "<mask>",
11
+ "model_max_length": 512,
12
+ "pad_token": "<pad>",
13
+ "sep_token": "</s>",
14
+ "tokenizer_class": "RobertaTokenizer",
15
+ "trim_offsets": true,
16
+ "unk_token": "<unk>"
17
+ }
training_args.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fcb3040d4b2288a3bef88cf246696bb9bf7e83e565d1e69fc374507038fc06b1
3
+ size 5201
training_config.json ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "dataset_name": "PoolC/1-fold-clone-detection-600k-5fold",
3
+ "train_split": "train",
4
+ "heldout_split": "val",
5
+ "test_group_fraction": 0.5,
6
+ "max_train_samples": 50000,
7
+ "max_eval_samples": 20000,
8
+ "max_test_samples": 20000,
9
+ "balance_subsamples": true,
10
+ "model_name_or_path": "microsoft/graphcodebert-base",
11
+ "code_length": 512,
12
+ "data_flow_length": 128,
13
+ "attn_implementation": "sdpa",
14
+ "learning_rate": 2e-05,
15
+ "num_train_epochs": 3.0,
16
+ "per_device_train_batch_size": 16,
17
+ "per_device_eval_batch_size": 32,
18
+ "gradient_accumulation_steps": 1,
19
+ "weight_decay": 0.01,
20
+ "warmup_ratio": 0.1,
21
+ "max_grad_norm": 1.0,
22
+ "fp16": true,
23
+ "bf16": false,
24
+ "gradient_checkpointing": false,
25
+ "optim": "adamw_torch",
26
+ "lr_scheduler_type": "linear",
27
+ "class_weighting": "auto",
28
+ "class_weight_threshold": 0.6,
29
+ "eval_strategy": "steps",
30
+ "eval_steps": 1000,
31
+ "save_strategy": "steps",
32
+ "save_steps": 1000,
33
+ "save_total_limit": 2,
34
+ "logging_steps": 100,
35
+ "metric_for_best_model": "f1",
36
+ "greater_is_better": true,
37
+ "load_best_model_at_end": true,
38
+ "seed": 42,
39
+ "full_determinism": false,
40
+ "dataloader_num_workers": 4,
41
+ "preprocessing_num_workers": 8,
42
+ "output_dir": "./outputs",
43
+ "model_dir": "./models/graphcodebert-clone-detection",
44
+ "logging_dir": "./logs",
45
+ "cache_dir": "./outputs/feature_cache",
46
+ "report_to": "none",
47
+ "run_sanity_check": true,
48
+ "sanity_check_samples": 64,
49
+ "total_sequence_length": 640,
50
+ "effective_batch_size": 16
51
+ }