SPB Git

spb/wp44_uqo

Public
69.1 KB · 515 lines python
1#!/usr/bin/env python32"""WP44 — generate LaTeX tables (booktabs) from result files into manuscript/tables/*.tex. Every table is regenerated from3CSV/JSON artefacts; the inventory manuscript/tables/TABLES.json records source files and captions for Appendix G."""4import json, os, sys, csv, glob5import pandas as pd6R = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname(os.path.dirname(os.path.abspath(__file__)))7OUT = os.path.join(R, "manuscript/tables"); os.makedirs(OUT, exist_ok=True); INV = []8910def tex_escape(s):11    s = str(s).replace("&", "\\&").replace("%", "\\%").replace("_", "\\_").replace("#", "\\#")12    for a, b in (("↔", "$\\leftrightarrow$"), ("→", "$\\rightarrow$"), ("…", "\\ldots{}"), ("∨", "$\\vee$"), ("∧", "$\\wedge$"), ("≥", "$\\geq$"), ("≤", "$\\leq$"), ("–", "--"), ("—", "---"), ("×", "$\\times$")):13        s = s.replace(a, b)14    return s151617def write(name, df, caption, label, sources, fmt=None, col_names=None, longtable=False, note=None):18    df = df.copy()19    if col_names: df.columns = col_names20    for c in df.columns:21        if pd.api.types.is_float_dtype(df[c]):22            df[c] = df[c].map(lambda v: "" if pd.isna(v) else (fmt or {}).get(c, "{:,.3f}").format(v).replace(",", "{,}"))23        elif pd.api.types.is_integer_dtype(df[c]):24            df[c] = df[c].map(lambda v: f"{v:,}".replace(",", "{,}"))25        else:26            df[c] = df[c].map(lambda v: tex_escape("" if pd.isna(v) else v))27    cols = "l" + "r" * (len(df.columns) - 1)28    head = " & ".join(f"\\textbf{{{tex_escape(c)}}}" for c in df.columns) + " \\\\"29    body = "\n".join(" & ".join(map(str, r)) + " \\\\" for r in df.itertuples(index=False))30    env = "longtable" if longtable else "tabular"31    s = f"% generated by tables/make_tables.py from {', '.join(sources)}\n"32    if longtable:33        s += f"\\begin{{longtable}}{{{cols}}}\n\\caption{{{caption}}}\\label{{{label}}}\\\\\n\\toprule\n{head}\n\\midrule\n\\endfirsthead\n\\toprule\n{head}\n\\midrule\n\\endhead\n{body}\n\\bottomrule\n"34        if note: s += f"\\multicolumn{{{len(df.columns)}}}{{p{{0.95\\linewidth}}}}{{\\footnotesize {note}}}\\\\\n"35        s += "\\end{longtable}\n"36    else:37        s += f"\\begin{{table}}[htbp]\\centering\\caption{{{caption}}}\\label{{{label}}}\n\\begin{{threeparttable}}\\small\n\\begin{{tabular}}{{{cols}}}\n\\toprule\n{head}\n\\midrule\n{body}\n\\bottomrule\n\\end{{tabular}}\n"38        if note: s += f"\\begin{{tablenotes}}\\footnotesize\\item {note}\\end{{tablenotes}}\n"39        s += "\\end{threeparttable}\\end{table}\n"40    open(os.path.join(OUT, name + ".tex"), "w").write(s); INV.append({"file": name + ".tex", "caption": caption, "label": label, "sources": sources, "rows": int(len(df))})414243def main():44    p = f"{R}/data/metadata/prisma_counts.json"45    if os.path.exists(p):46        P = json.load(open(p)); rows = [("Identified: OpenAlex works with a CS topic, 2018–2026 (snapshot 2026-10-14, both passes)", P["identified"]["openalex_total_records_loaded"])]47        labels = {"e1_outside_window": "E1 outside the window", "e2_type": "E2 non-research document type", "e3_xpac": "E3 XPAC expansion record (not DBLP-linked)", "e4_no_title": "E4 no title", "e5_duplicate": "E5 preprint merged into its published version", "e7_generic_deposit": "E7 generic deposit platform without DBLP/arXiv support"}48        rows += [("Excluded " + labels.get(k, k), v) for k, v in P["excluded_sequential"].items()]49        rows += [("After exclusions", P["after_exclusions"]), ("CS core (mapping corpus)", P["cs_core_eligible"]), ("CS penumbra (sensitivity set)", P["cs_penumbra_eligible_otherwise"]), ("Strict core: DBLP-indexed and primary field CS", P["strict_core_dblp_and_cs_primary"])]50        write("tab_prisma", pd.DataFrame(rows, columns=["Stage", "Records"]), "Record flow of the mapping corpus (PRISMA-style, sequential exclusions).", "tab:prisma", ["data/metadata/prisma_counts.json"])51        write("tab_eligible_by_year", pd.DataFrame(sorted(P["eligible_by_year"].items()), columns=["Year", "Eligible works"]), "Eligible works by publication year (2026 incomplete).", "tab:eligible_year", ["data/metadata/prisma_counts.json"])52        write("tab_eligible_by_type", pd.DataFrame(P["eligible_by_type"].items(), columns=["OpenAlex type", "Eligible works"]), "Eligible works by document type.", "tab:eligible_type", ["data/metadata/prisma_counts.json"])53    p = f"{R}/data/metadata/linkage_report.json"54    if os.path.exists(p):55        L = json.load(open(p)); rows = [("DBLP records 2018–2026", L["dblp_2018_2026"]), ("… with DOI", L["dblp_with_doi"]), ("DBLP ↔ OpenAlex links by DOI", L["link_dblp_oa"].get("doi", 0)), ("… by normalised title + year (+ first author)", L["link_dblp_oa"].get("title_year", 0)), ("DBLP records linked (distinct)", L["dblp_linked_distinct"]), ("DBLP records without OpenAlex record", L["dblp_unlinked"]),56                                       ("arXiv cs records created 2018–2026", L["arxiv_created_2018_2026"]), ("arXiv ↔ OpenAlex by arXiv id", L["link_arxiv_oa"].get("arxiv_id", 0)), ("… by DOI", L["link_arxiv_oa"].get("doi", 0)), ("… by title + year", L["link_arxiv_oa"].get("title_year", 0)), ("ACL entries 2018–2026", L["acl_2018_2026"]), ("ACL ↔ OpenAlex linked (2018–2026)", L["acl_linked_distinct_2018_2026"])]57        write("tab_linkage", pd.DataFrame(rows, columns=["Quantity", "Count"]), "Record linkage between sources.", "tab:linkage", ["data/metadata/linkage_report.json"])58    for f, name, cap, cols in [("volume_yearly", "tab_volume_yearly", "Yearly volume and composition of the eligible corpus.", ["year", "n", "n_dblp", "n_cs_primary", "n_arxiv", "n_abstract", "n_oa", "n_preprint", "n_conf", "n_article", "mean_authors", "mean_refs"]),59                               ("citation_windows_yearly", "tab_citation_windows", "Fixed-window citation statistics by publication year (c12: publication year + 1; c36: + 2 calendar years).", ["year", "n", "mean_c12", "mean_c36", "med_c36", "share_uncited_36", "p99_c36", "gini_c36", "gini_c12", "mean_fwci"]),60                               ("team_size_yearly", "tab_team_size", "Team size and collaboration by year.", ["year", "n", "mean_authors", "med_authors", "single", "five_plus", "ten_plus", "intl", "multi_inst"]),61                               ("industry_share_yearly", "tab_industry", "Works with a company affiliation by year.", ["year", "n_with_inst", "n_company", "share_company"]),62                               ("venue_hhi_yearly", "tab_venue_hhi", "Venue concentration by year.", ["year", "n", "n_sources", "hhi", "top1_share"]),63                               ("diversity_yearly", "tab_diversity", "Rao–Stirling and Leinster–Cobbold diversity of the category mix by year (bootstrap 95\\% CI).", ["year", "n", "rao_stirling", "rs_ci_low", "rs_ci_high", "leinster_cobbold_q2"]),64                               ("cagr_by_category", "tab_cagr", "Compound annual growth by category, baseline (2018–2022) and post (2023–2025).", ["category", "n_2018", "n_2022", "n_2025", "cagr_baseline", "cagr_post", "share_2018", "share_2025"])]:65        p = f"{R}/results/bibliometrics/{f}.csv"66        if os.path.exists(p):67            d = pd.read_csv(p)68            if "period" in d.columns: d = d[d.period.isin(["baseline", "post"])]69            d = d[[c for c in cols if c in d.columns]]70            if "team" in f or "volume" in f: d = d[d.iloc[:, 0] <= 2026]71            write(name, d, cap, f"tab:{name[4:]}", [f"results/bibliometrics/{f}.csv"], longtable=len(d) > 30)72    p = f"{R}/results/bibliometrics/countries_yearly.csv"73    if os.path.exists(p):74        d = pd.read_csv(p); top = d.groupby("country").n_frac.sum().nlargest(15).index; piv = d[d.country.isin(top)].pivot_table(index="country", columns="year", values="n_frac").loc[top]75        piv = piv.round(0).astype(int).reset_index(); write("tab_countries", piv, "Fractional output of the fifteen largest countries by year.", "tab:countries", [p])76    p = f"{R}/results/econometrics/breaks.csv"77    if os.path.exists(p):78        d = pd.read_csv(p); cols = ["series", "chatgpt_level", "chatgpt_level_p", "chatgpt_slope", "chatgpt_slope_p", "placebo_level_p", "placebo_slope_p", "bp_breaks_bic", "pelt"]79        write("tab_breaks", d[[c for c in cols if c in d.columns]], "Structural-change estimates by series: ITS level and slope changes at 2022-12 (HAC p-values), placebo p-values, Bai–Perron (BIC) and PELT break dates.", "tab:breaks", [p], longtable=len(d) > 25)80    p = f"{R}/taxonomy/validation/metrics.json"81    if os.path.exists(p):82        M = json.load(open(p)); rows = []83        for k, v in M.items():84            if isinstance(v, dict) and "per_category" in v:85                for c, m in v["per_category"].items():86                    rows.append({"comparison": k, "category": c, "precision": m["precision"], "recall": m["recall"], "f1": m["f1"], "tp": m["tp"], "fp": m["fp"], "fn": m["fn"]})87        if rows: write("tab_validation", pd.DataFrame(rows), "Taxonomy validation: precision, recall and F1 of the rule-based assignment against each rater and the rater consensus, by category.", "tab:validation", [p], longtable=True)88    for f in glob.glob(f"{R}/results/emerging/metrics_h*.json"):89        M = json.load(open(f)); rows = [{"model": m, "AUC": M[m]["auc"], "P@20": M[m]["precision_at_20"], "P@50": M[m]["precision_at_50"]} for m in ("zscore_baseline", "logistic", "gbm") if m in M]90        write("tab_emerging_h%d" % M["horizon"], pd.DataFrame(rows), f"Out-of-time performance of the emerging-topic detectors at horizon {M['horizon']} months (test cutoffs {M['test_cutoffs'][0]}–{M['test_cutoffs'][-1]}).", f"tab:emerging_h{M['horizon']}", [f])91    p = f"{R}/results/emerging/known_set_lead_times.csv"92    if os.path.exists(p):93        d = pd.read_csv(p)[["concept", "total_works", "detection_month", "peak_growth_month", "lead_months"]]; write("tab_known_set", d, "Preregistered known-emergence set: detection month, peak-growth month and lead time.", "tab:known_set", [p])94    tables_part08()  # Part VIII tables (appended 2026-11-03)95    tables_part07b()  # Part VII-b security tables (Agent H, appended 2026-11-03)96    tables_part06()  # Part VI systems table (appended by Agent F, 2026-11-03)97    tables_part09()  # Part IX results tables (appended by the Part IX writer, 2026-11-03)98    json.dump(INV, open(os.path.join(OUT, "TABLES.json"), "w"), indent=1); print(len(INV), "tables")99100101# ---------------- Part VIII (Scientific Knowledge Networks): tables appended by Agent L/I, 2026-11-03 ----------------102def _ascii(s, n=None):103    """Titles and venue names contain characters (U+2010, curly quotes, ...) that inputenc does not define; normalise to ASCII."""104    import unicodedata105    t = str(s).replace("‐", "-").replace("‑", "-").replace("–", "-").replace("—", "-").replace("’", "'").replace("‘", "'").replace("“", '"').replace("”", '"').replace("×", "x")106    t = unicodedata.normalize("NFKD", t).encode("ascii", "ignore").decode("ascii").replace("$", "\\$").replace("^", "").replace("~", "-").replace("{", "").replace("}", "")107    return (t[: n - 3].rstrip() + "\\dots") if (n and len(t) > n) else t108109110def tables_part08():111    B = f"{R}/results/bibliometrics"; G = f"{R}/results/graph"112    p = f"{B}/team_size_yearly.csv"; q = f"{B}/international_collab_yearly.csv"113    if os.path.exists(p):114        d = pd.read_csv(p); d = d[d.year <= 2026]115        d["sum_authors"] = d.mean_authors * d.n116        g = d.groupby("year").agg(n=("n", "sum"), sum_authors=("sum_authors", "sum"), single=("single", "sum"), five_plus=("five_plus", "sum"), ten_plus=("ten_plus", "sum"), multi_inst=("multi_inst", "sum"), no_country=("no_country", "sum")).reset_index()117        t = pd.DataFrame({"Year": g.year, "Works": g.n, "Mean authors": g.sum_authors / g.n, "Single (%)": 100 * g.single / g.n, "5+ (%)": 100 * g.five_plus / g.n, "10+ (%)": 100 * g.ten_plus / g.n, "Multi-inst. (%)": 100 * g.multi_inst / g.n, "No country (%)": 100 * g.no_country / g.n})118        if os.path.exists(q):119            i = pd.read_csv(q); t = t.merge(pd.DataFrame({"Year": i.year, "Intl. (%)": 100 * i.share_intl}), on="Year", how="left")120        t["Year"] = t.Year.astype(str)121        write("tab_collaboration", t, "Team size and collaboration indicators by publication year (all eligible works; periods of the source file summed).", "tab:collaboration", [p, q], fmt={"Mean authors": "{:.3f}", "Single (%)": "{:.1f}", "5+ (%)": "{:.1f}", "10+ (%)": "{:.1f}", "Multi-inst. (%)": "{:.1f}", "No country (%)": "{:.1f}", "Intl. (%)": "{:.1f}"},122              note="Columns: works with one author, five or more, ten or more authors, and more than one institution. Shares are over all eligible works of the year, including works without affiliation data (column `No country'); the international share is over works with at least one resolved country (ICS, Part IV). 2026 is incomplete.")123    p = f"{B}/institution_types_yearly.csv"124    if os.path.exists(p):125        d = pd.read_csv(p); d = d[d.year <= 2026]; tot = d.groupby("year").n.transform("sum"); d["share"] = 100 * d.n / tot126        piv = d.pivot_table(index="itype", columns="year", values="share").fillna(0); piv = piv.loc[piv.sum(axis=1).sort_values(ascending=False).index].reset_index(); piv.columns = ["Institution type"] + [str(c) for c in piv.columns[1:]]127        write("tab_institution_types", piv, "Share of typed affiliations by OpenAlex institution type and year (per cent of affiliation-work pairs).", "tab:institution_types", [p], fmt={c: "{:.2f}" for c in piv.columns[1:]}, note="A work with affiliations of several types counts once under each type; shares therefore describe the mix of affiliation types, not of works. Types follow the OpenAlex institution ontology (education, facility, company, government, healthcare, nonprofit, funder, archive, other).")128    p = f"{B}/countries_yearly.csv"129    if os.path.exists(p):130        d = pd.read_csv(p); d = d[d.year <= 2026]; tot = d.groupby("year")[["n_full", "n_frac"]].sum()131        top = d.groupby("country").n_frac.sum().nlargest(15).index132        sh = d[d.country.isin(top)].copy(); sh["share"] = 100 * sh.n_frac / sh.year.map(tot.n_frac); piv = sh.pivot_table(index="country", columns="year", values="share").loc[top].reset_index(); piv.columns = ["Country"] + [str(c) for c in piv.columns[1:]]133        write("tab_country_shares", piv, "Share of fractional output of the fifteen largest countries by year (per cent).", "tab:country_shares", [p], fmt={c: "{:.2f}" for c in piv.columns[1:]}, note="Fractional counting: each work is divided equally among its distinct author countries, so the shares of all countries sum to 100 within a year. ISO 3166-1 alpha-2 codes. 2026 is incomplete.")134        rows = []135        for y, g in d.groupby("year"):136            pf = g.n_frac / g.n_frac.sum(); cn = g[g.country == "CN"]; us = g[g.country == "US"]137            rows.append({"Year": str(y), "Works": int(round(g.n_frac.sum())), "Countries": int(len(g)), "HHI": float((pf ** 2).sum()), "Top-5 (%)": 100 * pf.nlargest(5).sum(), "Top-10 (%)": 100 * pf.nlargest(10).sum(), "CN works (%)": 100 * float(cn.n_full.sum()) / g.n_frac.sum(), "US works (%)": 100 * float(us.n_full.sum()) / g.n_frac.sum(), "CN full/frac": float(cn.n_full.sum()) / float(cn.n_frac.sum()), "US full/frac": float(us.n_full.sum()) / float(us.n_frac.sum())})138        write("tab_country_concentration", pd.DataFrame(rows), "Concentration of output across countries by year, and the two largest producers under full counting.", "tab:country_concentration", [p], fmt={"HHI": "{:.4f}", "Top-5 (%)": "{:.1f}", "Top-10 (%)": "{:.1f}", "CN works (%)": "{:.2f}", "US works (%)": "{:.2f}", "CN full/frac": "{:.3f}", "US full/frac": "{:.3f}"},139              note="Works: fractional total of works with at least one country; Countries: number of countries with any output; HHI: Herfindahl--Hirschman index of fractional country shares; Top-5/Top-10: fractional share of the five and ten largest countries. CN/US works: share of works with at least one author in the country among works with a country (full counting); CN/US full/frac: ratio of the country's full count to its fractional count, which exceeds one by the extent to which its works are co-authored with other countries.")140    p = f"{B}/country_pairs_by_period.csv"141    if os.path.exists(p):142        d = pd.read_csv(p); d = d[d.period.isin(["baseline", "post"])]143        rows = []144        for per in ["baseline", "post"]:145            g = d[d.period == per]; w = g.n.sum(); cu = g[g.c1.isin(["CN", "US"]) & g.c2.isin(["CN", "US"])].n.sum(); st = pd.concat([g[["c1", "n"]].rename(columns={"c1": "c"}), g[["c2", "n"]].rename(columns={"c2": "c"})]).groupby("c").n.sum().sort_values(ascending=False)146            rows.append({"Period": per, "Edges": int(len(g)), "Countries": int(len(set(g.c1) | set(g.c2))), "Weight": int(w), "CN--US": int(cu), "CN--US (%)": 100 * cu / w, "Top strength": st.index[0] + ", " + st.index[1] + ", " + st.index[2]})147        write("tab_country_network", pd.DataFrame(rows), "The country co-authorship network by period: edges with at least fifty joint works.", "tab:country_network", [p], fmt={"CN--US (%)": "{:.2f}"}, note="Edges: country pairs with at least fifty joint works; Weight: total joint works over these edges; CN--US: weight of the China--United States edge and its share of the total; Top strength: the three countries with the largest strength (sum of edge weights). Baseline: 2018-01-01 to 2022-11-29; post: 2022-11-30 to 2026-11-02 (incomplete).")148        b = d[d.period == "baseline"].sort_values("n", ascending=False).head(20).reset_index(drop=True); q = d[d.period == "post"].sort_values("n", ascending=False).head(20).reset_index(drop=True)149        t = pd.DataFrame({"Rank": [str(i + 1) for i in range(20)], "Pair (baseline)": b.c1 + "--" + b.c2, "Joint works (baseline)": b.n.astype(int), "Pair (post)": q.c1 + "--" + q.c2, "Joint works (post)": q.n.astype(int)})150        write("tab_country_pairs", t, "The twenty strongest country pairs by period (number of joint works).", "tab:country_pairs", [p], note="Pairs are unordered; HK and MO are OpenAlex country codes for Hong Kong and Macau and are distinct from CN in the affiliation data. The post period is incomplete.")151    p = f"{B}/top_institutions_by_period.csv"152    if os.path.exists(p):153        d = pd.read_csv(p); b = d[d.period == "baseline"].set_index("inst_id"); q = d[d.period == "post"].set_index("inst_id"); j = b.join(q, lsuffix="_b", rsuffix="_p", how="inner")154        rows = []155        for per, g in [("baseline", b), ("post", q)]:156            pf = g.n_frac / g.n_frac.sum()157            rows.append({"Period": per, "Institutions": int(len(g)), "Fractional works": int(round(g.n_frac.sum())), "Full-count works": int(g.n_full.sum()), "Top-10 (%)": 100 * pf.nlargest(10).sum(), "Top-50 (%)": 100 * pf.nlargest(50).sum(), "Top-100 (%)": 100 * pf.nlargest(100).sum()})158        rows.append({"Period": "both periods", "Institutions": int(len(j)), "Fractional works": int(round(j.n_frac_b.sum())), "Full-count works": int(j.n_full_b.sum()), "Top-10 (%)": float("nan"), "Top-50 (%)": float("nan"), "Top-100 (%)": float("nan")})159        t = pd.DataFrame(rows); write("tab_institution_concentration", t, "Institutions with at least two hundred works per period: coverage and concentration (fractional counting).", "tab:institution_concentration", [p], fmt={"Top-10 (%)": "{:.2f}", "Top-50 (%)": "{:.2f}", "Top-100 (%)": "{:.2f}"},160              note=f"Institutions: number with at least two hundred works in the period; Top-10/50/100: fractional share of the largest listed institutions relative to the listed institutions, not to all works. The row `both periods' counts the institutions present in both lists (its work columns are their baseline totals); the Spearman rank correlation of their fractional counts across periods is {j.n_frac_b.rank().corr(j.n_frac_p.rank(), method='spearman'):.3f}.")161        top = j.sort_values("n_frac_p", ascending=False).head(25)162        t = pd.DataFrame({"OpenAlex id": ["I" + str(i) for i in top.index], "Full, base": top.n_full_b.astype(int).values, "Frac., base": top.n_frac_b.values, "Full, post": top.n_full_p.astype(int).values, "Frac., post": top.n_frac_p.values, "Ratio": (top.n_frac_p / top.n_frac_b).values})163        write("tab_top_institutions", t, "The twenty-five institutions with the largest fractional output in the post period, with their baseline output.", "tab:top_institutions", [p], fmt={"Frac., base": "{:,.0f}", "Frac., post": "{:,.0f}", "Ratio": "{:.2f}"}, longtable=False,164              note="Full and fractional counts in the baseline and post periods; Ratio is the post-to-baseline ratio of fractional counts. Institution names are not stored in the result file; the identifiers resolve at https://openalex.org/I<id>. The post period is about ten months shorter than the baseline, so a ratio below the corpus-wide ratio does not indicate decline.")165    p = f"{B}/top_sources_by_period.csv"166    if os.path.exists(p):167        d = pd.read_csv(p); d = d[d.period.isin(["baseline", "post"])]168        rows = []169        for per in ["baseline", "post"]:170            g = d[d.period == per]; bt = g.groupby("source_type").n.sum(); w = bt.sum()171            r = {"Period": per, "Sources": int(len(g)), "Works": int(w)}172            for ty in ["journal", "conference", "repository", "book series", "ebook platform"]:173                r[ty.replace("ebook platform", "ebook").replace("book series", "series") + " (%)"] = 100 * float(bt.get(ty, 0)) / w174            rows.append(r)175        write("tab_source_types", pd.DataFrame(rows), "Distribution of works over source types among the listed sources, by period (per cent).", "tab:source_types", [p], fmt={c: "{:.1f}" for c in ["journal (%)", "conference (%)", "repository (%)", "series (%)", "ebook (%)"]}, note="Source types are OpenAlex's (series = book series; ebook = ebook platform). The listed sources are those retained by the bibliometric script for each period; works whose primary source is not listed are excluded from the denominators.")176        b = d[d.period == "baseline"].sort_values("n", ascending=False).head(20).reset_index(drop=True); q = d[d.period == "post"].sort_values("n", ascending=False).head(20).reset_index(drop=True)177        t = pd.DataFrame({"Rank": [str(i + 1) for i in range(20)], "Source (baseline)": [_ascii(x, 30) for x in b.source_name], "Type": [_ascii(x).replace("ebook platform", "ebook").replace("book series", "series").replace("repository", "repos.").replace("conference", "conf.") for x in b.source_type], "Works": b.n.astype(int), "Source (post)": [_ascii(x, 30) for x in q.source_name], "Type ": [_ascii(x).replace("ebook platform", "ebook").replace("book series", "series").replace("repository", "repos.").replace("conference", "conf.") for x in q.source_type], "Works ": q.n.astype(int)})178        write("tab_top_sources", t, "The twenty largest sources (venues) by period, with OpenAlex source type.", "tab:top_sources", [p], longtable=True, note="Source names are OpenAlex display names, truncated; types abbreviated (repos. = repository, conf. = conference, series = book series, ebook = ebook platform).")179    p = f"{G}/cited_age_yearly.csv"180    if os.path.exists(p):181        d = pd.read_csv(p); rows = []182        for y, g in d.groupby("citing_year"):183            if y < 2018 or y > 2026: continue184            n = g.n.sum(); neg = g[g.age < 0].n.sum(); gg = g[g.age >= 0].sort_values("age"); w = gg.n.values; ag = gg.age.values185            import numpy as np186            cum = np.cumsum(w) / w.sum(); med = int(ag[np.searchsorted(cum, 0.5)])187            rows.append({"Citing year": str(y), "References": int(n), "Negative (%)": 100 * neg / n, "Mean age": float((w * ag).sum() / w.sum()), "Median": med, "0--2 y (%)": 100 * gg[gg.age <= 2].n.sum() / w.sum(), "0--5 y (%)": 100 * gg[gg.age <= 5].n.sum() / w.sum(), "over 10 y (%)": 100 * gg[gg.age > 10].n.sum() / w.sum()})188        write("tab_cited_age", pd.DataFrame(rows), "Age of cited works by citing year (resolved references of eligible works; corpus version of the result file at generation time).", "tab:cited_age", [p], fmt={"Negative (%)": "{:.2f}", "Mean age": "{:.2f}", "0--2 y (%)": "{:.1f}", "0--5 y (%)": "{:.1f}", "over 10 y (%)": "{:.1f}"},189              note="References: resolved references of eligible works of the citing year. Age is the citing year minus the cited work's publication year. Negative ages (cited work dated after the citing work: preprints cited before their published version, or year-only dates) are excluded from the age statistics and reported as a share. The 2026 row is incomplete.")190    p = f"{B}/top_cited_by_year.csv"191    if os.path.exists(p):192        d = pd.read_csv(p); d = d[(d.year >= 2018) & (d.year <= 2025) & (d.rk <= 10)].sort_values(["year", "rk"])193        t = pd.DataFrame({"Year": d.year.astype(str).values, "Rk": d.rk.astype(str).values, "Title (truncated)": [_ascii(x, 44) for x in d.title], "c36": d.c36.astype(int).values, "Total": d.cited_by_count.astype(int).values})194        write("tab_top_cited", t, "The ten most cited works of each publication year by 36-month window citations (c36), with total citations to date.", "tab:top_cited", [p], longtable=True, note="As recorded in OpenAlex (snapshot 2026-10-14 plus increment). Several entries are duplicate or misdated records of canonical works, or records whose citation counts are inconsistent with their total (c36 larger than the total, or a total of zero); they are kept because the table documents what the database returns, and are discussed in the text.")195    p = f"{B}/dblp_only_yearly.csv"196    if os.path.exists(p):197        d = pd.read_csv(p); piv = d.pivot_table(index="year", columns="rec_type", values="n", aggfunc="sum").fillna(0).astype(int); piv["total"] = piv.sum(axis=1); piv["with DOI (%)"] = 100 * d.groupby("year").with_doi.sum() / d.groupby("year").n.sum()198        piv = piv.reset_index(); piv["year"] = piv.year.astype(str); piv.columns = ["Year"] + [str(c) for c in piv.columns[1:]]199        write("tab_dblp_only", piv, "DBLP records of 2018--2026 without an OpenAlex counterpart, by DBLP record type and year.", "tab:dblp_only", [p], fmt={"with DOI (%)": "{:.1f}"}, note="These records are in DBLP but were not linked to any OpenAlex work by DOI, arXiv identifier or normalised title (Part IV). They are not in the eligible corpus; the table bounds what DBLP-based studies would see that this corpus does not.")200    p = f"{B}/indexing_lag.csv"201    if os.path.exists(p):202        d = pd.read_csv(p); d["year"] = d.pub_month.str[:4].astype(int); rows = []203        for y, g in d.groupby("year"):204            jan = g[g.pub_month.str.endswith("-01")]; jun = g[g.pub_month.str.endswith("-06")]205            rows.append({"Year": str(y), "Works": int(g.n.sum()), "Lag, Jan. (d)": float(jan.lag_days_p50.iloc[0]) if len(jan) else float("nan"), "Lag, June (d)": float(jun.lag_days_p50.iloc[0]) if len(jun) else float("nan"), "Within 90 d (%)": 100 * g.n_indexed_within_90d.sum() / g.n.sum(), "After 180 d (%)": 100 * g.n_indexed_after_180d.sum() / g.n.sum()})206        write("tab_indexing_lag", pd.DataFrame(rows), "The OpenAlex \\texttt{created\\_date} relative to the publication date, by publication year.", "tab:indexing_lag", [p], fmt={"Lag, Jan. (d)": "{:.0f}", "Lag, June (d)": "{:.0f}", "Within 90 d (%)": "{:.1f}", "After 180 d (%)": "{:.1f}"},207              note="Lag is created\\_date minus publication date; the two lag columns give the median lag in days for works published in January and in June of the year; the last two columns give the shares of the year's works whose record was created within 90 days and after 180 days of publication. The near-constant decline of the median by one month per publication month in 2018--2024 identifies a single creation date for most records (a re-ingestion), not an indexing delay; see Part III.")208    # ---- corpus v3 graph tables (appended by Agent L/M, 2026-11-03): reference coverage and field-level inflows ----209    p = f"{G}/reference_coverage_yearly.csv"210    if os.path.exists(p):211        d = pd.read_csv(p); d = d[(d.year >= 2018) & (d.year <= 2026)].copy(); res = d.n_internal + d.n_external212        t = pd.DataFrame({"Year": d.year.astype(str), "Works": d.n_works.astype(int), "Declared refs.": d.n_refs_declared.astype(int), "Refs./work": d.n_refs_declared / d.n_works, "Resolved (%)": 100 * res / d.n_refs_declared, "Internal": d.n_internal.astype(int), "External": d.n_external.astype(int), "Internal (%)": 100 * d.n_internal / res})213        write("tab_reference_coverage", t, "Reference coverage by citing year: declared, resolved, internal and external references of eligible works.", "tab:reference_coverage", [p], fmt={"Refs./work": "{:.2f}", "Resolved (%)": "{:.2f}", "Internal (%)": "{:.2f}"},214              note="Works: eligible works of the year (including works with no reference list); Declared: entries of their OpenAlex \\texttt{referenced\\_works} lists; Resolved: declared references that resolve to an OpenAlex record (internal = cited work in the eligible corpus; external = cited work outside it), as a share of the declared count; Internal (\\%): internal share of resolved references. The resolved share of 2025--2026 is higher than in earlier years because the recent cohorts carry fewer parsed references per work (reference extraction lags publication) and the parsed ones resolve more often; it describes the database, not citing behaviour. The 2026 row is incomplete.")215    p = f"{G}/field_flows_yearly.csv"216    if os.path.exists(p):217        d = pd.read_csv(p); d = d[(d.citing_year >= 2018) & (d.citing_year <= 2026)]; tot = d.groupby("citing_year").n.sum()218        names = {17: "CS", 22: "Eng.", 33: "Soc.", 27: "Med.", 26: "Math.", 18: "Dec.", 32: "Psy.", 31: "Phys."}219        ext = d[d.kind == "external"]; internal = d[d.kind == "internal"].groupby("citing_year").n.sum().reindex(tot.index).fillna(0)220        byf = ext.pivot_table(index="citing_year", columns="cited_field_id", values="n", aggfunc="sum").reindex(tot.index).fillna(0)221        cs = byf[17.0] if 17.0 in byf.columns else 0 * tot222        t = pd.DataFrame({"Year": [str(y) for y in tot.index], "Inside": 100 * internal.values / tot.values, "CS out": 100 * cs.values / tot.values, "Other": 100 * (tot.values - internal.values - cs.values) / tot.values})223        for fid in [22, 33, 27, 26, 18, 32, 31]:224            t[names[fid]] = 100 * (byf[float(fid)].values if float(fid) in byf.columns else 0) / tot.values225        write("tab_field_flows", t, "Destination of the resolved references of eligible works by citing year: inside the corpus, computer science outside it, and the seven largest other OpenAlex fields (per cent of resolved references).", "tab:field_flows", [p], fmt={c: "{:.2f}" for c in t.columns[1:]},226              note="Shares of all resolved references of the citing year. Inside: cited work is an eligible work (computer science, 2018--2026); CS out: cited work outside the corpus whose primary OpenAlex field is Computer Science (field 17; mostly pre-2018 work); Other: all remaining external references, including those without a field. The seven field columns are the largest components of `Other': Eng. = Engineering (OpenAlex field 22), Soc. = Social Sciences (33), Med. = Medicine (27), Math. = Mathematics (26), Dec. = Decision Sciences (18), Psy. = Psychology (32), Phys. = Physics and Astronomy (31). The rise of the inside-corpus share is mechanical: the corpus starts in 2018, so later citing years have more corpus to cite. The 2026 row is incomplete.")227228229def tables_part06():230    """Part VI — the eleven systems categories: primary-category counts and CAGR (cagr_by_category.csv) joined with the231    structural-change estimates of breaks.csv (ITS slope change at 2022-12, HAC p, placebo p, Bai–Perron BIC break dates)."""232    B = f"{R}/results/bibliometrics"; E = f"{R}/results/econometrics"233    cats = ["computer_architecture", "ai_accelerators", "operating_systems", "distributed_computing", "cloud_computing", "edge_computing", "computer_networks", "database_systems", "information_retrieval", "quantum_computing", "sustainable_computing"]234    names = {"computer_architecture": "Computer architecture", "ai_accelerators": "Accelerators and AI hardware", "operating_systems": "Operating systems", "distributed_computing": "Distributed computing", "cloud_computing": "Cloud computing", "edge_computing": "Edge computing", "computer_networks": "Computer networks", "database_systems": "Database systems", "information_retrieval": "Information retrieval", "quantum_computing": "Quantum computing", "sustainable_computing": "Sustainable computing"}235    p, q = f"{B}/cagr_by_category.csv", f"{E}/breaks.csv"236    if os.path.exists(p) and os.path.exists(q):237        c = pd.read_csv(p).set_index("category"); b = pd.read_csv(q).set_index("series"); rows = []238        for k in cats:239            if k not in c.index: continue240            r = c.loc[k]; s = b.loc["cat_" + k] if ("cat_" + k) in b.index else None241            rows.append({"Category": names[k], "n 2018": int(r.n_2018), "n 2022": int(r.n_2022), "n 2025": int(r.n_2025), "CAGR base": float(r.cagr_baseline), "CAGR post": float(r.cagr_post),242                         "Slope ch.": float(s.chatgpt_slope) if s is not None else float("nan"), "p": float(s.chatgpt_slope_p) if s is not None else float("nan"),243                         "Placebo p": float(s.placebo_slope_p) if s is not None else float("nan"), "BP breaks": str(s.bp_breaks_bic) if s is not None else ""})244        write("tab_systems_categories", pd.DataFrame(rows), "The systems and infrastructure categories of Part VI: primary-category output, compound annual growth, and structural-change estimates of the monthly log-volume series.", "tab:systems_categories", [p, q],245              fmt={"CAGR base": "{:.3f}", "CAGR post": "{:.3f}", "Slope ch.": "{:.3f}", "p": "{:.3f}", "Placebo p": "{:.3f}"},246              note="Counts are eligible works whose primary category is the row category (multi-label memberships are larger). CAGR base: 2018 to 2022; CAGR post: 2023 to 2025 (complete years). Slope ch.: change in the monthly trend of the log-volume series at 2022-12 in the interrupted time-series model of Part IV, with its HAC p-value; Placebo p: share of placebo event dates whose slope statistic is at least as extreme; BP breaks: Bai--Perron break dates selected by BIC. 2026 is incomplete and excluded from the growth columns.")247248249250# ---------------- Part VII-b (security half of Part VII): tables appended by Agent H, 2026-11-03 ----------------251def tables_part07b():252    G = f"{R}/results/graph"; B = f"{R}/results/bibliometrics"; E = f"{R}/results/econometrics"253    SEC = ["cybersecurity", "applied_cryptography", "blockchain", "privacy_preserving_computation", "federated_learning"]254    NAMES = {"cybersecurity": "Cybersecurity", "applied_cryptography": "Applied cryptography", "blockchain": "Blockchain systems", "privacy_preserving_computation": "Privacy-preserving computation", "federated_learning": "Federated learning"}255    SHORT = {"cybersecurity": "Cyber.", "applied_cryptography": "Crypto.", "blockchain": "Blockch.", "privacy_preserving_computation": "Privacy", "federated_learning": "Fed. learn."}256    p = f"{G}/category_counts_yearly.csv"; agg = None257    if os.path.exists(p):258        d = pd.read_csv(p); d = d[d.category_id.isin(SEC)].groupby(["year", "category_id"])[["n_multilabel", "n_primary"]].sum().reset_index()259        agg = d260        t = pd.DataFrame({"Year": sorted(d.year.unique().astype(str))})261        for v, suffix in (("n_multilabel", " (all)"), ("n_primary", " (prim.)")):262            piv = d.pivot_table(index="year", columns="category_id", values=v, aggfunc="sum", fill_value=0).astype(int)263            for c in SEC: t[SHORT[c] + suffix] = piv[c].values264        write("tab_security_counts", t, "Eligible works in the five security-and-privacy categories by publication year: works carrying the category (all) and works whose primary category it is (prim.).", "tab:security_counts", [p], longtable=True,265              note="Counts of eligible works (Part III) per publication year, summed over the pre/baseline/post period labels of the result file. `all' counts every work to which the multi-label classifier assigns the category (a work can carry several categories); `prim.' counts works for which the category is the argmax of the assignment score (Part IV). Cyber. = cybersecurity; Crypto. = applied cryptography; Blockch. = blockchain systems; Privacy = privacy-preserving computation; Fed. learn. = federated learning. The 2026 row is incomplete (the corpus ends on 2026-11-02 and recent months are under-indexed).")266    p = f"{E}/breaks.csv"; q = f"{B}/cagr_by_category.csv"267    if os.path.exists(p) and agg is not None:268        b = pd.read_csv(p).set_index("series"); rows = []269        for c in SEC:270            g = agg[agg.category_id == c].set_index("year")271            m18, m22, m23, m25 = (int(g.loc[y, "n_multilabel"]) if y in g.index else 0 for y in (2018, 2022, 2023, 2025))272            r = b.loc[f"cat_{c}"]273            rows.append({"Category": NAMES[c], "Works 2018": m18, "Works 2025": m25, "CAGR 18--22 (%)": 100 * ((m22 / m18) ** 0.25 - 1) if m18 else float("nan"), "CAGR 23--25 (%)": 100 * ((m25 / m23) ** 0.5 - 1) if m23 else float("nan"),274                         "Level 2022-12": float(r.chatgpt_level), "p": float(r.chatgpt_level_p), "Slope change": float(r.chatgpt_slope), "p ": float(r.chatgpt_slope_p), "Placebo p (slope)": float(r.placebo_slope_p), "Bai--Perron": str(r.bp_breaks_bic).replace(";", ", "), "PELT": str(r.pelt).replace(";", ", ")})275        write("tab_security_summary", pd.DataFrame(rows), "Growth and structural-change summary for the five security-and-privacy categories (multi-label counts).", "tab:security_summary", [f"{G}/category_counts_yearly.csv", p], fmt={"CAGR 18--22 (%)": "{:.1f}", "CAGR 23--25 (%)": "{:.1f}", "Level 2022-12": "{:.3f}", "p": "{:.3f}", "Slope change": "{:.3f}", "p ": "{:.3f}", "Placebo p (slope)": "{:.3f}"},276              note="Works: eligible works carrying the category (multi-label). CAGR: compound annual growth of the multi-label count between the years shown (2026 excluded as incomplete). Level and slope change: interrupted-time-series estimates on the log monthly series at the 2022-12 reference month with HAC p-values (Part IV, Section~\\ref{sec:m-its}); Placebo p: share of placebo event months whose slope t-statistic exceeds the one at 2022-12 (Section~\\ref{sec:m-placebo}); Bai--Perron: BIC-selected break months (Section~\\ref{sec:m-bp}); PELT: penalised change-point months. The ITS, placebo and break columns are those of Table~\\ref{tab:breaks}.")277    p = f"{G}/category_cooccurrence_yearly.csv"278    if os.path.exists(p) and agg is not None:279        d = pd.read_csv(p); pairs = [("cybersecurity", "llm"), ("cybersecurity", "ai_safety"), ("cybersecurity", "ai_agents"), ("applied_cryptography", "llm"), ("blockchain", "llm"), ("privacy_preserving_computation", "llm"), ("federated_learning", "llm")]280        LAB = {"llm": "LLM", "ai_safety": "AI safety", "ai_agents": "AI agents"}281        rows = []282        for y in sorted(d.year.unique()):283            if y < 2018 or y > 2026: continue284            row = {"Year": str(y)}285            for a, bb in pairs:286                n = d[(d.year == y) & (((d.cat_a == a) & (d.cat_b == bb)) | ((d.cat_a == bb) & (d.cat_b == a)))].n.sum()287                m = agg[(agg.year == y) & (agg.category_id == a)].n_multilabel.sum()288                row[f"{SHORT[a]} x {LAB[bb]}"] = int(n); row[f"{SHORT[a]} x {LAB[bb]} (%)"] = 100 * n / m if m else float("nan")289            rows.append(row)290        t = pd.DataFrame(rows); cols = ["Year"] + [c for c in t.columns if c != "Year"]291        write("tab_security_llm_cooccurrence", t[cols], "Co-classification of security-and-privacy works with the LLM, AI-safety and AI-agents categories, by publication year: number of works carrying both categories and share of the security category's works.", "tab:security_llm_cooccurrence", [p, f"{G}/category_counts_yearly.csv"], longtable=True, fmt={c: "{:.1f}" for c in cols if c.endswith("(%)")},292              note="A work is co-classified when the multi-label classifier assigns both categories (Part IV). Shares are relative to all eligible works carrying the security category in the year (multi-label count). Non-zero counts before 2022 reflect the breadth of the keyword patterns of the AI categories (for example `language model' matches pre-transformer work), not the use of LLMs; the table should be read for its trend, not its level. The 2026 row is incomplete.")293    p = f"{R}/literature_synthesis/landmarks/security_privacy_landmarks.csv"294    if os.path.exists(p):295        d = pd.read_csv(p); rows = []296        for c in SEC:297            g = d[d.primary_category == c]298            rows.append({"Category": NAMES[c], "Works": int(len(g)), "Post-period": int((g.period == "post").sum()), "Dated 2026": int((g.year == 2026).sum()), "Articles": int(g.type.isin(["article", "review"]).sum()), "Conf.": int((g.type == "conference-paper").sum()), "Preprints": int((g.type == "preprint").sum()), "Grey": int(g.type.isin(["dissertation", "report", "book-chapter", "book"]).sum()), "Abstract": int((g.has_abstract == True).sum()), "No DOI": int(g.doi.isna().sum())})299        write("tab_security_landmarks", pd.DataFrame(rows), "Composition of the automatically selected landmark set of the security-and-privacy area, by category.", "tab:security_landmarks", [p],300              note="Landmarks are the works with the highest field-normalised 36-month citation score (NCS36; 2025--2026 works ranked by 12-month citations) within each primary category, with a floor of fifteen per category, plus the 200 most cited works of the corpus overall (literature_synthesis/select_landmarks.py). Grey = dissertations, reports, book chapters and books as typed by OpenAlex. Abstract = works whose abstract is available in the metadata; the synthesis of Part VII reads these abstracts only, never full texts. The set is flagged for principal-investigator review; Part VII documents the records that are duplicates, misdated or misclassified.")301302303# ---------------- Part IX (Empirical Results): tables appended by Agent L/I/J (Part IX writer), 2026-11-03 ----------------304def _p9_names():305    import yaml306    o = yaml.safe_load(open(f"{R}/taxonomy/ontology.yaml"))307    return {c["id"]: c["name"] for c in o["categories"]}, {c["id"]: c["area"] for c in o["categories"]}, {a["id"]: a["name"] for a in o["areas"]}308309310_P9_AREA_SHORT = {"ai_ml": "AI \\& ML", "perception_interaction": "Perception \\& interaction", "software_pl": "Software \\& PL", "theory": "Theory", "data_distributed": "Data \\& distributed",311                  "security_privacy": "Security \\& privacy", "hardware_emerging": "Hardware \\& emerging", "computational_science": "Computational science"}312_P9_EVENTS = [("gpt3", "GPT-3 (2020-06)"), ("dalle2", "DALL$\\cdot$E 2 (2022-05)"), ("chatgpt", "ChatGPT (2022-12)"), ("gpt4", "GPT-4 (2023-04)"), ("o1", "o1 (2024-10)")]313314315def _p9_estp(e, p):316    return f"{e:+.3f} ({p:.3f})" if pd.notna(e) and pd.notna(p) else ""317318319def tables_part09():320    import numpy as np321    names, area, area_names = _p9_names(); B = f"{R}/results/bibliometrics"; G = f"{R}/results/graph"; E = f"{R}/results/econometrics"; X = f"{R}/results/emerging"322    # --- corpus by period (volume_monthly, baseline vs post) ---323    p = f"{B}/volume_monthly.csv"324    if os.path.exists(p):325        m = pd.read_csv(p); m = m[m.period.isin(["baseline", "post"])]; rows = []326        tot = m.groupby("period").n.sum()327        def share(mask, label):328            s = m[mask].groupby("period").n.sum().reindex(["baseline", "post"]).fillna(0) / tot * 100329            rows.append({"Indicator": label, "Baseline": float(s["baseline"]), "Post": float(s["post"])})330        rows.append({"Indicator": "Eligible works (count)", "Baseline": float(tot["baseline"]), "Post": float(tot["post"])})331        share(m.date_precision.eq("month"), "Month-precision first date (\\%)"); share(m.in_dblp.astype(bool), "DBLP-indexed (\\%)"); share(m.cs_primary.astype(bool), "OpenAlex primary field CS (\\%)")332        share(m.has_arxiv.astype(bool), "arXiv-linked (\\%)")333        for c, lab in [("n_abstract", "With abstract (\\%)"), ("n_with_refs", "With at least one reference (\\%)"), ("n_oa", "Open access (\\%)")]:334            s = m.groupby("period")[c].sum() / tot * 100; rows.append({"Indicator": lab, "Baseline": float(s["baseline"]), "Post": float(s["post"])})335        for ty, lab in [("article", "Type: article (\\%)"), ("conference-paper", "Type: conference paper (\\%)"), ("preprint", "Type: preprint (\\%)"), ("book-chapter", "Type: book chapter (\\%)"), ("dissertation", "Type: dissertation (\\%)")]:336            share(m.type.eq(ty), lab)337        t = pd.DataFrame(rows)338        write("tab_p9_periods", t, "The eligible corpus by protocol period: completeness, source membership and document type.", "tab:p9-periods", [p], fmt={"Baseline": "{:,.2f}", "Post": "{:,.2f}"},339              note="Baseline: first date 2018-01-01 to 2022-11-29; post: 2022-11-30 to 2026-11-02 (right-truncated, see Part III). Percentages are over all eligible works of the period. The count row is an integer written with two decimals by the table generator.")340    # --- classification coverage by year ---341    p = f"{G}/category_counts_yearly.csv"; q = f"{R}/data/metadata/prisma_counts.json"342    if os.path.exists(p) and os.path.exists(q):343        cc = pd.read_csv(p); cc = cc[(cc.year >= 2018) & (cc.year <= 2026)]; P = json.load(open(q))["eligible_by_year"]344        y = cc.groupby("year")[["n_primary", "n_multilabel"]].sum(); y["eligible"] = [P[str(i)] for i in y.index]345        t = pd.DataFrame({"Year": y.index.astype(str), "Eligible works": y.eligible, "With primary category": y.n_primary, "Coverage (%)": 100 * y.n_primary / y.eligible, "Category assignments": y.n_multilabel, "Labels per classified work": y.n_multilabel / y.n_primary})346        write("tab_p9_classification", t, "Classification coverage by publication year: eligible works, works with a primary category, and multi-label assignments.", "tab:p9-classification", [p, q], fmt={"Coverage (%)": "{:.1f}", "Labels per classified work": "{:.2f}"},347              note="A work receives a primary category when at least one category score passes the threshold of Part IV; works without any assignment (no abstract, unmapped venue and no arXiv category) have no primary category and enter no category series. Assignments count every (work, category) pair. 2026 is incomplete.")348        cc["area"] = cc.category_id.map(area)349        ya = cc.groupby(["year", "area"]).n_primary.sum().unstack().fillna(0); sh = ya.div(ya.sum(1), axis=0) * 100350        per = cc[cc.period.isin(["baseline", "post"])].groupby(["period", "area"]).n_primary.sum().unstack().fillna(0); shp = per.div(per.sum(1), axis=0) * 100351        t = pd.concat([sh, shp.loc[["baseline", "post"]]]); t.index = [str(i) for i in sh.index] + ["Baseline (2018-01 to 2022-11)", "Post (2022-12 to 2026-11)"]352        t = t[[a for a in _P9_AREA_SHORT if a in t.columns]].rename(columns=_P9_AREA_SHORT).reset_index().rename(columns={"index": "Year / period"})353        write("tab_p9_area_shares", t, "Share of primary-category assignments by top-level area and year (per cent), with the two protocol periods.", "tab:p9-area-shares", [p], fmt={c: "{:.2f}" for c in _P9_AREA_SHORT.values()},354              note="Shares of works with a primary category (Table~\\ref{tab:p9-classification}); rows sum to 100. The period rows split at the reference date; the year rows split at the calendar year. 2026 is incomplete.")355        # category-level periods joined with CAGR356        cp = cc[cc.period.isin(["baseline", "post"])].groupby(["period", "category_id"])[["n_primary", "n_multilabel"]].sum().unstack(0)357        cp.columns = ["_".join(c) for c in cp.columns]358        r = f"{B}/cagr_by_category.csv"; cg = pd.read_csv(r).set_index("category") if os.path.exists(r) else None359        rows = []360        for k in cp.index:361            rr = cp.loc[k]; rows.append({"Category": names.get(k, k), "Area": _P9_AREA_SHORT.get(area.get(k), area.get(k)), "Primary, base": int(rr.n_primary_baseline), "Primary, post": int(rr.n_primary_post),362                                        "Share base (%)": 100 * rr.n_primary_baseline / cp.n_primary_baseline.sum(), "Share post (%)": 100 * rr.n_primary_post / cp.n_primary_post.sum(),363                                        "Multi-label post/base": rr.n_multilabel_post / rr.n_multilabel_baseline if rr.n_multilabel_baseline > 0 else float("nan"),364                                        "CAGR base": float(cg.loc[k, "cagr_baseline"]) if cg is not None and k in cg.index else float("nan"), "CAGR post": float(cg.loc[k, "cagr_post"]) if cg is not None and k in cg.index else float("nan")})365        t = pd.DataFrame(rows).sort_values("Share post (%)", ascending=False)366        write("tab_p9_category_periods", t, "The forty categories by protocol period: primary-category output, composition shares, multi-label growth and compound annual growth rates.", "tab:p9-category-periods", [p, r], longtable=True,367              fmt={"Share base (%)": "{:.2f}", "Share post (%)": "{:.2f}", "Multi-label post/base": "{:.2f}", "CAGR base": "{:.3f}", "CAGR post": "{:.3f}"},368              note="Primary counts and shares use the primary category; the multi-label ratio is the post-period count of all assignments to the category divided by the baseline count (periods split at the reference date). CAGR base: 2018 to 2022 (four years); CAGR post: 2023 to 2025 (two years), both on primary counts of complete calendar years (equation~\\eqref{eq:cagr}). Sorted by post-period share.")369    # --- AI categories: share with bootstrap CI (H2) ---370    p = f"{B}/category_share_ci_yearly.csv"371    if os.path.exists(p):372        s = pd.read_csv(p); ai = [k for k, a in area.items() if a == "ai_ml"]; rows = []373        for k in ai:374            r = {"Category": names[k]}375            for yv in (2018, 2022, 2025):376                z = s[(s.category == k) & (s.year == yv)]377                r[str(yv)] = f"{100 * float(z.share.iloc[0]):.2f} [{100 * float(z.ci_low.iloc[0]):.2f}, {100 * float(z.ci_high.iloc[0]):.2f}]" if len(z) else ""378            rows.append(r)379        r = {"Category": "AI \\& ML area (sum)"}380        for yv in (2018, 2022, 2025):381            z = s[(s.category.isin(ai)) & (s.year == yv)]; r[str(yv)] = f"{100 * float(z.share.sum()):.2f}" if len(z) else ""382        rows.append(r)383        write("tab_p9_ai_shares", pd.DataFrame(rows), "Share of primary-category assignments held by the AI and machine-learning categories in 2018, 2022 and 2025 (per cent, bootstrap 95\\% intervals).", "tab:p9-ai-shares", [p],384              note="Shares are of the year's works with a primary category; intervals from 500 multinomial resamples of the works (seed 44). The area row is the sum of the category shares (no interval). Categories 1--8 and 38 of the mandate.")385    # --- ITS total: events, robustness ---386    p = f"{E}/its_results.json"387    if os.path.exists(p):388        J = json.load(open(p)); S = J["series"]; T = S["total"]; rows = []389        for key, lab in _P9_EVENTS:390            e = T.get("its_" + key)391            if e: rows.append({"Event (month)": lab, "Pre-slope (SE)": f"{e['pre_slope']:.4f} ({e['pre_slope_se']:.4f})", "Level change (SE)": f"{e['level_change']:+.3f} ({e['level_se']:.3f})", "HAC p": e["level_p"], "Slope change (SE)": f"{e['slope_change']:+.4f} ({e['slope_se']:.4f})", "HAC p ": e["slope_p"], "R2": e["r2"]})392        write("tab_p9_its_total_events", pd.DataFrame(rows), "Interrupted time series of the total monthly series at the reference date and at four alternative event dates (log counts, month-of-year dummies, Newey--West standard errors).", "tab:p9-its-total-events", [p],393              fmt={"HAC p": "{:.3f}", "HAC p ": "{:.3g}", "R2": "{:.3f}"}, note="Equation~\\eqref{eq:its}; each event is coded as the first full month after the public release. Slopes are monthly log growth rates. A HAC $p$ printed as 0.000 is below 0.0005.")394        rows = [("Observations (months), first to last complete month", f"{T['n_months']} ({J['first']} to {J['last_complete']})"),395                ("Placebo rank of the level $t$-statistic (all candidate months)", f"rank {T['placebo_chatgpt']['level_t_rank']} of {T['placebo_chatgpt']['n_placebo']}, placebo $p$ = {T['placebo_chatgpt']['level_t_placebo_p']:.3f}"),396                ("Placebo rank of the slope $t$-statistic (all candidate months)", f"rank {T['placebo_chatgpt']['slope_t_rank']} of {T['placebo_chatgpt']['n_placebo']}, placebo $p$ = {T['placebo_chatgpt']['slope_t_placebo_p']:.3f}"),397                ("Placebo $p$, baseline candidate months only (level; slope)", f"{T['placebo_chatgpt_baseline_only']['level_t_placebo_p']:.3f}; {T['placebo_chatgpt_baseline_only']['slope_t_placebo_p']:.3f} (n = {T['placebo_chatgpt_baseline_only']['n_placebo']}; zero is attainable)"),398                ("Donut ITS, $\\pm$3 months removed: level change (HAC $p$)", f"{T['its_chatgpt_donut']['level_change']:+.3f} ({T['its_chatgpt_donut']['level_p']:.3f}), n = {T['its_chatgpt_donut']['n']}"),399                ("Donut ITS: slope change (HAC $p$)", f"{T['its_chatgpt_donut']['slope_change']:+.4f} ({T['its_chatgpt_donut']['slope_p']:.2g})"),400                ("Bai--Perron break dates, BIC (LWZ)", f"{', '.join(T['bai_perron']['break_dates_bic'])} ({', '.join(T['bai_perron']['break_dates_lwz'])}); m = {T['bai_perron']['m_bic']}, h = {T['bai_perron']['h']}"),401                ("Bai--Perron BIC by number of breaks m = 0, 1, 2, 3", ", ".join(f"{T['bai_perron']['bic_by_m'][k]:.1f}" for k in ("0", "1", "2", "3"))),402                ("PELT changepoints (piecewise-constant mean)", ", ".join(T["pelt"]) or "none"),403                ("BOCPD: max P(run length $\\le$ 3) over 2022-12 to 2023-03; MAP resets", f"{T['bocpd_cp_prob_2022_12_to_2023_03']:.3f}; {', '.join(T['bocpd_resets']) or 'none'}")]404        for lm, v in J.get("sensitivity_last_complete_total", {}).items():405            e = v["its_chatgpt"]; rows.append((f"Last complete month {lm}: level (p); slope (p); Bai--Perron", f"{e['level_change']:+.3f} ({e['level_p']:.3f}); {e['slope_change']:+.4f} ({e['slope_p']:.2g}); {', '.join(v['bai_perron'])}"))406        write("tab_p9_total_robustness", pd.DataFrame(rows, columns=["Diagnostic", "Total series"]), "Robustness diagnostics of the structural-change analysis of the total monthly series.", "tab:p9-total-robustness", [p],407              note="Placebo ranks follow equation~\\eqref{eq:placebo}; the baseline-only rank excludes the true event from the candidate set, so a value of zero means that no baseline month yields a larger $t$-statistic. Bai--Perron on the seasonally adjusted log series with trimming $h$; PELT with the $\\ell_2$ cost; BOCPD with hazard 1/60 (Part IV).")408        # categories: slopes409        rows = []; rows2 = []; rows3 = []; rows4 = []410        for sid, v in S.items():411            if sid == "total": continue412            k = sid[4:]; e = v["its_chatgpt"]; pl = v["placebo_chatgpt"]; plb = v["placebo_chatgpt_baseline_only"]; dn = v.get("its_chatgpt_donut", {})413            rows.append({"Category": names.get(k, k), "Works": int(v["total"]), "Pre-slope": e["pre_slope"], "Slope change": e["slope_change"], "SE": e["slope_se"], "HAC p": e["slope_p"], "Placebo p (all)": pl["slope_t_placebo_p"], "Placebo p (base)": plb["slope_t_placebo_p"], "Post slope": e["pre_slope"] + e["slope_change"]})414            rows2.append({"Category": names.get(k, k), "Level change": e["level_change"], "SE": e["level_se"], "HAC p": e["level_p"], "Placebo p (all)": pl["level_t_placebo_p"], "Placebo p (base)": plb["level_t_placebo_p"], "Donut level": dn.get("level_change", float("nan")), "Donut p": dn.get("level_p", float("nan")), "BOCPD P": v["bocpd_cp_prob_2022_12_to_2023_03"]})415            bp = v["bai_perron"]; inw = any("2022-10-01" <= d <= "2023-06-01" for d in bp["break_dates_bic"]); pin = any("2022-10" <= d <= "2023-06" for d in v["pelt"])416            rows3.append({"Category": names.get(k, k), "Bai--Perron (BIC)": ", ".join(d[:7] for d in bp["break_dates_bic"]) or "none", "Bai--Perron (LWZ)": ", ".join(d[:7] for d in bp["break_dates_lwz"]) or "none", "PELT": ", ".join(v["pelt"]) or "none", "BOCPD resets": ", ".join(v["bocpd_resets"]) or "none", "BIC break in window": "yes" if inw else "no", "PELT in window": "yes" if pin else "no"})417            r4 = {"Category": names.get(k, k)}418            for key, lab in _P9_EVENTS:419                ev = v.get("its_" + key); r4[lab.split(" (")[0]] = _p9_estp(ev["level_change"], ev["level_p"]) if ev else ""420            rows4.append(r4)421        t = pd.DataFrame(rows).sort_values("Slope change", ascending=False)422        write("tab_p9_its_slopes", t, "ITS slope change at December 2022 by category: pre-period slope, change, HAC and placebo $p$-values, and implied post-period slope (monthly log growth).", "tab:p9-its-slopes", [p], longtable=True,423              fmt={"Pre-slope": "{:.4f}", "Slope change": "{:+.4f}", "SE": "{:.4f}", "HAC p": "{:.3g}", "Placebo p (all)": "{:.3f}", "Placebo p (base)": "{:.3f}", "Post slope": "{:.4f}"},424              note="Multi-label monthly series of month-precision works (Part IV); Works: series total over the window. Placebo $p$ (all): rank of the slope $t$-statistic among all candidate event months; (base): among baseline months only, where zero is attainable. Sorted by slope change.")425        t = pd.DataFrame(rows2).sort_values("Category")426        write("tab_p9_its_levels", t, "ITS level change at December 2022 by category: full-sample and donut estimates, HAC and placebo $p$-values, and the BOCPD change probability over December 2022 to March 2023.", "tab:p9-its-levels", [p], longtable=True,427              fmt={"Level change": "{:+.3f}", "SE": "{:.3f}", "HAC p": "{:.3f}", "Placebo p (all)": "{:.3f}", "Placebo p (base)": "{:.3f}", "Donut level": "{:+.3f}", "Donut p": "{:.3f}", "BOCPD P": "{:.3f}"},428              note="Donut: the event month and three months on each side removed. BOCPD P: maximum posterior probability that a changepoint occurred within the last four months, taken over December 2022 to March 2023.")429        write("tab_p9_unknown_breaks", pd.DataFrame(rows3).sort_values("Category"), "Data-driven break and changepoint dates by category, and whether any falls in the H3 window (October 2022 to June 2023).", "tab:p9-unknown-breaks", [p], longtable=True,430              note="Bai--Perron on the seasonally adjusted log series with trimming $h = 15$ months (no break possible after April 2025); PELT with the $\\ell_2$ cost and a six-month minimum segment; BOCPD resets as defined in Part IV. The window test is the one pre-specified in H3; Bai--Perron dates carry no $p$-value.")431        write("tab_p9_altdates", pd.DataFrame(rows4).sort_values("Category"), "ITS level change (HAC $p$) by category at the five pre-specified event dates.", "tab:p9-altdates", [p], longtable=True,432              note="Each column re-estimates equation~\\eqref{eq:its} with the event at the stated month; entries are the log level change with the HAC $p$-value in parentheses. The five estimates of a row are not independent: they are the same series with the break moved.")433        # H3 focus table434        h3 = [("llm", "break expected"), ("generative_ai", "break expected"), ("ai_agents", "break expected"), ("ai_safety", "break expected"), ("formal_verification", "no break expected"), ("computational_complexity", "no break expected"), ("operating_systems", "no break expected"), ("computer_architecture", "no break expected")]435        rows = []436        for k, pred in h3:437            v = S.get("cat_" + k)438            if not v: continue439            bp = v["bai_perron"]; e = v["its_chatgpt"]440            rows.append({"Category": names[k], "H3 prediction": pred, "Bai--Perron (BIC)": ", ".join(d[:7] for d in bp["break_dates_bic"]) or "none", "PELT": ", ".join(v["pelt"]) or "none", "In window": "yes" if any("2022-10-01" <= d <= "2023-06-01" for d in bp["break_dates_bic"]) else "no",441                         "Level change (p)": _p9_estp(e["level_change"], e["level_p"]), "Slope change (p)": f"{e['slope_change']:+.4f} ({e['slope_p']:.2g})", "Placebo p (slope)": v["placebo_chatgpt"]["slope_t_placebo_p"], "BOCPD P": v["bocpd_cp_prob_2022_12_to_2023_03"]})442        write("tab_p9_h3", pd.DataFrame(rows), "Hypothesis H3: break dates and ITS estimates for the eight categories named in the protocol.", "tab:p9-h3", [p], fmt={"Placebo p (slope)": "{:.3f}", "BOCPD P": "{:.3f}"},443              note="In window: a Bai--Perron (BIC) break date between October 2022 and June 2023. The protocol predicted a break in that window for the first four categories and none for the last four.")444    # --- cross-area shares, modularity, flows ---445    p = f"{G}/cross_area_share_yearly.csv"446    if os.path.exists(p):447        cs = pd.read_csv(p); piv = cs.pivot(index="citing_year", columns="citing_area", values="share_cross") * 100448        cs["period"] = np.where(cs.citing_year <= 2022, "baseline", "post"); g = cs.groupby(["period", "citing_area"])[["n", "n_cross"]].sum(); w = g.loc["baseline"].n / g.loc["baseline"].n.sum()449        yr = cs.groupby("citing_year")[["n", "n_cross"]].sum(); raw = 100 * yr.n_cross / yr.n450        adj = pd.Series({y: 100 * float((w.reindex(gg.set_index("citing_area").index) * gg.set_index("citing_area").share_cross).sum()) for y, gg in cs.groupby("citing_year")})451        piv["All areas, raw"] = raw; piv["All areas, baseline area weights"] = adj; piv["Internal references (count)"] = yr.n452        t = piv.T.reset_index().rename(columns={"citing_area": "Citing area", "index": "Citing area"}); t.columns = ["Citing area"] + [str(c) for c in t.columns[1:]]453        t["Citing area"] = t["Citing area"].map(lambda a: _P9_AREA_SHORT.get(a, a))454        write("tab_p9_cross_area", t, "Cross-area share of internal references by citing area and citing year (per cent), with the aggregate share and its composition-adjusted counterpart.", "tab:p9-cross-area", [p], fmt={c: "{:,.1f}" for c in t.columns[1:]},455              note="Equation~\\eqref{eq:cross-area}: the share of an area's resolved internal references whose cited work has a primary category in another area. The composition-adjusted aggregate fixes each area's weight at its 2018--2022 share of internal references, so that it moves only when the areas' own cross-area rates move. The last row is the number of internal references with both ends classified. 2026 is incomplete.")456    p = f"{G}/modularity_yearly.csv"457    if os.path.exists(p):458        d = pd.read_csv(p); t = pd.DataFrame({"Year": d.year.astype(str), "Internal citations": d.edges.astype(int), "Q (categories)": d.Q_categories, "Q (areas)": d.Q_areas, "Self-share (%)": 100 * d.self_share})459        write("tab_p9_modularity", t, "Modularity of the yearly category citation-flow network at the category and area partitions, and the within-category self-share of citations.", "tab:p9-modularity", [p], fmt={"Q (categories)": "{:.4f}", "Q (areas)": "{:.4f}", "Self-share (%)": "{:.2f}"},460              note="Equation~\\eqref{eq:modularity} at the fixed partitions; the citation counts are internal references of works of the citing year whose two ends have a primary category. The 2018 network is small because few cited works of 2018 or later existed; 2026 is incomplete.")461    p = f"{G}/area_flows_yearly.csv"462    if os.path.exists(p):463        af = pd.read_csv(p); A = [a for a in _P9_AREA_SHORT if a in set(af.citing_area)]464        def mat(mask):465            mm = af[mask].pivot_table(index="citing_area", columns="cited_area", values="n", aggfunc="sum", fill_value=0).reindex(index=A, columns=A).fillna(0); return mm.div(mm.sum(1), axis=0) * 100466        base = mat(af.citing_year <= 2022); post = mat(af.citing_year >= 2023); diff = (post - base)467        t = diff.copy(); t.index = [_P9_AREA_SHORT[a] for a in t.index]; t.columns = [_P9_AREA_SHORT[a] for a in t.columns]; t = t.reset_index().rename(columns={"index": "Citing area"})468        write("tab_p9_area_flow_change", t, "Change in the area citation-flow matrix between 2018--2022 and 2023--2026: post-period row share minus baseline row share (percentage points).", "tab:p9-area-flow-change", [p], fmt={c: "{:+.1f}" for c in t.columns[1:]},469              note="Rows: citing area; columns: cited area; row shares of internal references with both ends classified, split by citing calendar year (2022 entirely in the first span). Figure~\\ref{fig:p9-area-flow-matrix} shows the two underlying matrices.")470    p = f"{G}/category_cooccurrence_yearly.csv"471    if os.path.exists(p):472        co = pd.read_csv(p); co["period"] = np.where(co.year <= 2022, "baseline", "post"); rows = []473        tb = co[co.period == "baseline"].groupby(["cat_a", "cat_b"]).n.sum().sort_values(ascending=False).head(15); tp = co[co.period == "post"].groupby(["cat_a", "cat_b"]).n.sum().sort_values(ascending=False).head(15)474        for i in range(15):475            rows.append({"Rank": i + 1, "Pair, 2018--2022": f"{names.get(tb.index[i][0], tb.index[i][0])} / {names.get(tb.index[i][1], tb.index[i][1])}", "Works": int(tb.iloc[i]), "Pair, 2023--2026": f"{names.get(tp.index[i][0], tp.index[i][0])} / {names.get(tp.index[i][1], tp.index[i][1])}", "Works ": int(tp.iloc[i])})476        write("tab_p9_cooccurrence", pd.DataFrame(rows), "The fifteen most frequent co-classification pairs (works carrying both categories in their label set), 2018--2022 and 2023--2026.", "tab:p9-cooccurrence", [p],477              note="Counts of works with both labels, summed over the calendar years of each span (2022 in the first); the post span is incomplete. Co-classification depends on abstract availability and vocabulary (Part IV), so the pairs describe the classifier's view of the literature.")478        ll = co[(co.cat_a == "llm") | (co.cat_b == "llm")].copy(); ll["partner"] = np.where(ll.cat_a == "llm", ll.cat_b, ll.cat_a)479        g = ll.groupby(["partner", "period"]).n.sum().unstack().fillna(0); g = g.sort_values("post", ascending=False).head(15)480        t = pd.DataFrame({"Partner category": [names.get(k, k) for k in g.index], "Works, 2018--2022": g.baseline.astype(int).values, "Works, 2023--2026": g.post.astype(int).values, "Ratio": (g.post / g.baseline.replace(0, np.nan)).values})481        write("tab_p9_llm_partners", t, "Categories most often co-assigned with Large Language Models, by span.", "tab:p9-llm-partners", [p], fmt={"Ratio": "{:.2f}"}, note="Works carrying both the LLM label and the partner label; spans split by calendar year; the second span is incomplete.")482    # --- openness by year ---483    p = f"{B}/volume_yearly.csv"484    if os.path.exists(p):485        v = pd.read_csv(p); v = v[v.year <= 2026]486        t = pd.DataFrame({"Year": v.year.astype(str), "Works": v.n, "DBLP (%)": 100 * v.n_dblp / v.n, "arXiv-linked (%)": 100 * v.n_arxiv / v.n, "Preprint type (%)": 100 * v.n_preprint / v.n, "Open access (%)": 100 * v.n_oa / v.n, "Abstract (%)": 100 * v.n_abstract / v.n, "Conference (%)": 100 * v.n_conf / v.n, "Article (%)": 100 * v.n_article / v.n, "Mean refs.": v.mean_refs})487        write("tab_p9_openness", t, "Openness and metadata indicators by publication year: arXiv linkage, preprint share, open access, abstract availability and document type (per cent of eligible works).", "tab:p9-openness", [p], fmt={c: "{:.1f}" for c in t.columns[2:]},488              note="arXiv-linked: the work is linked to an arXiv e-print (any version); preprint type: the surviving record's OpenAlex type is preprint; open access: any OA location in OpenAlex. Mean refs.: declared references per work, which falls in recent years because references are indexed with a lag. 2026 is incomplete.")489    # --- landmarks ---490    p = f"{R}/literature_synthesis/landmarks/selection_report.json"491    if os.path.exists(p):492        Sj = json.load(open(p)); rows = [{"Group": "Area", "Level": area_names.get(k, k), "Landmarks": v} for k, v in Sj["by_area"].items()] + [{"Group": "Year", "Level": k, "Landmarks": v} for k, v in sorted(Sj["by_year_landmarks"].items())]493        write("tab_p9_landmarks", pd.DataFrame(rows), "Composition of the landmark set by area and by publication year.", "tab:p9-landmarks", [p], note="Selection by field-normalised impact within category and year with per-area quotas (Part IV); the specialist review of the set (checkpoint H-3) is pending, so the composition is reported without any assessment of the works.")494    # --- emerging: setup and AUC by cutoff ---495    fs = {h: f"{X}/metrics_h{h}.json" for h in (12, 24)}496    if all(os.path.exists(f) for f in fs.values()):497        M = {h: json.load(open(f)) for h, f in fs.items()}; rows = []498        for h in (12, 24):499            m = M[h]; rows.append({"Horizon (months)": str(h), "Training cutoffs": f"{m['train_cutoffs'][0]} to {m['train_cutoffs'][-1]} ({len(m['train_cutoffs'])})", "Test cutoffs": f"{m['test_cutoffs'][0]} to {m['test_cutoffs'][-1]} ({len(m['test_cutoffs'])})", "Train rows": m["n_train"], "Test rows": m["n_test"], "Positive rate, train": m["pos_rate_train"], "Positive rate, test": m["pos_rate_test"]})500        write("tab_p9_emerging_setup", pd.DataFrame(rows), "Out-of-time evaluation design of the emerging-topic detector: cutoffs, panel sizes and positive rates.", "tab:p9-emerging-setup", list(fs.values()), fmt={"Positive rate, train": "{:.4f}", "Positive rate, test": "{:.4f}"},501              note="Rows are eligible (topic, cutoff) pairs; the label of equation~\\eqref{eq:label} requires top-decile future growth and a doubling of volume, so the positive rate is below ten per cent.")502        cut = sorted(set(M[12]["auc_by_cutoff"]) | set(M[24]["auc_by_cutoff"])); rows = []503        for c in cut:504            r = {"Test cutoff": c}505            for h in (12, 24):506                a = M[h]["auc_by_cutoff"].get(c, {})507                for mdl, lab in (("zscore_baseline", "z-score"), ("logistic", "logistic"), ("gbm", "GBM")): r[f"h{h}: {lab}"] = a.get(mdl, float("nan"))508            rows.append(r)509        t = pd.DataFrame(rows); write("tab_p9_emerging_cutoffs", t, "AUC of the three detectors at each test cutoff, by horizon.", "tab:p9-emerging-cutoffs", list(fs.values()), fmt={c: "{:.3f}" for c in t.columns[1:]},510                                      note="Within-cutoff AUC on the eligible topics of the cutoff; blank where the label window of the cutoff extends beyond the panel. Pooled values are in Tables~\\ref{tab:emerging_h12} and~\\ref{tab:emerging_h24}.")511512513if __name__ == "__main__":514    main()515
Files · cc7bdc1d7e9c9d644aad447a0ce2c782cdb76947
Menu

Theme