Add literature review protocol, searches and first snowball round
Six seeds snowballed (Semantic Scholar) plus logged keyword searches (OpenAlex, arXiv): 2,127 unique records, 480 title/abstract screened. AI first-pass suggestions are in their own column; decisions are pending. Claude-Session: https://claude.ai/code/session_01S8KN7NuUCm8nXLmy6eDuVv
This commit is contained in:
1 parent
4369bcefa8
commit
95b5857189
28 files changed
+5547
No files matched your search
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Record the AI first-pass screening suggestions in ../search/screening.csv.
|
||||
|
||||
These are SUGGESTIONS from a title/abstract read by Claude (28/10/2026), not
|
||||
decisions. The `decision` column is left for Cormac. Codes are defined in
|
||||
protocol.md section 5. A '*' in the code marks a paper whose question looks
|
||||
close to the thesis RQ (needs a "how we differ" note in the extraction table).
|
||||
|
||||
Matching is by arXiv id / DOI / title fragment. Re-runnable: it only writes
|
||||
the ai_proposal column (and reason, if empty).
|
||||
"""
|
||||
import csv
|
||||
import os
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SHEET = os.path.normpath(os.path.join(HERE, "..", "search", "screening.csv"))
|
||||
|
||||
# (match, proposal, code, note)
|
||||
P = [
|
||||
# --- seeds -------------------------------------------------------------
|
||||
("2010.04245", "include", "SEED", "origin of QK-norm (small NMT models)"),
|
||||
("findings-emnlp.379", "include", "SEED", "origin of QK-norm (ACL anthology copy of 2010.04245)"),
|
||||
("2302.05442", "include", "SEED", "QK-norm popularised to stop logit growth at 22B"),
|
||||
("2303.06296", "include", "SEED", "attention entropy collapse <-> instability"),
|
||||
("2309.14322", "include", "SEED*", "LR-sensitivity metric; qk-layernorm at small scale"),
|
||||
("2602.02522", "include", "SEED*", "QK-norm largest win at 70M, bundled with muP"),
|
||||
("2410.16682", "include", "IC*", "QK-norm + softcap -> 1.5x LR before divergence, 830M"),
|
||||
# --- core: evaluates QK-norm / logit control on stability or LR ---------
|
||||
("2511.21377", "include", "IC*", "QK-norm vs alternatives for logit control; LR ablations"),
|
||||
("2607.18363", "include", "IC*", "QK-norm keeps 48-layer stacks trainable: depth evidence (H3)"),
|
||||
("2610.34582", "include", "IS*", "single-variable ablations at 130M incl. QK-norm (HobbyLM)"),
|
||||
("zenodo.20803082", "include", "IS*", "29-36M, QK-norm x depth x 3 seeds sweep; Zenodo preprint, check quality"),
|
||||
("2610.17969", "include", "IA*", "compares vs QK-Norm across 16x LR range at 124M"),
|
||||
("2606.16310", "include", "IC", "QK-norm permits higher LR; high-LR stress test vs QK-clip"),
|
||||
("2603.02170", "include", "IC", "QK-norm necessary for stable low-bit training at large tokens/step"),
|
||||
("2603.11611", "maybe", "IC", "NoPE loss spikes mitigated by QK-norm (side finding)"),
|
||||
("2602.05006", "include", "IC", "Lp-norm generalisation of QKNorm (variant)"),
|
||||
("2602.01212", "include", "IA", "SimpleGPT normalisation vs LLaMA2+QKNorm, LR ablations"),
|
||||
("2505.22014", "maybe", "IA", "approx. normalised transformer vs GPT+QK-norm convergence"),
|
||||
("2601.02031", "maybe", "IA", "output-logit divergence; z-loss / softcap context"),
|
||||
("2501.18795", "maybe", "IC", "RoPE/NoPE/QK-norm attention patterns, long context focus"),
|
||||
("2507.13338", "maybe", "IA", "Lipschitz transformers trained without QK-norm/softcap"),
|
||||
("Kimi K2", "include", "IA", "MuonClip/QK-clip: alternative fix for attention-logit explosion"),
|
||||
("2502.02732", "include", "IA", "Peri-LN: normalisation placement neighbour"),
|
||||
("2503.04598", "include", "IA", "HybridNorm: normalisation placement neighbour"),
|
||||
("2110.09456", "include", "IA", "NormFormer: extra normalisation"),
|
||||
("Spike No More", "include", "IA", "pretraining loss-spike mitigation"),
|
||||
("Stable and low-precision training for large-scale vision", "include", "IM", "Wortsman et al. follow-up on instability"),
|
||||
("On Layer Normalization in the Transformer Architecture", "include", "BG", "Pre-LN vs Post-LN, warmup and LR (2020)"),
|
||||
("Transformers without Normalization", "maybe", "IA", "DyT replaces norm layers"),
|
||||
("Qwen3.8-Next", "maybe", "IC", "industry architecture study incl. training stability"),
|
||||
# --- mechanism: why logits grow / why QK-norm works ---------------------
|
||||
("2505.15548", "include", "IM", "dense local dependencies -> attention-logit explosion"),
|
||||
("2505.21910", "include", "IM", "W_q^T W_k spectral concentration -> entropy collapse, warmup"),
|
||||
("2501.12352", "maybe", "IM", "theory justifying QK-norm (test-time regression view)"),
|
||||
("2402.02098", "include", "IM", "QK eigenspectrum concentration and entropy collapse"),
|
||||
("2505.24333", "include", "IM", "rank vs entropy collapse in deep transformers (depth)"),
|
||||
("nht8-e622", "include", "IM", "variance sensitivity -> attention entropy collapse"),
|
||||
("2608.02091", "maybe", "IM", "low-precision attention collapse; QK-norm disrupts it"),
|
||||
("2609.34272", "maybe", "IM", "BF16 attention gradients; logit growth late in training"),
|
||||
("2608.14712", "include", "IM", "how to measure attention entropy properly (metric choice)"),
|
||||
("2502.00919", "maybe", "IM", "attention sinks persist with QK-norm"),
|
||||
("2410.10781", "include", "IM", "when attention sinks emerge (seed paper ref)"),
|
||||
("2402.17762", "include", "IM", "massive activations -> attention sinks"),
|
||||
("Efficient Streaming Language Models with Attention Sinks", "maybe", "IM", "attention sinks (inference paper, mechanism background)"),
|
||||
("The Curse of Depth", "include", "IM", "depth effects in LLMs (H3 context)"),
|
||||
("2601.22966", "maybe", "IM", "attention + residual sinks, outlier rescaling"),
|
||||
("Exact Attention Sensitivity", "maybe", "IM", "attention sensitivity and stability geometry"),
|
||||
("Normalization in Attention Dynamics", "maybe", "IM", "normalisation in attention dynamics"),
|
||||
("Active-Dormant Attention Heads", "maybe", "IM", "extreme-token phenomena mechanism"),
|
||||
("Mechanism-Aligned Observability", "maybe", "IM", "instability diagnostics; possible metrics"),
|
||||
# --- method / scale / confounds ----------------------------------------
|
||||
("2203.03466", "include", "BG", "muP: the confound held OFF"),
|
||||
("PolyPythias", "include", "IS", "stability across 50 small-model seeds: seed-variance precedent"),
|
||||
("Weight Decay may matter more than muP", "maybe", "BG", "LR transfer without muP"),
|
||||
("CompleteP", "maybe", "BG", "depth-wise parametrisation, LR across depth"),
|
||||
("Effective Learning Rate Governs Loss Dynamics", "maybe", "BG", "effective LR view of loss dynamics"),
|
||||
("How to set AdamW's weight decay", "maybe", "BG", "AdamW hyperparameters vs scale"),
|
||||
("Deconstructing What Makes a Good Optimizer", "maybe", "BG", "LR stability across optimisers, small LMs"),
|
||||
("2609.14715", "maybe", "IS", "sub-150M depth study (QK-norm fixed, not varied)"),
|
||||
("zenodo.18924690", "maybe", "IS", "GPT-2-scale modernisation incl. QK-norm; Zenodo, check quality"),
|
||||
("Cerebras-GPT", "maybe", "BG", "muP in small open models"),
|
||||
# --- duplicates / fragments --------------------------------------------
|
||||
("variancesensitivityinducesattentionentropycollapseintransformers", "exclude", "E4", "duplicate record"),
|
||||
("Intriguing", "exclude", "E4", "earlier title / duplicate of Wortsman et al. 2309.14322"),
|
||||
("2022resultstoprobe", "exclude", "E4", "citation-context fragment, not a paper"),
|
||||
("canrecoverfrom", "exclude", "E4", "citation-context fragment, not a paper"),
|
||||
("wealsoobserved", "exclude", "E4", "citation-context fragment, not a paper"),
|
||||
("Duality, Weight Decay, and Metrized", "exclude", "E4", "near-duplicate of 2507.13338"),
|
||||
# --- LLMs for literature search (supervisor: 'find a paper that backs it up')
|
||||
("Preliminary Guidelines for Using and Evaluating GenAI", "include", "LLM*", "Kitchenham et al. GUEST guidelines for GenAI in SLRs (SE)"),
|
||||
("3674805.3686666", "include", "LLM", "ChatGPT for SLR selection in SE: accuracy evaluation"),
|
||||
("wsese66602.2025.00010", "include", "LLM", "difficulties replicating LLM-supported SLRs in SE"),
|
||||
("3539618.3591703", "include", "LLM", "ChatGPT writing Boolean search queries (SIGIR)"),
|
||||
("jrsm.1715", "include", "LLM", "GPT-4 vs humans across SR stages; pre-registered; caution"),
|
||||
("j.jclinepi.2025.111746", "include", "LLM", "scoping review: LLMs for SRs 'not yet ready'"),
|
||||
("s10515-026-00629-6", "maybe", "LLM", "GPT-4o on SLR sub-tasks (SE)"),
|
||||
("s13643-024-02575-4", "maybe", "LLM", "LLM title/abstract screening (biomedical)"),
|
||||
("2196/48996", "maybe", "LLM", "automated screening with LLMs (clinical)"),
|
||||
("jamanetworkopen.2024.20496", "maybe", "LLM", "LLM citation screening performance"),
|
||||
("ocaf063", "maybe", "LLM", "LLMs as literature-review tools: review"),
|
||||
("pnas.2411962122", "maybe", "LLM", "LLMs in literature screening (PNAS perspective)"),
|
||||
]
|
||||
|
||||
DOMAIN = ("E1", "applies a transformer in another domain; QK-norm/logit control incidental")
|
||||
|
||||
|
||||
def main():
|
||||
rows = list(csv.DictReader(open(SHEET)))
|
||||
hits = {m: 0 for m, *_ in P}
|
||||
for r in rows:
|
||||
if r["auto_stage"] != "SCREEN":
|
||||
continue
|
||||
hay = f"{r['key']} {r['title']}".lower()
|
||||
for m, prop, code, note in P:
|
||||
if m.lower() in hay:
|
||||
r["ai_proposal"] = f"{prop} [{code}] {note}"
|
||||
hits[m] += 1
|
||||
break
|
||||
else:
|
||||
lit = r["found_by"].startswith("L") and ";" not in r["found_by"]
|
||||
if lit:
|
||||
r["ai_proposal"] = "exclude [E6] uses LLMs as a research subject, not as a review tool"
|
||||
else:
|
||||
r["ai_proposal"] = "exclude [E5] title/abstract: not about attention-logit control or LM training stability"
|
||||
with open(SHEET, "w", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=list(rows[0]))
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
missing = [m for m, n in hits.items() if n == 0]
|
||||
from collections import Counter
|
||||
c = Counter(r["ai_proposal"].split(" ")[0] for r in rows if r["auto_stage"] == "SCREEN")
|
||||
print("proposals:", dict(c))
|
||||
if missing:
|
||||
print("patterns that matched nothing:", missing)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge all search/snowball results into one de-duplicated screening sheet.
|
||||
|
||||
Usage: python3 merge.py (reads ../search/results/*.csv)
|
||||
|
||||
Writes ../search/screening.csv. Columns:
|
||||
found_by which searches / snowball steps returned the paper
|
||||
auto_stage what the automatic filters decided (see protocol.md section 5):
|
||||
EXCLUDE-pre2020 | EXCLUDE-offtopic | SCREEN
|
||||
ai_proposal filled later: first-pass include/exclude suggestion
|
||||
decision YOURS to fill: include / exclude / maybe
|
||||
reason short reason for the decision
|
||||
|
||||
Existing decision/ai_proposal/reason values are preserved on re-runs, so you
|
||||
can re-merge after new searches without losing screening work.
|
||||
"""
|
||||
import csv
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SEARCH = os.path.normpath(os.path.join(HERE, "..", "search"))
|
||||
OUT = os.path.join(SEARCH, "screening.csv")
|
||||
|
||||
# Topic filter (protocol.md section 5, criterion I2): a paper goes to manual
|
||||
# screening only if its title/abstract touches attention-logit control or
|
||||
# training stability of transformers.
|
||||
TOPIC = re.compile(
|
||||
r"qk[- ]?(norm|layer ?norm)|query[- ]key norm|attention logit|logit (growth|explosion|cap|soft)"
|
||||
r"|entropy collapse|attention entropy|training (instabilit|stabilit)|loss spike|divergen"
|
||||
r"|learning[- ]rate (sensitiv|stabil|transfer)|maximal update|\bmu-?p\b|μp|z-loss"
|
||||
r"|massive activation|attention sink|layer ?norm|rms ?norm|normali[sz]ation",
|
||||
re.I,
|
||||
)
|
||||
TRANSFORMER = re.compile(r"transformer|attention|language model|\bllm|\bvit\b|gpt", re.I)
|
||||
LLM_SEARCH = re.compile(r"systematic|literature|screening|review", re.I)
|
||||
# Seed set (protocol.md section 3): always screened, whatever the filters say.
|
||||
SEEDS = {"2010.04245", "2302.05442", "2303.06296", "2309.14322", "2602.02522", "2410.16682"}
|
||||
|
||||
|
||||
def key(r):
|
||||
if r.get("arxiv", "").lower().startswith("10.48550/arxiv."): # old rows: DOI in arxiv column
|
||||
r["arxiv"] = r["arxiv"].lower().split("arxiv.")[-1]
|
||||
if not r.get("arxiv") and "10.48550/arxiv." in r.get("doi", "").lower():
|
||||
r["arxiv"] = r["doi"].lower().split("arxiv.")[-1]
|
||||
if r.get("arxiv"):
|
||||
return "arxiv:" + r["arxiv"].lower()
|
||||
if r.get("doi"):
|
||||
return "doi:" + r["doi"].lower()
|
||||
return "title:" + re.sub(r"[^a-z0-9]", "", r["title"].lower())
|
||||
|
||||
|
||||
def main():
|
||||
merged = {}
|
||||
for path in sorted(glob.glob(os.path.join(SEARCH, "results", "*.csv"))):
|
||||
name = os.path.basename(path)[:-4]
|
||||
for r in csv.DictReader(open(path)):
|
||||
if not r.get("title"):
|
||||
continue
|
||||
k = key(r)
|
||||
m = merged.setdefault(k, {**r, "found_by": set()})
|
||||
m["found_by"].add(name)
|
||||
for f in ("arxiv", "doi", "abstract", "venue"):
|
||||
if not m.get(f) and r.get(f):
|
||||
m[f] = r[f]
|
||||
try:
|
||||
m["citations"] = max(int(m.get("citations") or 0), int(r.get("citations") or 0))
|
||||
except ValueError:
|
||||
pass
|
||||
# also merge title-duplicates that slipped through on different id types
|
||||
by_title = {}
|
||||
for k, m in list(merged.items()):
|
||||
t = re.sub(r"[^a-z0-9]", "", m["title"].lower())
|
||||
if t in by_title:
|
||||
by_title[t]["found_by"] |= m["found_by"]
|
||||
for f in ("arxiv", "doi", "abstract"):
|
||||
by_title[t][f] = by_title[t].get(f) or m.get(f)
|
||||
del merged[k]
|
||||
else:
|
||||
by_title[t] = m
|
||||
|
||||
old = {}
|
||||
if os.path.exists(OUT):
|
||||
for r in csv.DictReader(open(OUT)):
|
||||
old[r["key"]] = r
|
||||
|
||||
rows = []
|
||||
for k, m in merged.items():
|
||||
text = f"{m['title']} {m.get('abstract', '')}"
|
||||
lit_only = all(f.startswith("L") for f in m["found_by"])
|
||||
try:
|
||||
year = int(m.get("year") or 0)
|
||||
except ValueError:
|
||||
year = 0
|
||||
if any(sd in k for sd in SEEDS) or "findings-emnlp.379" in k:
|
||||
stage = "SCREEN"
|
||||
elif lit_only:
|
||||
stage = "SCREEN" if LLM_SEARCH.search(m["title"]) else "EXCLUDE-offtopic"
|
||||
elif year and year < 2020:
|
||||
stage = "EXCLUDE-pre2020"
|
||||
elif TOPIC.search(text) and TRANSFORMER.search(text):
|
||||
stage = "SCREEN"
|
||||
else:
|
||||
stage = "EXCLUDE-offtopic"
|
||||
prev = old.get(k, {})
|
||||
rows.append({
|
||||
"key": k, "year": m.get("year", ""), "title": m["title"],
|
||||
"first_author": m.get("first_author", ""), "venue": m.get("venue", ""),
|
||||
"citations": m.get("citations", ""), "found_by": ";".join(sorted(m["found_by"])),
|
||||
"n_sources": len(m["found_by"]), "auto_stage": stage,
|
||||
"ai_proposal": prev.get("ai_proposal", ""), "decision": prev.get("decision", ""),
|
||||
"reason": prev.get("reason", ""), "abstract": m.get("abstract", ""),
|
||||
})
|
||||
rows.sort(key=lambda r: (r["auto_stage"] != "SCREEN", -r["n_sources"], str(r["year"])))
|
||||
with open(OUT, "w", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=list(rows[0]))
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
from collections import Counter
|
||||
c = Counter(r["auto_stage"] for r in rows)
|
||||
print(f"{len(rows)} unique records -> {os.path.relpath(OUT, SEARCH)}: {dict(c)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tiny Semantic Scholar Graph API client for the literature search (stdlib only).
|
||||
|
||||
Every API response is cached under ../search/.cache/ so re-running a search is
|
||||
free and reproducible, and the raw JSON is kept as an audit trail.
|
||||
|
||||
Usage:
|
||||
python3 s2.py search "query string" [--year 2020-] [--limit 100] [--out NAME]
|
||||
python3 s2.py refs ARXIV_ID_OR_S2_ID [--out NAME] # backward snowball
|
||||
python3 s2.py cites ARXIV_ID_OR_S2_ID [--out NAME] # forward snowball
|
||||
|
||||
Output: a CSV in ../search/results/ (one row per paper) and a line appended
|
||||
to ../search/search-log.csv recording what was run, when, and how many hits.
|
||||
|
||||
Set S2_API_KEY in the environment if you get one (free, raises rate limits).
|
||||
"""
|
||||
import csv
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SEARCH_DIR = os.path.normpath(os.path.join(HERE, "..", "search"))
|
||||
CACHE = os.path.join(SEARCH_DIR, ".cache")
|
||||
RESULTS = os.path.join(SEARCH_DIR, "results")
|
||||
LOG = os.path.join(SEARCH_DIR, "search-log.csv")
|
||||
API = "https://api.semanticscholar.org/graph/v1"
|
||||
FIELDS = "paperId,externalIds,title,year,venue,authors,citationCount,abstract,publicationDate"
|
||||
|
||||
|
||||
def get(path, params):
|
||||
url = f"{API}/{path}?{urllib.parse.urlencode(params)}"
|
||||
key = hashlib.sha1(url.encode()).hexdigest()
|
||||
cpath = os.path.join(CACHE, key + ".json")
|
||||
if os.path.exists(cpath):
|
||||
return json.load(open(cpath))
|
||||
headers = {"User-Agent": "msc-thesis-lit-search/0.1"}
|
||||
if os.environ.get("S2_API_KEY"):
|
||||
headers["x-api-key"] = os.environ["S2_API_KEY"]
|
||||
for attempt in range(int(os.environ.get("S2_RETRIES", 8))):
|
||||
try:
|
||||
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=60) as r:
|
||||
data = json.load(r)
|
||||
os.makedirs(CACHE, exist_ok=True)
|
||||
json.dump(data, open(cpath, "w"))
|
||||
time.sleep(1.2) # stay under the shared rate limit
|
||||
return data
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
return None
|
||||
if e.code not in (429, 500, 502, 503, 504):
|
||||
raise
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
pass
|
||||
wait = min(60, 3 * 2 ** attempt)
|
||||
print(f" rate-limited/transient error; retry in {wait}s", file=sys.stderr)
|
||||
time.sleep(wait)
|
||||
sys.exit(f"gave up on {url}")
|
||||
|
||||
|
||||
def norm_id(pid):
|
||||
if pid[:1].isdigit() and "." in pid: # bare arXiv id like 2309.14322
|
||||
return "arXiv:" + pid
|
||||
return pid
|
||||
|
||||
|
||||
def row(p):
|
||||
ext = p.get("externalIds") or {}
|
||||
return {
|
||||
"s2_id": p.get("paperId"),
|
||||
"arxiv": ext.get("ArXiv", ""),
|
||||
"doi": ext.get("DOI", ""),
|
||||
"year": p.get("year") or "",
|
||||
"title": " ".join((p.get("title") or "").split()),
|
||||
"first_author": ((p.get("authors") or [{}])[0] or {}).get("name", ""),
|
||||
"n_authors": len(p.get("authors") or []),
|
||||
"venue": p.get("venue") or "",
|
||||
"citations": p.get("citationCount") or 0,
|
||||
"abstract": " ".join((p.get("abstract") or "").split()),
|
||||
}
|
||||
|
||||
|
||||
def search(query, year=None, limit=100):
|
||||
"""Relevance-ranked search (top `limit` hits)."""
|
||||
out, offset, total = [], 0, 0
|
||||
while len(out) < limit:
|
||||
params = {"query": query, "fields": FIELDS, "offset": offset, "limit": min(100, limit - len(out))}
|
||||
if year:
|
||||
params["year"] = year
|
||||
data = get("paper/search", params)
|
||||
out += data.get("data") or []
|
||||
total = data.get("total", len(out))
|
||||
if "next" not in data:
|
||||
break
|
||||
offset = data["next"]
|
||||
return out[:limit], total
|
||||
|
||||
|
||||
def snowball(pid, direction):
|
||||
field = "citedPaper" if direction == "refs" else "citingPaper"
|
||||
path = f"paper/{norm_id(pid)}/{'references' if direction == 'refs' else 'citations'}"
|
||||
out, offset = [], 0
|
||||
while True:
|
||||
data = get(path, {"fields": FIELDS, "limit": 500, "offset": offset})
|
||||
if data is None:
|
||||
sys.exit(f"paper not found: {pid}")
|
||||
out += [d[field] for d in data.get("data") or [] if d.get(field, {}).get("title")]
|
||||
if "next" not in data:
|
||||
return out, len(out)
|
||||
offset = data["next"]
|
||||
|
||||
|
||||
def write(name, papers, what, total):
|
||||
os.makedirs(RESULTS, exist_ok=True)
|
||||
rows = [row(p) for p in papers]
|
||||
path = os.path.join(RESULTS, name + ".csv")
|
||||
with open(path, "w", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=list(rows[0]) if rows else ["s2_id"])
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
new = not os.path.exists(LOG)
|
||||
with open(LOG, "a", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
if new:
|
||||
w.writerow(["date", "source", "action", "query_or_seed", "filters", "total_hits", "retrieved", "results_file"])
|
||||
w.writerow([dt.date.today().isoformat(), "Semantic Scholar API", *what, total, len(rows), os.path.relpath(path, SEARCH_DIR)])
|
||||
print(f"{what[0]} '{what[1]}': {total} total, {len(rows)} saved -> {os.path.relpath(path, SEARCH_DIR)}")
|
||||
|
||||
|
||||
def main(a):
|
||||
def opt(flag, default=None):
|
||||
return a[a.index(flag) + 1] if flag in a else default
|
||||
|
||||
cmd, arg = a[0], a[1]
|
||||
out = opt("--out", hashlib.sha1(f"{cmd}{arg}".encode()).hexdigest()[:8])
|
||||
if cmd == "search":
|
||||
year = opt("--year")
|
||||
papers, total = search(arg, year, int(opt("--limit", 100)))
|
||||
write(out, papers, ("search", arg, f"year={year or 'any'}"), total)
|
||||
elif cmd in ("refs", "cites"):
|
||||
papers, total = snowball(arg, cmd)
|
||||
label = "backward snowball (references)" if cmd == "refs" else "forward snowball (citations)"
|
||||
write(out, papers, (label, arg, ""), total)
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Keyword search over OpenAlex and arXiv for the literature review (stdlib only).
|
||||
|
||||
Usage:
|
||||
python3 search.py openalex 'TERMS' --out NAME [--since 2020-01-01] [--limit 200]
|
||||
python3 search.py arxiv 'ARXIV_QUERY' --out NAME [--since 2020-01-01] [--limit 200]
|
||||
|
||||
OpenAlex TERMS: '|'-separated phrases (OR), ' AND ' between concept blocks, e.g.
|
||||
'"qk-norm"|"query-key normalization"'
|
||||
arXiv ARXIV_QUERY: arXiv API syntax, e.g.
|
||||
'abs:"qk norm" OR abs:"query-key normalization"'
|
||||
|
||||
Writes ../search/results/NAME.csv and appends a row to ../search/search-log.csv
|
||||
(same format as s2.py). Raw responses are cached in ../search/.cache/.
|
||||
"""
|
||||
import csv
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SEARCH_DIR = os.path.normpath(os.path.join(HERE, "..", "search"))
|
||||
CACHE = os.path.join(SEARCH_DIR, ".cache")
|
||||
RESULTS = os.path.join(SEARCH_DIR, "results")
|
||||
LOG = os.path.join(SEARCH_DIR, "search-log.csv")
|
||||
COLS = ["source_id", "arxiv", "doi", "year", "title", "first_author", "n_authors", "venue", "citations", "abstract"]
|
||||
NS = {"a": "http://www.w3.org/2005/Atom"}
|
||||
|
||||
|
||||
def fetch(url, raw=False):
|
||||
key = os.path.join(CACHE, hashlib.sha1(url.encode()).hexdigest())
|
||||
if os.path.exists(key):
|
||||
body = open(key, "rb").read()
|
||||
else:
|
||||
for attempt in range(6):
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "msc-thesis-lit-search/0.1"})
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
body = r.read()
|
||||
break
|
||||
except Exception as e:
|
||||
wait = 5 * 2 ** attempt
|
||||
print(f" {e}; retry in {wait}s", file=sys.stderr)
|
||||
time.sleep(wait)
|
||||
else:
|
||||
sys.exit(f"gave up on {url}")
|
||||
os.makedirs(CACHE, exist_ok=True)
|
||||
open(key, "wb").write(body)
|
||||
time.sleep(1)
|
||||
return body if raw else json.loads(body)
|
||||
|
||||
|
||||
def clean(s):
|
||||
return " ".join((s or "").split())
|
||||
|
||||
|
||||
def openalex_abstract(inv):
|
||||
if not inv:
|
||||
return ""
|
||||
pos = sorted((i, w) for w, idx in inv.items() for i in idx)
|
||||
return " ".join(w for _, w in pos)
|
||||
|
||||
|
||||
def openalex(terms, since, limit):
|
||||
# ' AND ' separates concept blocks; each block is an OR-list of phrases.
|
||||
blocks = [f"title_and_abstract.search:{b.strip()}" for b in terms.split(" AND ")]
|
||||
filt = ",".join(blocks + ([f"from_publication_date:{since}"] if since else []))
|
||||
rows, page, total = [], 1, 0
|
||||
while len(rows) < limit:
|
||||
url = "https://api.openalex.org/works?" + urllib.parse.urlencode(
|
||||
{"filter": filt, "per_page": 100, "page": page}
|
||||
)
|
||||
d = fetch(url)
|
||||
total = d["meta"]["count"]
|
||||
for w in d["results"]:
|
||||
ids = w.get("ids") or {}
|
||||
doi = (w.get("doi") or "").replace("https://doi.org/", "")
|
||||
arxiv = doi.lower().split("arxiv.")[-1] if "arxiv." in doi.lower() else ""
|
||||
src = ((w.get("primary_location") or {}).get("source") or {}).get("display_name", "")
|
||||
auth = w.get("authorships") or []
|
||||
rows.append({
|
||||
"source_id": ids.get("openalex", "").split("/")[-1],
|
||||
"arxiv": arxiv, "doi": doi, "year": w.get("publication_year") or "",
|
||||
"title": clean(w.get("title")),
|
||||
"first_author": (auth[0]["author"]["display_name"] if auth else ""),
|
||||
"n_authors": len(auth), "venue": src, "citations": w.get("cited_by_count") or 0,
|
||||
"abstract": clean(openalex_abstract(w.get("abstract_inverted_index"))),
|
||||
})
|
||||
if len(d["results"]) < 100:
|
||||
break
|
||||
page += 1
|
||||
return rows[:limit], total
|
||||
|
||||
|
||||
def arxiv(query, since, limit):
|
||||
rows, start, total = [], 0, 0
|
||||
while len(rows) < limit:
|
||||
url = "https://export.arxiv.org/api/query?" + urllib.parse.urlencode(
|
||||
{"search_query": query, "start": start, "max_results": 100, "sortBy": "relevance"}
|
||||
)
|
||||
root = ET.fromstring(fetch(url, raw=True))
|
||||
total = int(root.find("{http://a9.com/-/spec/opensearch/1.1/}totalResults").text)
|
||||
entries = root.findall("a:entry", NS)
|
||||
for e in entries:
|
||||
year = e.find("a:published", NS).text[:10]
|
||||
if since and year < since:
|
||||
continue
|
||||
aid = e.find("a:id", NS).text.split("/abs/")[-1].rsplit("v", 1)[0]
|
||||
au = e.findall("a:author", NS)
|
||||
rows.append({
|
||||
"source_id": aid, "arxiv": aid, "doi": "", "year": year[:4],
|
||||
"title": clean(e.find("a:title", NS).text),
|
||||
"first_author": au[0].find("a:name", NS).text if au else "",
|
||||
"n_authors": len(au), "venue": "arXiv", "citations": "",
|
||||
"abstract": clean(e.find("a:summary", NS).text),
|
||||
})
|
||||
if len(entries) < 100:
|
||||
break
|
||||
start += 100
|
||||
time.sleep(3) # arXiv asks for >=3s between calls
|
||||
return rows[:limit], total
|
||||
|
||||
|
||||
def main(a):
|
||||
def opt(flag, default=None):
|
||||
return a[a.index(flag) + 1] if flag in a else default
|
||||
|
||||
src, query, out = a[0], a[1], opt("--out")
|
||||
since, limit = opt("--since", "2020-01-01"), int(opt("--limit", 200))
|
||||
rows, total = {"openalex": openalex, "arxiv": arxiv}[src](query, since, limit)
|
||||
os.makedirs(RESULTS, exist_ok=True)
|
||||
path = os.path.join(RESULTS, out + ".csv")
|
||||
with open(path, "w", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=COLS)
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
new = not os.path.exists(LOG)
|
||||
with open(LOG, "a", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
if new:
|
||||
w.writerow(["date", "source", "action", "query_or_seed", "filters", "total_hits", "retrieved", "results_file"])
|
||||
w.writerow([dt.date.today().isoformat(), {"openalex": "OpenAlex API", "arxiv": "arXiv API"}[src],
|
||||
"search", query, f"since={since}", total, len(rows), os.path.relpath(path, SEARCH_DIR)])
|
||||
print(f"{src} {out}: {total} total, {len(rows)} saved")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
Reference in new issue
Block a user