SPB Git

spb/wp44_uqo

Public
8.7 KB · 151 lines python
1#!/usr/bin/env python32"""WP44 — Phase 2: assign the 40 ontology categories (multi-label) to every work of the corpus.34Signals per work: OpenAlex topics (ids + subfield ids), arXiv categories (through link_arxiv_oa), DBLP venue stream5(through link_dblp_oa), keyword regexes on title and abstract. Scoring follows ontology.yaml `assignment_rule`:6per signal type the matched weights are summed and capped at 1.0, then summed over signal types; a category is assigned7if score >= threshold; primary = argmax (ties: child before parent, then lower category number).89Step 1 (`--prepare`): DuckDB builds data/processed/classification/input/chunk_*.parquet (id, year, title, abstract,10topic_ids, subfield_ids, arxiv_cats, dblp_streams). Step 2 (`--run`): multiprocessing over chunks →11data/processed/classification/work_categories.parquet (work_id, category_id, score, s_topic, s_subfield, s_arxiv,12s_dblp, s_kw) and work_primary.parquet (work_id, primary_category, n_categories). Deterministic; version-stamped.13"""14import argparse, glob, json, multiprocessing as mp, os, re, time, datetime as dt15import yaml, duckdb16import pyarrow as pa, pyarrow.parquet as pq1718ONT = None192021def load_ontology(path):22    o = yaml.safe_load(open(path))23    cats = o["categories"]24    comp = []25    for c in cats:26        kws = []27        for k in (c.get("keywords") or []):28            kws.append((re.compile(k["pattern"]), float(k["weight"]), k.get("field", "both")))29        exc = [re.compile(p if isinstance(p, str) else p["pattern"]) for p in (c.get("exclusions") or [])]30        comp.append(dict(31            id=c["id"], number=int(c["number"]), parent=c.get("child_of"),32            topics={str(t["id"]).lstrip("T"): float(t["weight"]) for t in (c.get("openalex_topics") or [])},33            subfields={str(t["id"]): float(t["weight"]) for t in (c.get("openalex_subfields") or [])},34            arxiv={t["id"]: float(t["weight"]) for t in (c.get("arxiv_categories") or [])},35            dblp={t["key"]: float(t["weight"]) for t in (c.get("dblp_streams") or [])},36            kws=kws, exc=exc))37    rule = o.get("assignment_rule", {})38    return dict(cats=comp, threshold=float(rule.get("threshold", 1.0)), version=o.get("version"))394041def _init(path):42    global ONT43    ONT = load_ontology(path)444546def score_row(title, abstract, topic_ids, subfield_ids, arxiv_cats, dblp_streams):47    title = title or ""; abstract = abstract or ""; both = title + "\n" + abstract48    out = []49    for c in ONT["cats"]:50        st = min(1.0, sum(c["topics"].get(str(t), 0.0) for t in set(topic_ids or [])))51        ss = min(1.0, sum(c["subfields"].get(str(s), 0.0) for s in set(subfield_ids or [])))52        sa = min(1.0, sum(c["arxiv"].get(a, 0.0) for a in set(arxiv_cats or [])))53        sd = min(1.0, sum(c["dblp"].get(d, 0.0) for d in set(dblp_streams or [])))54        sk = 0.055        for rx, w, field in c["kws"]:56            txt = title if field == "title" else abstract if field == "abstract" else both57            if txt and rx.search(txt):58                sk += w59                if sk >= 1.0:60                    sk = 1.0; break61        s = st + ss + sa + sd + sk62        if s >= ONT["threshold"] and c["exc"] and any(rx.search(both) for rx in c["exc"]):63            s = 0.064        if s >= ONT["threshold"]:65            out.append((c["id"], c["number"], c["parent"], round(s, 3), st, ss, sa, sd, sk))66    return out676869def process_chunk(args):70    path, out_dir = args71    name = os.path.basename(path).replace(".parquet", "")72    o1 = os.path.join(out_dir, "cats", name + ".parquet"); o2 = os.path.join(out_dir, "primary", name + ".parquet")73    if os.path.exists(o1) and os.path.exists(o2):74        return name, 0, 075    t = pq.read_table(path).to_pydict()76    cw, cc, cs, c1, c2, c3, c4, c5 = [], [], [], [], [], [], [], []77    pw, pc, pn, ps = [], [], [], []78    n = len(t["id"])79    for i in range(n):80        res = score_row(t["title"][i], t["abstract"][i], t["topic_ids"][i], t["subfield_ids"][i], t["arxiv_cats"][i], t["dblp_streams"][i])81        for (cid, num, parent, s, st, ss, sa, sd, sk) in res:82            cw.append(t["id"][i]); cc.append(cid); cs.append(s); c1.append(st); c2.append(ss); c3.append(sa); c4.append(sd); c5.append(sk)83        if res:84            parents = {r[2] for r in res if r[2]}85            # primary: highest score; tie → child before parent (a category that is someone's parent loses), then lower number86            best = sorted(res, key=lambda r: (-r[3], 1 if r[0] in parents else 0, r[1]))[0]87            pw.append(t["id"][i]); pc.append(best[0]); pn.append(len(res)); ps.append(best[3])88    os.makedirs(os.path.dirname(o1), exist_ok=True); os.makedirs(os.path.dirname(o2), exist_ok=True)89    pq.write_table(pa.table({"work_id": pa.array(cw, pa.int64()), "category_id": cc, "score": pa.array(cs, pa.float32()), "s_topic": pa.array(c1, pa.float32()),90                             "s_subfield": pa.array(c2, pa.float32()), "s_arxiv": pa.array(c3, pa.float32()), "s_dblp": pa.array(c4, pa.float32()), "s_kw": pa.array(c5, pa.float32())}), o1 + ".part", compression="zstd")91    os.replace(o1 + ".part", o1)92    pq.write_table(pa.table({"work_id": pa.array(pw, pa.int64()), "primary_category": pc, "n_categories": pa.array(pn, pa.int16()), "primary_score": pa.array(ps, pa.float32())}), o2 + ".part", compression="zstd")93    os.replace(o2 + ".part", o2)94    return name, n, len(pw)959697def prepare(root, db, out_dir, chunk_rows):98    con = duckdb.connect(db); con.execute("SET threads TO 24; SET memory_limit='60GB';")99    os.makedirs(os.path.join(out_dir, "input"), exist_ok=True)100    con.execute("""CREATE OR REPLACE TABLE cls_input AS101        SELECT w.id, w.publication_year AS year, w.title, a.abstract,102               (SELECT list(topic_id ORDER BY rank) FROM oa_work_topics t WHERE t.work_id = w.id) AS topic_ids,103               (SELECT list(subfield_id ORDER BY rank) FROM oa_work_topics t WHERE t.work_id = w.id) AS subfield_ids,104               (SELECT flatten(list(x.categories)) FROM link_arxiv_oa l JOIN arxiv_keys x USING(arxiv_id) WHERE l.oa_id = w.id) AS arxiv_cats,105               (SELECT list(DISTINCT d.stream) FROM link_dblp_oa l JOIN dblp_keys d USING(dblp_key) WHERE l.oa_id = w.id) AS dblp_streams106        FROM corpus_works w LEFT JOIN oa_abstracts a ON a.work_id = w.id""")107    n = con.execute("SELECT count(*) FROM cls_input").fetchone()[0]108    con.execute(f"""COPY (SELECT *, (row_number() OVER ()) // {chunk_rows} AS chunk FROM cls_input) TO '{out_dir}/input' (FORMAT PARQUET, PARTITION_BY (chunk), OVERWRITE_OR_IGNORE, COMPRESSION ZSTD)""")109    print("prepared", n, "works")110111112def main():113    ap = argparse.ArgumentParser(); ap.add_argument("--root", required=True); ap.add_argument("--ontology", required=True)114    ap.add_argument("--prepare", action="store_true"); ap.add_argument("--run", action="store_true"); ap.add_argument("--workers", type=int, default=20)115    ap.add_argument("--chunk-rows", type=int, default=100000); ap.add_argument("--limit", type=int, default=0)116    a = ap.parse_args(); out_dir = os.path.join(a.root, "data/processed/classification"); db = os.path.join(a.root, "data/processed/wp44.duckdb")117    if a.prepare:118        prepare(a.root, db, out_dir, a.chunk_rows)119    if a.run:120        files = sorted(glob.glob(os.path.join(out_dir, "input", "chunk=*", "*.parquet")))121        files = [(f, out_dir) for f in files]122        if a.limit:123            files = files[: a.limit]124        # chunk files share the name data_0.parquet inside chunk=N dirs → rename key125        files = [(f, out_dir) for f, _ in files]126        t0 = time.time(); tot = 0; cl = 0127        with mp.Pool(a.workers, initializer=_init, initargs=(a.ontology,)) as pool:128            for name, n, k in pool.imap_unordered(process_chunk_named, [(f, out_dir, os.path.basename(os.path.dirname(f))) for f, _ in files]):129                tot += n; cl += k130                print(f"[{time.time()-t0:6.0f}s] {name}: {n} works, {k} classified | total {tot} / classified {cl}", flush=True)131        json.dump({"ontology": a.ontology, "version": load_ontology(a.ontology)["version"], "works": tot, "classified": cl, "when": dt.datetime.now(dt.UTC).isoformat(),132                   "seconds": round(time.time() - t0)}, open(os.path.join(out_dir, "classification_report.json"), "w"), indent=1)133134135def process_chunk_named(args):136    path, out_dir, chunk_name = args137    name = chunk_name.replace("=", "_")138    o1 = os.path.join(out_dir, "cats", name + ".parquet"); o2 = os.path.join(out_dir, "primary", name + ".parquet")139    if os.path.exists(o1) and os.path.exists(o2):140        return name, 0, 0141    # reuse process_chunk with a symlinked name142    tmp = os.path.join(out_dir, "input_named"); os.makedirs(tmp, exist_ok=True)143    link = os.path.join(tmp, name + ".parquet")144    if not os.path.exists(link):145        os.symlink(path, link)146    return process_chunk((link, out_dir))147148149if __name__ == "__main__":150    main()151
Files · 9ea41439e5f6cdefefe57c3b175ecc90647b4d50
Menu

Theme