Yakk99 commited on
Commit
1b2a5c1
·
verified ·
1 Parent(s): a1e4562

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -17,5 +17,20 @@ research highlights from scientific paper abstracts (MixSub corpus).
17
  - Training: one epoch on 15,960 pairs, Kaggle dual T4
18
  - Validation ROUGE-L F1 (plain LCS, no stemming): **26.37**
19
 
20
- Pipeline and evaluation protocol: see the `final/` directory of the
21
- project repository. Trained 2026-08-08.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  - Training: one epoch on 15,960 pairs, Kaggle dual T4
18
  - Validation ROUGE-L F1 (plain LCS, no stemming): **26.37**
19
 
20
+ ## Repository contents
21
+
22
+ | file | purpose |
23
+ | --- | --- |
24
+ | `train_final.py` | standalone training recipe that produced this model |
25
+ | `train_final.ipynb` | notebook version of the same recipe |
26
+ | `final/` | full data pipeline: abstract recovery, title fetch, dataset build, submission validation |
27
+
28
+ ## Data pipeline summary
29
+
30
+ ~40% of corpus abstracts are truncated mid-sentence. Each row was matched to
31
+ its ScienceDirect PII (100% verified join), resolved to a DOI via Elsevier's
32
+ keyless API, and its complete abstract recovered from Semantic Scholar under
33
+ a label-free >=90%-token-overlap acceptance filter (~95% of truncated rows
34
+ repaired). Paper titles were fetched for 100% of rows and prepended to
35
+ inputs. See `final/README.md` for the full pipeline and negative-results
36
+ summary. Trained 2026-08-08.
final/README.md ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SciHigh 2026 Subtask 1 — Final Pipeline
2
+
3
+ Team Yushkk99. Task: generate research highlights from paper abstracts
4
+ (MixSub corpus, 19,785 ScienceDirect papers). Official metric: ROUGE-L F1.
5
+
6
+ ## Results (validation, 1,985 rows, plain rougeL / no stemming)
7
+
8
+ | system | val ROUGE-L |
9
+ | --- | --- |
10
+ | FIRE-2025 winning system (reference) | 23.45 |
11
+ | bart-large-cnn, original data | 24.37 |
12
+ | bart-large-cnn, repaired data + titles (run 1) | 26.37 |
13
+ | **Qwen2.5-72B QLoRA, repaired data + titles (run 2)** | **28.33** |
14
+
15
+ ## Pipeline
16
+
17
+ Data enrichment (all sources free and sanctioned; test rows touched only
18
+ via abstract/title metadata — never highlight fields):
19
+
20
+ 1. `recover_abstracts.py` — ~40% of corpus abstracts are truncated
21
+ mid-sentence. Each row is matched to its ScienceDirect PII (verified
22
+ 100% by abstract-text join against the dataset's source repository),
23
+ resolved to a DOI via Elsevier's keyless Article API, and its complete
24
+ abstract fetched from Semantic Scholar. A label-free acceptance filter
25
+ (recovered text must contain >=90% of the truncated text's tokens)
26
+ guarantees same-paper extensions and structurally excludes records
27
+ containing highlight fields. Acceptance: ~95% of truncated rows.
28
+ 2. `fetch_titles.py` — paper titles for 100% of rows via the same keyless
29
+ PII lookup.
30
+ 3. `build_datasets.py` — rebuilds all splits with recovered abstracts
31
+ normalized to the corpus's punctuation-stripped format (transform
32
+ validated token-for-token against 5,481 known prefixes, mean agreement
33
+ 0.987), then prepends titles: `<title> | <abstract>`.
34
+
35
+ Modeling:
36
+
37
+ 4. `train_qwen_qlora.py` — one-epoch QLoRA SFT (4-bit NF4, LoRA r=16 on
38
+ attention+MLP) of a Qwen2.5 Instruct base on the 15,960-pair titled
39
+ pool, chat-formatted, greedy decoding. The identical script trains the
40
+ 7B (24GB GPU) and 72B (80GB GPU) variants; only `--base` changes.
41
+ 5. `infer_test.py` — greedy test-set inference producing the submission CSV.
42
+ 6. `validate_submission.py` — format gate: row count, column schema, no
43
+ empty predictions, length statistics.
44
+
45
+ Run 1 (bart lineage) reproduces via the Kaggle kernels
46
+ (`res-3-title-prefix`) recorded in the project artifacts.
47
+
48
+ ## Models
49
+
50
+ - `Yakk99/scihigh2026-subtask1-qwen72b-qlora` — run-2 adapter (72B)
51
+ - `Yakk99/scihigh2026-subtask1-qwen7b-qlora` — 7B pilot adapter
52
+ - `Yakk99/scihigh2026-subtask1-bart-titled` — run-1 full model
53
+
54
+ ## Negative results (tested and closed, full logs in project artifacts)
55
+
56
+ BRIO contrastive calibration (two decode-matched attempts, ~0 delta);
57
+ MBR/DPO preference signal on the 72B pool (−2.0 — sampled candidates
58
+ cluster below the greedy mode of a well-trained model); oracle bullet
59
+ reordering (+0.0002); GRPO (rejected on published scientific-domain
60
+ negatives); RAG (measured negative on this exact corpus by a FIRE-2025
61
+ team).
final/build_datasets.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the final training/eval datasets.
2
+
3
+ Stage 1 — repair: replace each accepted truncated abstract with its
4
+ recovered text, normalized to the corpus format (the corpus strips all
5
+ punctuation except periods; sentence-final periods are free-standing ' . '
6
+ tokens; intra-token periods like '94.5' survive). The transform is
7
+ validated against every accepted pair by comparing the normalized recovered
8
+ prefix to the corpus text we already hold.
9
+
10
+ Stage 2 — titles: prepend the paper title, `<title> | <abstract>`, applied
11
+ identically to every split.
12
+
13
+ Usage: python build_datasets.py
14
+ Inputs: data/raw/*, data/external/{recovered_*,pii_title_cache.jsonl,*_with_pii.csv}
15
+ Outputs: data/recovered/{train,train_expanded,val,test}[_titled].csv
16
+ """
17
+
18
+ import json
19
+ import re
20
+ import unicodedata
21
+ from difflib import SequenceMatcher
22
+ from pathlib import Path
23
+
24
+ import pandas as pd
25
+
26
+ REPO = Path(__file__).resolve().parent.parent
27
+ EXT = REPO / "data/external"
28
+ OUT = REPO / "data/recovered"
29
+
30
+ DASHES = "‐‑‒–—―−"
31
+ SECTION_HEADERS = re.compile(
32
+ r"\b(BACKGROUND|METHODS?|RESULTS?|CONCLUSIONS?|OBJECTIVES?|PURPOSE"
33
+ r"|INTRODUCTION|RATIONALE|AIMS?|DISCUSSION|MATERIALS|SIGNIFICANCE"
34
+ r"|FINDINGS|INTERPRETATION|SETTING|DESIGN|PARTICIPANTS|MEASUREMENTS"
35
+ r"|LIMITATIONS|IMPLICATIONS|HYPOTHESIS|UNLABELLED|IMPORTANCE"
36
+ r"|EXPOSURES?|OUTCOMES?)(\s+AND\s+[A-Z]{4,})?\b[:.]?")
37
+
38
+
39
+ def strip_to_corpus_format(text):
40
+ t = unicodedata.normalize("NFKC", str(text))
41
+ t = SECTION_HEADERS.sub("", t)
42
+ t = t.replace("!", ".").replace("?", ".")
43
+ t = re.sub(r"(?<=\S)\.(?=\S)", "\x00", t)
44
+ t = t.replace(".", " . ").replace("\x00", ".")
45
+ t = t.replace("-", " ")
46
+ for d in DASHES:
47
+ t = t.replace(d, "")
48
+ t = re.sub(r"[^A-Za-z0-9. ]", "", t)
49
+ return " ".join(t.split())
50
+
51
+
52
+ def validate_transform():
53
+ scores = []
54
+ for split in ("train", "val", "test"):
55
+ rec = pd.read_csv(EXT / f"recovered_{split}.csv")
56
+ for _, r in rec[rec["accepted"]].iterrows():
57
+ a = str(r["Abstract"]).split()[:-2]
58
+ b = strip_to_corpus_format(r["RecoveredAbstract"]).split()[: len(a) + 20]
59
+ m = SequenceMatcher(None, a, b, autojunk=False)
60
+ scores.append(sum(bl.size for bl in m.get_matching_blocks()) / max(len(a), 1))
61
+ mean = sum(scores) / len(scores)
62
+ print(f"transform validation: {len(scores)} pairs, mean token agreement {mean:.4f}")
63
+ assert mean >= 0.90, "transform does not reproduce corpus format"
64
+
65
+
66
+ def load_titles():
67
+ titles = {}
68
+ for line in (EXT / "pii_title_cache.jsonl").read_text().splitlines():
69
+ try:
70
+ r = json.loads(line)
71
+ if r.get("title"):
72
+ titles[r["pii"]] = r["title"]
73
+ except (json.JSONDecodeError, KeyError):
74
+ continue
75
+ return titles
76
+
77
+
78
+ def clean_title(raw):
79
+ t = strip_to_corpus_format(raw)
80
+ while t.endswith(" ."):
81
+ t = t[:-2].rstrip()
82
+ return t
83
+
84
+
85
+ def main():
86
+ validate_transform()
87
+ OUT.mkdir(exist_ok=True)
88
+ titles = load_titles()
89
+
90
+ repl = {}
91
+ for split in ("train", "val", "test"):
92
+ rec = pd.read_csv(EXT / f"recovered_{split}.csv")
93
+ acc = rec[rec["accepted"]]
94
+ for pii, a in zip(acc["PII"], acc["RecoveredAbstract"]):
95
+ repl[pii] = strip_to_corpus_format(a)
96
+ extra = EXT / "recovered_expanded_extra.csv"
97
+ if extra.exists():
98
+ rec = pd.read_csv(extra)
99
+ acc = rec[rec["accepted"]]
100
+ for pii, a in zip(acc["PII"], acc["RecoveredAbstract"]):
101
+ repl[pii] = strip_to_corpus_format(a)
102
+
103
+ pii_by_split = {
104
+ s: dict(zip(*(lambda d: (d["Filename"], d["PII"]))(
105
+ pd.read_csv(EXT / f"{s}_with_pii.csv"))))
106
+ for s in ("train", "val", "test")
107
+ }
108
+
109
+ for name in ("train", "val", "test", "train_expanded"):
110
+ df = pd.read_csv(REPO / f"data/raw/{name}.csv")
111
+ piis = (df["PaperID"] if name == "train_expanded"
112
+ else df["Filename"].map(pii_by_split[name.replace("_expanded", "")]))
113
+ df["Abstract"] = [repl.get(p, a) for p, a in zip(piis, df["Abstract"])]
114
+ df.to_csv(OUT / f"{name}.csv", index=False)
115
+
116
+ titled = []
117
+ for pii, ab in zip(piis, df["Abstract"]):
118
+ t = titles.get(pii)
119
+ titled.append(f"{clean_title(t)} | {ab}" if t else str(ab))
120
+ df_t = df.copy()
121
+ df_t["Abstract"] = titled
122
+ df_t.to_csv(OUT / f"{name}_titled.csv", index=False)
123
+
124
+ trunc = (~df["Abstract"].astype(str).str.rstrip().str.endswith(".")).mean()
125
+ print(f"{name}: {len(df)} rows, residual truncation {trunc:.1%}, "
126
+ f"titled variant written")
127
+ return 0
128
+
129
+
130
+ if __name__ == "__main__":
131
+ raise SystemExit(main())
final/fetch_titles.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fetch paper titles for all corpus rows via the keyless Elsevier PII
2
+ lookup. Titles are open bibliographic metadata and return for 100% of rows
3
+ regardless of paywall status. Cache is append-only JSONL; resumable.
4
+
5
+ Usage: python fetch_titles.py --splits val test expanded
6
+ Outputs: data/external/pii_title_cache.jsonl
7
+ """
8
+
9
+ import argparse
10
+ import json
11
+ import ssl
12
+ import time
13
+ import urllib.error
14
+ import urllib.request
15
+ from pathlib import Path
16
+
17
+ import certifi
18
+ import pandas as pd
19
+
20
+ REPO = Path(__file__).resolve().parent.parent
21
+ CACHE = REPO / "data/external/pii_title_cache.jsonl"
22
+ SSL_CTX = ssl.create_default_context(cafile=certifi.where())
23
+ UA = "scihigh2026-titles"
24
+
25
+
26
+ def fetch_title(pii):
27
+ req = urllib.request.Request(
28
+ f"https://api.elsevier.com/content/article/pii/{pii}",
29
+ headers={"User-Agent": UA, "Accept": "application/json"})
30
+ for attempt in range(4):
31
+ try:
32
+ with urllib.request.urlopen(req, timeout=45, context=SSL_CTX) as r:
33
+ d = json.loads(r.read())
34
+ return d["full-text-retrieval-response"]["coredata"].get("dc:title")
35
+ except urllib.error.HTTPError as e:
36
+ if e.code == 404:
37
+ return None
38
+ time.sleep(8 * (attempt + 1))
39
+ except Exception:
40
+ time.sleep(8 * (attempt + 1))
41
+ return None
42
+
43
+
44
+ def main():
45
+ ap = argparse.ArgumentParser()
46
+ ap.add_argument("--splits", nargs="+", default=["val", "test", "expanded"])
47
+ args = ap.parse_args()
48
+
49
+ piis = []
50
+ for split in args.splits:
51
+ if split == "expanded":
52
+ piis += pd.read_csv(REPO / "data/raw/train_expanded.csv")["PaperID"].dropna().tolist()
53
+ else:
54
+ piis += pd.read_csv(REPO / f"data/external/{split}_with_pii.csv")["PII"].dropna().tolist()
55
+
56
+ cached = set()
57
+ if CACHE.exists():
58
+ for line in CACHE.read_text().splitlines():
59
+ try:
60
+ cached.add(json.loads(line)["pii"])
61
+ except (json.JSONDecodeError, KeyError):
62
+ continue
63
+ todo = [p for p in dict.fromkeys(piis) if p not in cached]
64
+ print(f"{len(piis)} rows requested, {len(todo)} to fetch")
65
+
66
+ with CACHE.open("a") as f:
67
+ for i, pii in enumerate(todo, 1):
68
+ f.write(json.dumps({"pii": pii, "title": fetch_title(pii)}) + "\n")
69
+ time.sleep(0.15)
70
+ if i % 500 == 0:
71
+ print(f" {i}/{len(todo)}", flush=True)
72
+ print("done")
73
+ return 0
74
+
75
+
76
+ if __name__ == "__main__":
77
+ raise SystemExit(main())
final/infer_test.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate the test-set submission CSV from a trained Qwen adapter.
2
+
3
+ Greedy decoding over `<title> | <abstract>` test inputs, output formatted as
4
+ `Filename, Abstract, Predicted_Highlights` per the task specification. The
5
+ Abstract column carries the OFFICIAL (unmodified) test abstracts.
6
+
7
+ python infer_test.py --base unsloth/Qwen2.5-72B-Instruct-bnb-4bit \\
8
+ --adapter qwen72_out/adapter --test-titled test_titled.csv \\
9
+ --test-official test.csv --out Yushkk99_Task1_run2.csv
10
+ """
11
+
12
+ import argparse
13
+ import subprocess
14
+ import sys
15
+ import time
16
+
17
+
18
+ def pip(*p):
19
+ subprocess.run([sys.executable, "-m", "pip", "install", "-q", *p], check=True)
20
+
21
+
22
+ pip("transformers>=4.51", "peft", "bitsandbytes", "pandas", "accelerate")
23
+
24
+ import pandas as pd # noqa: E402
25
+ import torch # noqa: E402
26
+ from peft import PeftModel # noqa: E402
27
+ from transformers import (AutoModelForCausalLM, AutoTokenizer, # noqa: E402
28
+ BitsAndBytesConfig)
29
+
30
+ SYS = ("You write research highlights. Given a paper title and abstract "
31
+ "(separated by |), produce 3-5 short highlight sentences in the exact "
32
+ "style of the examples you were trained on: no punctuation except "
33
+ "sentence-final periods written as ' . ', ~55 words total, each "
34
+ "sentence stating one contribution or finding.")
35
+
36
+
37
+ def main():
38
+ ap = argparse.ArgumentParser()
39
+ ap.add_argument("--base", required=True)
40
+ ap.add_argument("--adapter", required=True)
41
+ ap.add_argument("--test-titled", required=True)
42
+ ap.add_argument("--test-official", required=True)
43
+ ap.add_argument("--out", default="submission.csv")
44
+ args = ap.parse_args()
45
+
46
+ tok = AutoTokenizer.from_pretrained(args.base)
47
+ model = AutoModelForCausalLM.from_pretrained(
48
+ args.base,
49
+ quantization_config=BitsAndBytesConfig(
50
+ load_in_4bit=True, bnb_4bit_quant_type="nf4",
51
+ bnb_4bit_compute_dtype=torch.bfloat16,
52
+ bnb_4bit_use_double_quant=True),
53
+ dtype=torch.bfloat16, device_map="auto")
54
+ model = PeftModel.from_pretrained(model, args.adapter)
55
+ model.eval()
56
+
57
+ titled = pd.read_csv(args.test_titled)
58
+ official = pd.read_csv(args.test_official)
59
+ preds = []
60
+ t0 = time.time()
61
+ with torch.no_grad():
62
+ for i in range(0, len(titled), 8):
63
+ batch = titled.iloc[i : i + 8]
64
+ prompts = [tok.apply_chat_template(
65
+ [{"role": "system", "content": SYS},
66
+ {"role": "user", "content": str(a)}],
67
+ tokenize=False, add_generation_prompt=True)
68
+ for a in batch["Abstract"]]
69
+ enc = tok(prompts, return_tensors="pt", padding=True,
70
+ truncation=True, max_length=1024,
71
+ padding_side="left").to("cuda")
72
+ out = model.generate(**enc, max_new_tokens=130, do_sample=False,
73
+ pad_token_id=tok.pad_token_id)
74
+ preds += [tok.decode(out[j][enc.input_ids.shape[1]:],
75
+ skip_special_tokens=True).strip()
76
+ for j in range(len(batch))]
77
+ if (i // 8) % 20 == 0:
78
+ rate = (i + len(batch)) / max(time.time() - t0, 1)
79
+ print(f"{i + len(batch)}/{len(titled)} "
80
+ f"(eta {(len(titled) - i) / max(rate, 1e-9) / 60:.0f}m)",
81
+ flush=True)
82
+
83
+ sub = pd.DataFrame({
84
+ "Filename": titled["Filename"],
85
+ "Abstract": official.set_index("Filename").loc[
86
+ titled["Filename"], "Abstract"].values,
87
+ "Predicted_Highlights": preds,
88
+ })
89
+ sub.to_csv(args.out, index=False)
90
+ print(f"wrote {args.out}: {len(sub)} rows")
91
+ return 0
92
+
93
+
94
+ if __name__ == "__main__":
95
+ raise SystemExit(main())
final/recover_abstracts.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Recover complete abstracts for truncated corpus rows.
2
+
3
+ Chain: CSV row -> ScienceDirect PII (pre-verified join tables) -> DOI
4
+ (keyless Elsevier Article API) -> abstract (Semantic Scholar /paper/batch).
5
+
6
+ Acceptance filter (label-free): the recovered abstract must contain >=90%
7
+ of the truncated text's content tokens, be strictly longer, and end in
8
+ sentence-final punctuation. Both network stages checkpoint to JSONL caches,
9
+ so the sweep is resumable.
10
+
11
+ Usage: python recover_abstracts.py --splits val train test
12
+ Inputs: data/external/{split}_with_pii.csv
13
+ Outputs: data/external/recovered_{split}.csv
14
+ """
15
+
16
+ import argparse
17
+ import json
18
+ import re
19
+ import ssl
20
+ import sys
21
+ import time
22
+ import urllib.error
23
+ import urllib.parse
24
+ import urllib.request
25
+ from pathlib import Path
26
+
27
+ import certifi
28
+ import pandas as pd
29
+
30
+ REPO = Path(__file__).resolve().parent.parent
31
+ EXT = REPO / "data/external"
32
+ SSL_CTX = ssl.create_default_context(cafile=certifi.where())
33
+ UA = "scihigh2026-recovery"
34
+ SENTENCE_FINAL = (".", "!", "?")
35
+ DOI_CACHE = EXT / "pii_doi_cache.jsonl"
36
+ S2_CACHE = EXT / "s2_abstract_cache.jsonl"
37
+
38
+
39
+ def is_truncated(text):
40
+ return not str(text).rstrip().endswith(SENTENCE_FINAL)
41
+
42
+
43
+ def toks(t):
44
+ return re.findall(r"[a-z0-9]+", str(t).lower())
45
+
46
+
47
+ def load_cache(path, key):
48
+ cache = {}
49
+ if path.exists():
50
+ for line in path.read_text().splitlines():
51
+ try:
52
+ rec = json.loads(line)
53
+ cache[rec[key]] = rec
54
+ except (json.JSONDecodeError, KeyError):
55
+ continue
56
+ return cache
57
+
58
+
59
+ def append_cache(path, rec):
60
+ with path.open("a") as f:
61
+ f.write(json.dumps(rec) + "\n")
62
+
63
+
64
+ def request_json(url, data=None, retries=6):
65
+ headers = {"User-Agent": UA, "Accept": "application/json"}
66
+ if data is not None:
67
+ headers["Content-Type"] = "application/json"
68
+ for attempt in range(retries):
69
+ req = urllib.request.Request(url, data=data, headers=headers)
70
+ try:
71
+ with urllib.request.urlopen(req, timeout=60, context=SSL_CTX) as r:
72
+ return json.loads(r.read())
73
+ except urllib.error.HTTPError as e:
74
+ if e.code in (404, 400):
75
+ return None
76
+ time.sleep(10 * (attempt + 1))
77
+ except Exception:
78
+ time.sleep(10 * (attempt + 1))
79
+ return None
80
+
81
+
82
+ def resolve_dois(piis):
83
+ cache = load_cache(DOI_CACHE, "pii")
84
+ todo = [p for p in piis if p not in cache]
85
+ print(f"PII->DOI: {len(piis) - len(todo)} cached, {len(todo)} to resolve", flush=True)
86
+ for i, pii in enumerate(todo, 1):
87
+ d = request_json(f"https://api.elsevier.com/content/article/pii/{pii}", retries=3)
88
+ try:
89
+ doi = d["full-text-retrieval-response"]["coredata"]["prism:doi"]
90
+ except (KeyError, TypeError):
91
+ doi = None
92
+ rec = {"pii": pii, "doi": doi}
93
+ cache[pii] = rec
94
+ append_cache(DOI_CACHE, rec)
95
+ time.sleep(0.15)
96
+ if i % 500 == 0:
97
+ print(f" {i}/{len(todo)}", flush=True)
98
+ return {p: cache[p]["doi"] for p in piis if p in cache}
99
+
100
+
101
+ def fetch_s2(dois):
102
+ cache = load_cache(S2_CACHE, "doi")
103
+ todo = [d for d in dois if d not in cache]
104
+ print(f"S2 abstracts: {len(dois) - len(todo)} cached, {len(todo)} to fetch", flush=True)
105
+ for start in range(0, len(todo), 100):
106
+ chunk = todo[start : start + 100]
107
+ payload = json.dumps({"ids": [f"DOI:{d}" for d in chunk]}).encode()
108
+ res = request_json(
109
+ "https://api.semanticscholar.org/graph/v1/paper/batch?fields=abstract",
110
+ data=payload)
111
+ recs = res if isinstance(res, list) else [None] * len(chunk)
112
+ for doi, rec in zip(chunk, recs):
113
+ abstract = None
114
+ if isinstance(rec, dict) and rec.get("abstract"):
115
+ abstract = " ".join(rec["abstract"].split())
116
+ out = {"doi": doi, "abstract": abstract}
117
+ cache[doi] = out
118
+ append_cache(S2_CACHE, out)
119
+ time.sleep(3.0)
120
+ return {d: cache[d]["abstract"] for d in dois if d in cache}
121
+
122
+
123
+ def accept(ours, theirs):
124
+ if not theirs:
125
+ return False, 0.0
126
+ cleaned = re.sub(r"^\s*Abstract\s+", "", theirs)
127
+ ours_t = toks(ours)
128
+ theirs_set = set(toks(cleaned))
129
+ cov = sum(1 for t in ours_t if t in theirs_set) / max(len(ours_t), 1)
130
+ ok = (cov >= 0.90
131
+ and len(cleaned) > len(str(ours)) * 1.05
132
+ and not is_truncated(cleaned))
133
+ return ok, cov
134
+
135
+
136
+ def main():
137
+ ap = argparse.ArgumentParser()
138
+ ap.add_argument("--splits", nargs="+", default=["val", "train", "test"])
139
+ args = ap.parse_args()
140
+
141
+ frames = {}
142
+ all_piis = []
143
+ for split in args.splits:
144
+ df = pd.read_csv(EXT / f"{split}_with_pii.csv")
145
+ df = df[df["PII"].notna()].copy()
146
+ df["is_trunc"] = df["Abstract"].map(is_truncated)
147
+ frames[split] = df
148
+ all_piis += df.loc[df["is_trunc"], "PII"].tolist()
149
+
150
+ doi_map = resolve_dois(all_piis)
151
+ s2_map = fetch_s2(sorted({d for d in doi_map.values() if d}))
152
+
153
+ for split, df in frames.items():
154
+ recs = []
155
+ for _, r in df[df["is_trunc"]].iterrows():
156
+ doi = doi_map.get(r["PII"])
157
+ theirs = s2_map.get(doi) if doi else None
158
+ ok, cov = accept(r["Abstract"], theirs)
159
+ cleaned = re.sub(r"^\s*Abstract\s+", "", theirs) if theirs else None
160
+ recs.append({
161
+ "Filename": r["Filename"], "PII": r["PII"], "DOI": doi,
162
+ "Abstract": r["Abstract"],
163
+ "RecoveredAbstract": cleaned if ok else None,
164
+ "coverage": round(cov, 3), "accepted": ok,
165
+ })
166
+ out = pd.DataFrame(recs)
167
+ out.to_csv(EXT / f"recovered_{split}.csv", index=False)
168
+ print(f"{split}: {len(out)} truncated, {int(out['accepted'].sum())} "
169
+ f"accepted ({out['accepted'].mean():.1%})")
170
+ return 0
171
+
172
+
173
+ if __name__ == "__main__":
174
+ raise SystemExit(main())
final/train_qwen_qlora.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """QLoRA supervised fine-tuning of a Qwen2.5 Instruct model for research
2
+ highlight generation.
3
+
4
+ Input rows: Abstract = `<title> | <repaired abstract>`, Highlights = target.
5
+ 4-bit NF4 base + LoRA (r=16, attention+MLP), bf16 compute, one epoch,
6
+ chat-formatted. Evaluates plain ROUGE-L (stemmer=False) on a fixed
7
+ stratified 300-row validation subset, then on full validation. Greedy
8
+ decoding throughout. Self-contained: installs its own dependencies.
9
+
10
+ python train_qwen_qlora.py --train train_expanded_titled.csv \
11
+ --val val_titled.csv --base Qwen/Qwen2.5-72B-Instruct \
12
+ --batch 1 --accum 16 --out qwen72_out
13
+
14
+ 7B fits a 24GB GPU (--batch 4 --accum 4); 72B needs 80GB + ~150GB disk.
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import subprocess
20
+ import sys
21
+ import time
22
+
23
+
24
+ def pip(*pkgs):
25
+ subprocess.run([sys.executable, "-m", "pip", "install", "-q", *pkgs], check=True)
26
+
27
+
28
+ pip("transformers>=4.51", "peft", "trl", "bitsandbytes", "datasets",
29
+ "rouge_score", "absl-py", "pandas", "accelerate")
30
+
31
+ import numpy as np # noqa: E402
32
+ import pandas as pd # noqa: E402
33
+ import torch # noqa: E402
34
+ from datasets import Dataset # noqa: E402
35
+ from peft import LoraConfig # noqa: E402
36
+ from rouge_score import rouge_scorer # noqa: E402
37
+ from transformers import (AutoModelForCausalLM, AutoTokenizer, # noqa: E402
38
+ BitsAndBytesConfig)
39
+ from trl import SFTConfig, SFTTrainer # noqa: E402
40
+
41
+ BASE = None # set from --base
42
+ SYS = ("You write research highlights. Given a paper title and abstract "
43
+ "(separated by |), produce 3-5 short highlight sentences in the exact "
44
+ "style of the examples you were trained on: no punctuation except "
45
+ "sentence-final periods written as ' . ', ~55 words total, each "
46
+ "sentence stating one contribution or finding.")
47
+ SC = rouge_scorer.RougeScorer(["rougeL"], use_stemmer=False, split_summaries=False)
48
+
49
+
50
+ def to_messages(row):
51
+ return {"messages": [
52
+ {"role": "system", "content": SYS},
53
+ {"role": "user", "content": str(row["Abstract"])},
54
+ {"role": "assistant", "content": str(row["Highlights"])},
55
+ ]}
56
+
57
+
58
+ @torch.no_grad()
59
+ def gen_eval(model, tok, df, label, save=None):
60
+ model.eval()
61
+ preds = []
62
+ t0 = time.time()
63
+ for i in range(0, len(df), 8):
64
+ batch = df.iloc[i : i + 8]
65
+ prompts = [tok.apply_chat_template(
66
+ [{"role": "system", "content": SYS},
67
+ {"role": "user", "content": str(a)}],
68
+ tokenize=False, add_generation_prompt=True) for a in batch["Abstract"]]
69
+ enc = tok(prompts, return_tensors="pt", padding=True,
70
+ truncation=True, max_length=1024, padding_side="left").to("cuda")
71
+ out = model.generate(**enc, max_new_tokens=130, do_sample=False,
72
+ pad_token_id=tok.pad_token_id)
73
+ for j in range(len(batch)):
74
+ text = tok.decode(out[j][enc.input_ids.shape[1]:],
75
+ skip_special_tokens=True).strip()
76
+ preds.append(text)
77
+ if i == 0:
78
+ print(f"[{label}] sample pred: {preds[0][:200]!r}", flush=True)
79
+ scores = [SC.score(str(g), p)["rougeL"].fmeasure
80
+ for g, p in zip(df["Highlights"], preds)]
81
+ words = float(np.mean([len(p.split()) for p in preds]))
82
+ print(f"[{label}] rougeL={np.mean(scores):.4f} words={words:.1f} "
83
+ f"({time.time() - t0:.0f}s)", flush=True)
84
+ if save:
85
+ pd.DataFrame({"Filename": df["Filename"], "Prediction": preds,
86
+ "rougeL": scores}).to_csv(save, index=False)
87
+ model.train()
88
+ return float(np.mean(scores))
89
+
90
+
91
+ def main():
92
+ ap = argparse.ArgumentParser()
93
+ ap.add_argument("--train", required=True)
94
+ ap.add_argument("--val", required=True)
95
+ ap.add_argument("--out", default="qwen_out")
96
+ ap.add_argument("--base", default="Qwen/Qwen2.5-7B-Instruct")
97
+ ap.add_argument("--batch", type=int, default=4)
98
+ ap.add_argument("--accum", type=int, default=4)
99
+ args = ap.parse_args()
100
+ global BASE
101
+ BASE = args.base
102
+
103
+ train = pd.read_csv(args.train)
104
+ val = pd.read_csv(args.val)
105
+ trunc = ~val["Abstract"].astype(str).str.rstrip().str.endswith(".")
106
+ n_c = int(300 * (1 - trunc.mean()))
107
+ val_eval = pd.concat([
108
+ val[~trunc].sample(n=n_c, random_state=42),
109
+ val[trunc].sample(n=300 - n_c, random_state=42),
110
+ ]).reset_index(drop=True)
111
+
112
+ tok = AutoTokenizer.from_pretrained(BASE)
113
+ model = AutoModelForCausalLM.from_pretrained(
114
+ BASE,
115
+ quantization_config=BitsAndBytesConfig(
116
+ load_in_4bit=True, bnb_4bit_quant_type="nf4",
117
+ bnb_4bit_compute_dtype=torch.bfloat16,
118
+ bnb_4bit_use_double_quant=True),
119
+ dtype=torch.bfloat16, device_map="auto")
120
+
121
+ ds = Dataset.from_list([to_messages(r) for _, r in train.iterrows()])
122
+ peft_cfg = LoraConfig(
123
+ r=16, lora_alpha=32, lora_dropout=0.05, task_type="CAUSAL_LM",
124
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
125
+ "gate_proj", "up_proj", "down_proj"])
126
+ sft_cfg = SFTConfig(
127
+ output_dir=args.out, num_train_epochs=1,
128
+ per_device_train_batch_size=args.batch, gradient_accumulation_steps=args.accum,
129
+ learning_rate=2e-4, lr_scheduler_type="cosine", warmup_ratio=0.03,
130
+ bf16=True, logging_steps=50, save_strategy="no",
131
+ max_length=1024, gradient_checkpointing=True, report_to=[])
132
+ trainer = SFTTrainer(model=model, args=sft_cfg, train_dataset=ds,
133
+ peft_config=peft_cfg, processing_class=tok)
134
+
135
+ print("=== pre-SFT zero-shot eval (30 rows, sanity) ===", flush=True)
136
+ gen_eval(trainer.model, tok, val_eval.head(30), "zero-shot")
137
+
138
+ trainer.train()
139
+ trainer.model.save_pretrained(f"{args.out}/adapter")
140
+ tok.save_pretrained(f"{args.out}/adapter")
141
+
142
+ print("=== post-SFT eval (300-row pinned subset) ===", flush=True)
143
+ subset = gen_eval(trainer.model, tok, val_eval, "post-sft-300",
144
+ save=f"{args.out}/val300_predictions.csv")
145
+ full = None
146
+ if subset >= 0.25:
147
+ print("=== subset >= 0.25: full-val pass ===", flush=True)
148
+ full = gen_eval(trainer.model, tok, val, "post-sft-full",
149
+ save=f"{args.out}/val_predictions.csv")
150
+ else:
151
+ print(f"subset {subset:.4f} < 0.25 -> kill-gate zone, skipping full val",
152
+ flush=True)
153
+ json.dump({"subset300": subset, "full_val": full},
154
+ open(f"{args.out}/result.json", "w"), indent=2)
155
+ print(f"DONE subset={subset:.4f} full={full}", flush=True)
156
+
157
+
158
+ if __name__ == "__main__":
159
+ main()
final/validate_submission.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Format gate for a submission CSV. Checks row count against the official
2
+ test split, column schema, empty/duplicate predictions, and length
3
+ statistics. Exits non-zero on any hard failure.
4
+
5
+ python validate_submission.py --submission Yushkk99_Task1_run2.csv \\
6
+ --test data/raw/test.csv
7
+ """
8
+
9
+ import argparse
10
+ import sys
11
+
12
+ import pandas as pd
13
+
14
+
15
+ def main():
16
+ ap = argparse.ArgumentParser()
17
+ ap.add_argument("--submission", required=True)
18
+ ap.add_argument("--test", required=True)
19
+ args = ap.parse_args()
20
+
21
+ sub = pd.read_csv(args.submission)
22
+ test = pd.read_csv(args.test)
23
+ failures = []
24
+
25
+ if list(sub.columns) != ["Filename", "Abstract", "Predicted_Highlights"]:
26
+ failures.append(f"columns are {list(sub.columns)}")
27
+ if len(sub) != len(test):
28
+ failures.append(f"{len(sub)} rows, expected {len(test)}")
29
+ if set(sub["Filename"]) != set(test["Filename"]):
30
+ failures.append("Filename sets differ from official test split")
31
+ empty = sub["Predicted_Highlights"].isna() | (
32
+ sub["Predicted_Highlights"].astype(str).str.strip() == "")
33
+ if empty.any():
34
+ failures.append(f"{int(empty.sum())} empty predictions")
35
+
36
+ words = sub["Predicted_Highlights"].astype(str).str.split().str.len()
37
+ dupes = sub["Predicted_Highlights"].duplicated().sum()
38
+ print(f"rows: {len(sub)}")
39
+ print(f"prediction length: mean {words.mean():.1f} / min {words.min()} / "
40
+ f"max {words.max()} words")
41
+ print(f"duplicate predictions: {dupes}")
42
+
43
+ if failures:
44
+ print("\nFAIL:")
45
+ for f in failures:
46
+ print(f" - {f}")
47
+ return 1
48
+ print("\nPASS: submission format is valid")
49
+ return 0
50
+
51
+
52
+ if __name__ == "__main__":
53
+ raise SystemExit(main())
train_final.ipynb ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# SciHigh-2026 Subtask 1 \u2014 bart-large-cnn titled (run 1)\n",
8
+ "\n",
9
+ "Notebook version of `train_final.py` \u2014 run top to bottom.\n"
10
+ ]
11
+ },
12
+ {
13
+ "cell_type": "code",
14
+ "execution_count": null,
15
+ "metadata": {},
16
+ "outputs": [],
17
+ "source": [
18
+ "\"\"\"\n",
19
+ "SciHigh-2026 Subtask 1 (Research Highlight Generation) -- final training recipe.\n",
20
+ "\n",
21
+ "Fine-tunes facebook/bart-large-cnn on the expanded MixSub-SciHigh training pool\n",
22
+ "to generate short research highlights from a paper's title and abstract.\n",
23
+ "Inputs are '<title> | <abstract>' with truncated abstracts repaired via\n",
24
+ "DOI-verified Semantic Scholar recovery (see final/ pipeline). This is the\n",
25
+ "exact recipe that produced the submitted result:\n",
26
+ "\n",
27
+ " ROUGE-1=39.30% ROUGE-2=15.32% ROUGE-L=26.37%\n",
28
+ " METEOR=36.17% BERTScore-F1=88.05%\n",
29
+ " (vs. the FIRE-2025 winning baseline of 23.45% ROUGE-L)\n",
30
+ "\n",
31
+ "This script is a clean, standalone extraction of the winning configuration --\n",
32
+ "it deliberately does NOT include the large space of experiments (backbone\n",
33
+ "sweeps, input-feature engineering, decode-time reranking/logit-bias/checkpoint\n",
34
+ "averaging, proxy-scale fast iteration mode, etc.) that were tried and used to\n",
35
+ "arrive at this recipe. Those all live in the project's internal experiment\n",
36
+ "history; none of them are part of what actually produced the result above.\n",
37
+ "\n",
38
+ "Usage:\n",
39
+ " pip install torch transformers accelerate datasets evaluate rouge_score \\\n",
40
+ " sentencepiece bert-score nltk\n",
41
+ " python train_final.py --data_dir /path/to/data --output_dir ./output\n",
42
+ "\n",
43
+ "Expects --data_dir to contain:\n",
44
+ " train_expanded_recovered_titled.csv (15,960 rows: the\n",
45
+ " official 10,000-row MixSub-SciHigh train split plus\n",
46
+ " 5,960 additional real pairs recovered from the\n",
47
+ " dataset's original source release, leakage-checked\n",
48
+ " against val/test by exact Abstract-text match)\n",
49
+ " val_recovered_titled.csv (Filename, Abstract, Highlights -- 1,985 rows, the\n",
50
+ " official held-out validation split, used only for\n",
51
+ " per-epoch monitoring/checkpoint selection here)\n",
52
+ " test_recovered_titled.csv (Filename, Abstract -- 1,840 rows, official masked\n",
53
+ " test split, for the submission predictions)\n",
54
+ "\"\"\"\n",
55
+ "import argparse\n",
56
+ "import json\n",
57
+ "import os\n",
58
+ "\n",
59
+ "import evaluate\n",
60
+ "import nltk\n",
61
+ "import numpy as np\n",
62
+ "import pandas as pd\n",
63
+ "import torch\n",
64
+ "from datasets import Dataset\n",
65
+ "from transformers import (\n",
66
+ " AutoModelForSeq2SeqLM,\n",
67
+ " AutoTokenizer,\n",
68
+ " DataCollatorForSeq2Seq,\n",
69
+ " EarlyStoppingCallback,\n",
70
+ " Seq2SeqTrainer,\n",
71
+ " Seq2SeqTrainingArguments,\n",
72
+ ")\n",
73
+ "\n",
74
+ "MODEL_NAME = \"facebook/bart-large-cnn\"\n",
75
+ "MAX_INPUT_LEN = 512\n",
76
+ "MAX_TARGET_LEN = 100 # matches the FIRE-2025 baseline recipe's output budget\n",
77
+ "BATCH_SIZE = 2\n",
78
+ "LEARNING_RATE = 2e-5\n",
79
+ "NUM_BEAMS = 4\n",
80
+ "\n",
81
+ "# Epoch ceiling, not a fixed schedule: bart-large-cnn converges fast on this\n",
82
+ "# task (a small-scale proxy run reached near-final quality in ~600-1,200\n",
83
+ "# gradient steps, a fraction of one full epoch's ~7,980 steps at batch_size=2\n",
84
+ "# over the 15,960-row pool). load_best_model_at_end + EarlyStoppingCallback\n",
85
+ "# below let the run self-terminate rather than committing to a fixed count.\n",
86
+ "# In the actual run that produced the reported numbers, training stopped\n",
87
+ "# after epoch 3 (2 consecutive non-improving epochs), and the checkpoint from\n",
88
+ "# epoch 1 -- the true best on val ROUGE-L -- was the one restored and\n",
89
+ "# evaluated/submitted.\n",
90
+ "EPOCH_CEILING = 4\n",
91
+ "EARLY_STOPPING_PATIENCE = 2\n",
92
+ "\n",
93
+ "for _pkg in [\"wordnet\", \"punkt_tab\", \"omw-1.4\"]:\n",
94
+ " try:\n",
95
+ " nltk.download(_pkg, quiet=True)\n",
96
+ " except Exception as e: # noqa: BLE001\n",
97
+ " print(f\"warning: failed to download nltk resource '{_pkg}': {e}\")\n",
98
+ "\n",
99
+ "\n",
100
+ "def parse_args():\n",
101
+ " p = argparse.ArgumentParser()\n",
102
+ " p.add_argument(\"--data_dir\", required=True)\n",
103
+ " p.add_argument(\"--output_dir\", default=\"output\")\n",
104
+ " p.add_argument(\"--epochs\", type=int, default=EPOCH_CEILING)\n",
105
+ " p.add_argument(\"--skip_bertscore\", action=\"store_true\", help=\"BERTScore eval downloads its own scoring model; skip for a fast local check\")\n",
106
+ " return p.parse_args()\n",
107
+ "\n",
108
+ "\n",
109
+ "def to_hf_dataset(df, has_target):\n",
110
+ " d = {\"Abstract\": df[\"Abstract\"].tolist()}\n",
111
+ " if has_target:\n",
112
+ " d[\"Highlights\"] = df[\"Highlights\"].tolist()\n",
113
+ " return Dataset.from_dict(d)\n",
114
+ "\n",
115
+ "\n",
116
+ "def make_preprocess_fn(tokenizer):\n",
117
+ " def preprocess(batch):\n",
118
+ " model_inputs = tokenizer(batch[\"Abstract\"], max_length=MAX_INPUT_LEN, truncation=True)\n",
119
+ " labels = tokenizer(text_target=batch[\"Highlights\"], max_length=MAX_TARGET_LEN, truncation=True)\n",
120
+ " model_inputs[\"labels\"] = labels[\"input_ids\"]\n",
121
+ " return model_inputs\n",
122
+ "\n",
123
+ " return preprocess\n",
124
+ "\n",
125
+ "\n",
126
+ "def main():\n",
127
+ " args = parse_args()\n",
128
+ " os.makedirs(args.output_dir, exist_ok=True)\n",
129
+ " device = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n",
130
+ "\n",
131
+ " train_df = pd.read_csv(os.path.join(args.data_dir, \"train_expanded_recovered_titled.csv\"))\n",
132
+ " val_df = pd.read_csv(os.path.join(args.data_dir, \"val_recovered_titled.csv\"))\n",
133
+ " test_df = pd.read_csv(os.path.join(args.data_dir, \"test_recovered_titled.csv\"))\n",
134
+ " print(f\"[train_final] device={device} train={len(train_df)} val={len(val_df)} test={len(test_df)}\")\n",
135
+ "\n",
136
+ " tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)\n",
137
+ " model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_NAME).to(device)\n",
138
+ "\n",
139
+ " # Decode-time settings: bart-large-cnn already ships no_repeat_ngram_size=3\n",
140
+ " # in its own generation_config, but it's set explicitly here rather than\n",
141
+ " # left implicit, so the decision doesn't silently depend on that shipped\n",
142
+ " # default surviving a future transformers/model-card change. Two other\n",
143
+ " # settings that were tuned during small-scale proxy experiments\n",
144
+ " # (repetition_penalty=1.5, min_new_tokens=15) were fixes for degenerate\n",
145
+ " # repetition-collapse in a severely undertrained checkpoint -- this\n",
146
+ " # full-scale, fully-converged model doesn't exhibit that failure mode, so\n",
147
+ " # those are deliberately left untouched at bart-large-cnn's own defaults\n",
148
+ " # (repetition_penalty=1.0, min_length=56) rather than carried over.\n",
149
+ " model.generation_config.no_repeat_ngram_size = 3\n",
150
+ " model.generation_config.max_length = MAX_TARGET_LEN\n",
151
+ "\n",
152
+ " train_ds = to_hf_dataset(train_df, has_target=True)\n",
153
+ " val_ds = to_hf_dataset(val_df, has_target=True)\n",
154
+ " preprocess = make_preprocess_fn(tokenizer)\n",
155
+ " train_tok = train_ds.map(preprocess, batched=True, remove_columns=train_ds.column_names)\n",
156
+ " val_tok = val_ds.map(preprocess, batched=True, remove_columns=val_ds.column_names)\n",
157
+ " collator = DataCollatorForSeq2Seq(tokenizer, model=model)\n",
158
+ "\n",
159
+ " rouge = evaluate.load(\"rouge\")\n",
160
+ " meteor = evaluate.load(\"meteor\")\n",
161
+ "\n",
162
+ " def compute_metrics(eval_preds):\n",
163
+ " preds, labels = eval_preds\n",
164
+ " if isinstance(preds, tuple):\n",
165
+ " preds = preds[0]\n",
166
+ " preds = np.where(preds != -100, preds, tokenizer.pad_token_id)\n",
167
+ " decoded_preds = tokenizer.batch_decode(preds, skip_special_tokens=True)\n",
168
+ " labels = np.where(labels != -100, labels, tokenizer.pad_token_id)\n",
169
+ " decoded_labels = tokenizer.batch_decode(labels, skip_special_tokens=True)\n",
170
+ "\n",
171
+ " result = rouge.compute(predictions=decoded_preds, references=decoded_labels)\n",
172
+ " result = {f\"rouge_{k}\": v for k, v in result.items()}\n",
173
+ " result[\"meteor\"] = meteor.compute(predictions=decoded_preds, references=decoded_labels)[\"meteor\"]\n",
174
+ " return result\n",
175
+ "\n",
176
+ " training_args = Seq2SeqTrainingArguments(\n",
177
+ " output_dir=os.path.join(args.output_dir, \"checkpoints\"),\n",
178
+ " num_train_epochs=args.epochs,\n",
179
+ " per_device_train_batch_size=BATCH_SIZE,\n",
180
+ " per_device_eval_batch_size=BATCH_SIZE,\n",
181
+ " learning_rate=LEARNING_RATE,\n",
182
+ " label_smoothing_factor=0.0,\n",
183
+ " warmup_ratio=0.0,\n",
184
+ " # Adafactor's factored second-moment estimates avoid the full-size\n",
185
+ " # exp_avg/exp_avg_sq buffers that OOM'd a 16GB T4 with plain Adam; it's\n",
186
+ " # also what the original PEGASUS/BART pretraining used.\n",
187
+ " optim=\"adafactor\",\n",
188
+ " predict_with_generate=True,\n",
189
+ " generation_max_length=MAX_TARGET_LEN,\n",
190
+ " generation_num_beams=NUM_BEAMS,\n",
191
+ " eval_strategy=\"epoch\",\n",
192
+ " save_strategy=\"epoch\",\n",
193
+ " save_total_limit=1,\n",
194
+ " load_best_model_at_end=True,\n",
195
+ " metric_for_best_model=\"rouge_rougeL\",\n",
196
+ " fp16=(device == \"cuda\"),\n",
197
+ " logging_steps=50,\n",
198
+ " report_to=[],\n",
199
+ " )\n",
200
+ "\n",
201
+ " trainer = Seq2SeqTrainer(\n",
202
+ " model=model,\n",
203
+ " args=training_args,\n",
204
+ " train_dataset=train_tok,\n",
205
+ " eval_dataset=val_tok,\n",
206
+ " data_collator=collator,\n",
207
+ " compute_metrics=compute_metrics,\n",
208
+ " callbacks=[EarlyStoppingCallback(early_stopping_patience=EARLY_STOPPING_PATIENCE)],\n",
209
+ " )\n",
210
+ "\n",
211
+ " trainer.train()\n",
212
+ "\n",
213
+ " final_model_dir = os.path.join(args.output_dir, \"model\")\n",
214
+ " trainer.save_model(final_model_dir)\n",
215
+ " tokenizer.save_pretrained(final_model_dir)\n",
216
+ "\n",
217
+ " val_metrics = trainer.evaluate()\n",
218
+ " print(\"[train_final] validation metrics:\", val_metrics)\n",
219
+ "\n",
220
+ " if not args.skip_bertscore:\n",
221
+ " from bert_score import score as bertscore\n",
222
+ "\n",
223
+ " val_preds = trainer.predict(val_tok)\n",
224
+ " preds = np.where(val_preds.predictions != -100, val_preds.predictions, tokenizer.pad_token_id)\n",
225
+ " decoded_preds = tokenizer.batch_decode(preds, skip_special_tokens=True)\n",
226
+ " _, _, f1 = bertscore(decoded_preds, val_df[\"Highlights\"].tolist(), lang=\"en\", verbose=False)\n",
227
+ " val_metrics[\"bertscore_f1\"] = float(f1.mean())\n",
228
+ " print(\"[train_final] bertscore_f1:\", val_metrics[\"bertscore_f1\"])\n",
229
+ "\n",
230
+ " with open(os.path.join(args.output_dir, \"val_metrics.json\"), \"w\") as f:\n",
231
+ " json.dump(val_metrics, f, indent=2)\n",
232
+ "\n",
233
+ " # Generate the submission predictions on the masked test set.\n",
234
+ " model.eval()\n",
235
+ " gen_device = next(model.parameters()).device\n",
236
+ " predictions = []\n",
237
+ " batch_size = max(BATCH_SIZE, 8)\n",
238
+ " abstracts = test_df[\"Abstract\"].tolist()\n",
239
+ " for i in range(0, len(abstracts), batch_size):\n",
240
+ " batch = abstracts[i : i + batch_size]\n",
241
+ " inputs = tokenizer(batch, max_length=MAX_INPUT_LEN, truncation=True, padding=True, return_tensors=\"pt\").to(gen_device)\n",
242
+ " with torch.no_grad():\n",
243
+ " generated = model.generate(**inputs, max_length=MAX_TARGET_LEN, num_beams=NUM_BEAMS)\n",
244
+ " predictions.extend(tokenizer.batch_decode(generated, skip_special_tokens=True))\n",
245
+ "\n",
246
+ " submission = pd.DataFrame({\n",
247
+ " \"Filename\": test_df[\"Filename\"],\n",
248
+ " \"Abstract\": test_df[\"Abstract\"],\n",
249
+ " \"Predicted_Highlights\": predictions,\n",
250
+ " })\n",
251
+ " submission_path = os.path.join(args.output_dir, \"Yushkk99_Task1_run1.csv\")\n",
252
+ " submission.to_csv(submission_path, index=False)\n",
253
+ " print(f\"[train_final] wrote submission to {submission_path}\")\n",
254
+ "\n",
255
+ "\n",
256
+ "if __name__ == \"__main__\":\n",
257
+ " main()\n"
258
+ ]
259
+ }
260
+ ],
261
+ "metadata": {
262
+ "kernelspec": {
263
+ "display_name": "Python 3",
264
+ "language": "python",
265
+ "name": "python3"
266
+ },
267
+ "language_info": {
268
+ "name": "python",
269
+ "version": "3.11"
270
+ }
271
+ },
272
+ "nbformat": 4,
273
+ "nbformat_minor": 5
274
+ }
train_final.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SciHigh-2026 Subtask 1 (Research Highlight Generation) -- final training recipe.
3
+
4
+ Fine-tunes facebook/bart-large-cnn on the expanded MixSub-SciHigh training pool
5
+ to generate short research highlights from a paper's title and abstract.
6
+ Inputs are '<title> | <abstract>' with truncated abstracts repaired via
7
+ DOI-verified Semantic Scholar recovery (see final/ pipeline). This is the
8
+ exact recipe that produced the submitted result:
9
+
10
+ ROUGE-1=39.30% ROUGE-2=15.32% ROUGE-L=26.37%
11
+ METEOR=36.17% BERTScore-F1=88.05%
12
+ (vs. the FIRE-2025 winning baseline of 23.45% ROUGE-L)
13
+
14
+ This script is a clean, standalone extraction of the winning configuration --
15
+ it deliberately does NOT include the large space of experiments (backbone
16
+ sweeps, input-feature engineering, decode-time reranking/logit-bias/checkpoint
17
+ averaging, proxy-scale fast iteration mode, etc.) that were tried and used to
18
+ arrive at this recipe. Those all live in the project's internal experiment
19
+ history; none of them are part of what actually produced the result above.
20
+
21
+ Usage:
22
+ pip install torch transformers accelerate datasets evaluate rouge_score \
23
+ sentencepiece bert-score nltk
24
+ python train_final.py --data_dir /path/to/data --output_dir ./output
25
+
26
+ Expects --data_dir to contain:
27
+ train_expanded_recovered_titled.csv (15,960 rows: the
28
+ official 10,000-row MixSub-SciHigh train split plus
29
+ 5,960 additional real pairs recovered from the
30
+ dataset's original source release, leakage-checked
31
+ against val/test by exact Abstract-text match)
32
+ val_recovered_titled.csv (Filename, Abstract, Highlights -- 1,985 rows, the
33
+ official held-out validation split, used only for
34
+ per-epoch monitoring/checkpoint selection here)
35
+ test_recovered_titled.csv (Filename, Abstract -- 1,840 rows, official masked
36
+ test split, for the submission predictions)
37
+ """
38
+ import argparse
39
+ import json
40
+ import os
41
+
42
+ import evaluate
43
+ import nltk
44
+ import numpy as np
45
+ import pandas as pd
46
+ import torch
47
+ from datasets import Dataset
48
+ from transformers import (
49
+ AutoModelForSeq2SeqLM,
50
+ AutoTokenizer,
51
+ DataCollatorForSeq2Seq,
52
+ EarlyStoppingCallback,
53
+ Seq2SeqTrainer,
54
+ Seq2SeqTrainingArguments,
55
+ )
56
+
57
+ MODEL_NAME = "facebook/bart-large-cnn"
58
+ MAX_INPUT_LEN = 512
59
+ MAX_TARGET_LEN = 100 # matches the FIRE-2025 baseline recipe's output budget
60
+ BATCH_SIZE = 2
61
+ LEARNING_RATE = 2e-5
62
+ NUM_BEAMS = 4
63
+
64
+ # Epoch ceiling, not a fixed schedule: bart-large-cnn converges fast on this
65
+ # task (a small-scale proxy run reached near-final quality in ~600-1,200
66
+ # gradient steps, a fraction of one full epoch's ~7,980 steps at batch_size=2
67
+ # over the 15,960-row pool). load_best_model_at_end + EarlyStoppingCallback
68
+ # below let the run self-terminate rather than committing to a fixed count.
69
+ # In the actual run that produced the reported numbers, training stopped
70
+ # after epoch 3 (2 consecutive non-improving epochs), and the checkpoint from
71
+ # epoch 1 -- the true best on val ROUGE-L -- was the one restored and
72
+ # evaluated/submitted.
73
+ EPOCH_CEILING = 4
74
+ EARLY_STOPPING_PATIENCE = 2
75
+
76
+ for _pkg in ["wordnet", "punkt_tab", "omw-1.4"]:
77
+ try:
78
+ nltk.download(_pkg, quiet=True)
79
+ except Exception as e: # noqa: BLE001
80
+ print(f"warning: failed to download nltk resource '{_pkg}': {e}")
81
+
82
+
83
+ def parse_args():
84
+ p = argparse.ArgumentParser()
85
+ p.add_argument("--data_dir", required=True)
86
+ p.add_argument("--output_dir", default="output")
87
+ p.add_argument("--epochs", type=int, default=EPOCH_CEILING)
88
+ p.add_argument("--skip_bertscore", action="store_true", help="BERTScore eval downloads its own scoring model; skip for a fast local check")
89
+ return p.parse_args()
90
+
91
+
92
+ def to_hf_dataset(df, has_target):
93
+ d = {"Abstract": df["Abstract"].tolist()}
94
+ if has_target:
95
+ d["Highlights"] = df["Highlights"].tolist()
96
+ return Dataset.from_dict(d)
97
+
98
+
99
+ def make_preprocess_fn(tokenizer):
100
+ def preprocess(batch):
101
+ model_inputs = tokenizer(batch["Abstract"], max_length=MAX_INPUT_LEN, truncation=True)
102
+ labels = tokenizer(text_target=batch["Highlights"], max_length=MAX_TARGET_LEN, truncation=True)
103
+ model_inputs["labels"] = labels["input_ids"]
104
+ return model_inputs
105
+
106
+ return preprocess
107
+
108
+
109
+ def main():
110
+ args = parse_args()
111
+ os.makedirs(args.output_dir, exist_ok=True)
112
+ device = "cuda" if torch.cuda.is_available() else "cpu"
113
+
114
+ train_df = pd.read_csv(os.path.join(args.data_dir, "train_expanded_recovered_titled.csv"))
115
+ val_df = pd.read_csv(os.path.join(args.data_dir, "val_recovered_titled.csv"))
116
+ test_df = pd.read_csv(os.path.join(args.data_dir, "test_recovered_titled.csv"))
117
+ print(f"[train_final] device={device} train={len(train_df)} val={len(val_df)} test={len(test_df)}")
118
+
119
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
120
+ model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_NAME).to(device)
121
+
122
+ # Decode-time settings: bart-large-cnn already ships no_repeat_ngram_size=3
123
+ # in its own generation_config, but it's set explicitly here rather than
124
+ # left implicit, so the decision doesn't silently depend on that shipped
125
+ # default surviving a future transformers/model-card change. Two other
126
+ # settings that were tuned during small-scale proxy experiments
127
+ # (repetition_penalty=1.5, min_new_tokens=15) were fixes for degenerate
128
+ # repetition-collapse in a severely undertrained checkpoint -- this
129
+ # full-scale, fully-converged model doesn't exhibit that failure mode, so
130
+ # those are deliberately left untouched at bart-large-cnn's own defaults
131
+ # (repetition_penalty=1.0, min_length=56) rather than carried over.
132
+ model.generation_config.no_repeat_ngram_size = 3
133
+ model.generation_config.max_length = MAX_TARGET_LEN
134
+
135
+ train_ds = to_hf_dataset(train_df, has_target=True)
136
+ val_ds = to_hf_dataset(val_df, has_target=True)
137
+ preprocess = make_preprocess_fn(tokenizer)
138
+ train_tok = train_ds.map(preprocess, batched=True, remove_columns=train_ds.column_names)
139
+ val_tok = val_ds.map(preprocess, batched=True, remove_columns=val_ds.column_names)
140
+ collator = DataCollatorForSeq2Seq(tokenizer, model=model)
141
+
142
+ rouge = evaluate.load("rouge")
143
+ meteor = evaluate.load("meteor")
144
+
145
+ def compute_metrics(eval_preds):
146
+ preds, labels = eval_preds
147
+ if isinstance(preds, tuple):
148
+ preds = preds[0]
149
+ preds = np.where(preds != -100, preds, tokenizer.pad_token_id)
150
+ decoded_preds = tokenizer.batch_decode(preds, skip_special_tokens=True)
151
+ labels = np.where(labels != -100, labels, tokenizer.pad_token_id)
152
+ decoded_labels = tokenizer.batch_decode(labels, skip_special_tokens=True)
153
+
154
+ result = rouge.compute(predictions=decoded_preds, references=decoded_labels)
155
+ result = {f"rouge_{k}": v for k, v in result.items()}
156
+ result["meteor"] = meteor.compute(predictions=decoded_preds, references=decoded_labels)["meteor"]
157
+ return result
158
+
159
+ training_args = Seq2SeqTrainingArguments(
160
+ output_dir=os.path.join(args.output_dir, "checkpoints"),
161
+ num_train_epochs=args.epochs,
162
+ per_device_train_batch_size=BATCH_SIZE,
163
+ per_device_eval_batch_size=BATCH_SIZE,
164
+ learning_rate=LEARNING_RATE,
165
+ label_smoothing_factor=0.0,
166
+ warmup_ratio=0.0,
167
+ # Adafactor's factored second-moment estimates avoid the full-size
168
+ # exp_avg/exp_avg_sq buffers that OOM'd a 16GB T4 with plain Adam; it's
169
+ # also what the original PEGASUS/BART pretraining used.
170
+ optim="adafactor",
171
+ predict_with_generate=True,
172
+ generation_max_length=MAX_TARGET_LEN,
173
+ generation_num_beams=NUM_BEAMS,
174
+ eval_strategy="epoch",
175
+ save_strategy="epoch",
176
+ save_total_limit=1,
177
+ load_best_model_at_end=True,
178
+ metric_for_best_model="rouge_rougeL",
179
+ fp16=(device == "cuda"),
180
+ logging_steps=50,
181
+ report_to=[],
182
+ )
183
+
184
+ trainer = Seq2SeqTrainer(
185
+ model=model,
186
+ args=training_args,
187
+ train_dataset=train_tok,
188
+ eval_dataset=val_tok,
189
+ data_collator=collator,
190
+ compute_metrics=compute_metrics,
191
+ callbacks=[EarlyStoppingCallback(early_stopping_patience=EARLY_STOPPING_PATIENCE)],
192
+ )
193
+
194
+ trainer.train()
195
+
196
+ final_model_dir = os.path.join(args.output_dir, "model")
197
+ trainer.save_model(final_model_dir)
198
+ tokenizer.save_pretrained(final_model_dir)
199
+
200
+ val_metrics = trainer.evaluate()
201
+ print("[train_final] validation metrics:", val_metrics)
202
+
203
+ if not args.skip_bertscore:
204
+ from bert_score import score as bertscore
205
+
206
+ val_preds = trainer.predict(val_tok)
207
+ preds = np.where(val_preds.predictions != -100, val_preds.predictions, tokenizer.pad_token_id)
208
+ decoded_preds = tokenizer.batch_decode(preds, skip_special_tokens=True)
209
+ _, _, f1 = bertscore(decoded_preds, val_df["Highlights"].tolist(), lang="en", verbose=False)
210
+ val_metrics["bertscore_f1"] = float(f1.mean())
211
+ print("[train_final] bertscore_f1:", val_metrics["bertscore_f1"])
212
+
213
+ with open(os.path.join(args.output_dir, "val_metrics.json"), "w") as f:
214
+ json.dump(val_metrics, f, indent=2)
215
+
216
+ # Generate the submission predictions on the masked test set.
217
+ model.eval()
218
+ gen_device = next(model.parameters()).device
219
+ predictions = []
220
+ batch_size = max(BATCH_SIZE, 8)
221
+ abstracts = test_df["Abstract"].tolist()
222
+ for i in range(0, len(abstracts), batch_size):
223
+ batch = abstracts[i : i + batch_size]
224
+ inputs = tokenizer(batch, max_length=MAX_INPUT_LEN, truncation=True, padding=True, return_tensors="pt").to(gen_device)
225
+ with torch.no_grad():
226
+ generated = model.generate(**inputs, max_length=MAX_TARGET_LEN, num_beams=NUM_BEAMS)
227
+ predictions.extend(tokenizer.batch_decode(generated, skip_special_tokens=True))
228
+
229
+ submission = pd.DataFrame({
230
+ "Filename": test_df["Filename"],
231
+ "Abstract": test_df["Abstract"],
232
+ "Predicted_Highlights": predictions,
233
+ })
234
+ submission_path = os.path.join(args.output_dir, "Yushkk99_Task1_run1.csv")
235
+ submission.to_csv(submission_path, index=False)
236
+ print(f"[train_final] wrote submission to {submission_path}")
237
+
238
+
239
+ if __name__ == "__main__":
240
+ main()