1#!/usr/bin/env python32"""WP44 — publication figures, batch 1 (corpus and volume). Every figure is generated from result files; none is drawn by hand.3Run on the compute node: python figures/make_figures.py --root ~/wp44_uqo → figures/*.pdf + .png, and figures/FIGURES.json (inventory)."""4import argparse, json, os, sys5import numpy as np, pandas as pd, matplotlib6matplotlib.use("Agg")7import matplotlib.pyplot as plt8sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))9import style10style.apply()11INV = []121314def reg(name, caption, sources):15 INV.append({"file": name, "caption": caption, "sources": sources})161718def fig_prisma(R, out):19 P = json.load(open(f"{R}/data/metadata/prisma_counts.json")); fig, ax = plt.subplots(figsize=(7.2, 6.2)); ax.axis("off"); ax.grid(False)20 boxes = [("Identified (OpenAlex works with a CS topic, 2018–2026, snapshot 2026-10-14)", P["identified"]["openalex_total_records_loaded"]),21 ("Outside window (E1)", -P["excluded_sequential"]["e1_outside_window"]), ("Non-research types (E2)", -P["excluded_sequential"]["e2_type"]),22 ("XPAC expansion records, not DBLP-linked (E3)", -P["excluded_sequential"]["e3_xpac"]), ("No title (E4)", -P["excluded_sequential"]["e4_no_title"]),23 ("Preprint duplicates merged into the published version (E5)", -P["excluded_sequential"]["e5_duplicate"]),24 ("Generic deposit platforms without DBLP/arXiv support (E7)", -P["excluded_sequential"].get("e7_generic_deposit", 0)),25 ("After exclusions", P["after_exclusions"]), ("CS core — mapping corpus (DBLP ∨ primary CS ∨ CS topic ∧ arXiv cs.*)", P["cs_core_eligible"]),26 ("CS penumbra — secondary CS topic only (sensitivity set)", P["cs_penumbra_eligible_otherwise"])]27 y = 0.9528 for label, n in boxes:29 excl = n < 0; txt = f"{abs(n):,}"30 ax.text(0.02 if not excl else 0.40, y, (label if not excl else "− " + label) + f"\n{txt}", fontsize=8.2, va="top", ha="left",31 bbox=dict(boxstyle="round,pad=0.4", fc="white", ec=style.AXIS if not excl else style.GRID, lw=0.8), transform=ax.transAxes, color=style.INK)32 y -= 0.09533 ax.set_title("PRISMA-style flow of the WP44 mapping corpus", loc="left")34 style.save(fig, f"{out}/fig_prisma_flow.pdf"); reg("fig_prisma_flow", "PRISMA-style flow of record identification, exclusion and the final mapping corpus.", ["data/metadata/prisma_counts.json"])353637def fig_yearly_volume(R, out):38 d = pd.read_csv(f"{R}/results/bibliometrics/volume_yearly.csv"); d = d[d.year <= 2026]39 fig, ax = plt.subplots(figsize=(6.8, 3.4))40 cols = [("n", "All eligible works"), ("n_dblp", "DBLP-indexed"), ("n_cs_primary", "Primary field = CS"), ("n_arxiv", "arXiv version linked")]41 for i, (c, lab) in enumerate(cols):42 ax.plot(d.year, d[c] / 1e3, marker="o", label=lab, color=style.CATEGORICAL[i])43 ax.annotate(lab, (d.year.iloc[-1], d[c].iloc[-1] / 1e3), xytext=(4, 0), textcoords="offset points", fontsize=7, color=style.INK2, va="center")44 ax.axvspan(2026, 2026.6, color=style.GRID, alpha=0.5, lw=0); ax.text(2026.3, ax.get_ylim()[0] + 0.02 * ax.get_ylim()[1], "2026\n(partial)", fontsize=7, color=style.INK2, va="bottom", ha="center")45 ax.set_ylabel("Works (thousands)"); ax.set_xlabel(""); ax.set_xlim(2017.7, 2027.4); ax.legend(loc="upper left")46 ax.set_title("Eligible computer-science works per publication year, by membership", loc="left")47 style.save(fig, f"{out}/fig_volume_yearly.pdf"); reg("fig_volume_yearly", "Eligible works per publication year by source membership (2026 incomplete).", ["results/bibliometrics/volume_yearly.csv"])484950def fig_monthly(R, out):51 d = pd.read_csv(f"{R}/results/bibliometrics/volume_monthly.csv")52 m = d.groupby("first_month")["n"].sum().reset_index(); m = m[(m.first_month >= "2018-01") & (m.first_month <= "2026-10")]53 # month-precision only: records dated YYYY-01-01 are year-precision and are excluded from monthly series (A-002); here approximated by dropping January spikes is NOT done:54 # volume_monthly is built from first_month of all eligible works; the precise month-only series comes from category_counts_monthly (n_month_precision). We plot both when available.55 x = pd.to_datetime(m.first_month + "-01"); fig, ax = plt.subplots(figsize=(6.8, 3.2))56 ax.plot(x, m.n / 1e3, color=style.CATEGORICAL[0], lw=1.2, label="All eligible works (first appearance month)")57 style.event_line(ax, pd.Timestamp("2022-11-30"))58 ax.set_ylabel("Works per month (thousands)"); ax.set_title("Monthly first appearances of eligible works (January spikes = year-precision dates)", loc="left")59 ax.legend(loc="upper left"); style.save(fig, f"{out}/fig_volume_monthly_raw.pdf")60 reg("fig_volume_monthly_raw", "Monthly first appearances; January spikes reflect year-only publication dates and motivate the month-precision filter.", ["results/bibliometrics/volume_monthly.csv"])616263def fig_types_sources(R, out):64 d = pd.read_csv(f"{R}/results/bibliometrics/volume_yearly.csv"); d = d[d.year <= 2026]65 fig, axes = plt.subplots(1, 2, figsize=(7.2, 3.0))66 ax = axes[0]67 for i, (c, lab) in enumerate([("n_article", "Journal article"), ("n_conf", "Conference paper"), ("n_preprint", "Preprint")]):68 ax.plot(d.year, 100 * d[c] / d.n, marker="o", label=lab, color=style.CATEGORICAL[i])69 ax.set_ylabel("Share of eligible works (%)"); ax.set_title("Document types", loc="left"); ax.legend()70 ax = axes[1]71 for i, (c, lab) in enumerate([("n_abstract", "Abstract available"), ("n_oa", "Open access"), ("n_arxiv", "arXiv linked")]):72 ax.plot(d.year, 100 * d[c] / d.n, marker="o", label=lab, color=style.CATEGORICAL[i + 3])73 ax.set_title("Metadata completeness and openness", loc="left"); ax.legend(); ax.set_ylabel("Share of eligible works (%)")74 style.save(fig, f"{out}/fig_types_openness.pdf"); reg("fig_types_openness", "Document-type composition and metadata completeness by year.", ["results/bibliometrics/volume_yearly.csv"])757677def fig_collab(R, out):78 p = f"{R}/results/bibliometrics/team_size_yearly.csv"79 if not os.path.exists(p):80 return81 d = pd.read_csv(p); d = d[d.year <= 2026]; ind = pd.read_csv(f"{R}/results/bibliometrics/industry_share_yearly.csv")82 fig, axes = plt.subplots(1, 2, figsize=(7.2, 3.0))83 ax = axes[0]; ax.plot(d.year, d.mean_authors, marker="o", color=style.CATEGORICAL[0], label="Mean authors per work"); ax.set_ylabel("Authors"); ax.set_title("Team size", loc="left"); ax.legend()84 ax = axes[1]; ax.plot(d.year, 100 * d.intl / d.n, marker="o", color=style.CATEGORICAL[1], label="≥ 2 countries (all works)")85 ax.plot(ind.year, 100 * ind.share_company, marker="o", color=style.CATEGORICAL[2], label="Company affiliation (works with affiliations)")86 ax.set_ylabel("Share (%)"); ax.set_title("International and industry participation", loc="left"); ax.legend()87 style.save(fig, f"{out}/fig_collaboration.pdf"); reg("fig_collaboration", "Team size, international co-authorship and industry participation by year.", ["results/bibliometrics/team_size_yearly.csv", "results/bibliometrics/industry_share_yearly.csv"])888990def main():91 ap = argparse.ArgumentParser(); ap.add_argument("--root", required=True); a = ap.parse_args(); out = f"{a.root}/figures"; os.makedirs(out, exist_ok=True)92 for f in (fig_prisma, fig_yearly_volume, fig_monthly, fig_types_sources, fig_collab) + _BATCH2:93 try:94 f(a.root, out); print("ok", f.__name__)95 except Exception as e:96 print("FAIL", f.__name__, e)97 json.dump(INV, open(f"{out}/FIGURES.json", "w"), indent=1)9899100101102# ---------------- batch 2: citations and knowledge flows (generated from results/bibliometrics + results/graph) ----------------103def fig_citation_inequality(R, out):104 p = f"{R}/results/bibliometrics/citation_windows_yearly.csv"105 if not os.path.exists(p):106 return107 d = pd.read_csv(p); d = d[d.year <= 2023] # c36 requires three full years of citation data108 fig, axes = plt.subplots(1, 2, figsize=(7.2, 3.0))109 ax = axes[0]; ax.plot(d.year, d.mean_c36, marker="o", color=style.CATEGORICAL[0], label="Mean c36"); ax.plot(d.year, d.p99_c36 / 10, marker="o", color=style.CATEGORICAL[1], label="p99 c36 ÷ 10")110 ax.set_title("Fixed-window citations (36 months)", loc="left"); ax.set_ylabel("Citations"); ax.legend()111 ax = axes[1]; ax.plot(d.year, d.gini_c36, marker="o", color=style.CATEGORICAL[2], label="Gini of c36"); ax.plot(d.year, d.share_uncited_36, marker="o", color=style.CATEGORICAL[3], label="Share uncited at 36 months")112 ax.set_title("Citation inequality", loc="left"); ax.set_ylim(0, 1); ax.legend()113 style.save(fig, f"{out}/fig_citation_inequality.pdf"); reg("fig_citation_inequality", "Fixed-window citation levels and inequality by publication year (years with complete 36-month windows).", ["results/bibliometrics/citation_windows_yearly.csv"])114115116def fig_field_inflows(R, out):117 p = f"{R}/results/graph/field_flows_yearly.csv"118 if not os.path.exists(p):119 return120 d = pd.read_csv(p); d = d[(d.citing_year >= 2018) & (d.citing_year <= 2026)]121 names = {17: "Computer science (outside corpus)", 26: "Mathematics", 22: "Engineering", 31: "Physics & astronomy", 27: "Medicine", 33: "Social sciences", 13: "Biochem./genetics", 18: "Decision sciences", 28: "Neuroscience", 20: "Economics & finance"}122 tot = d.groupby("citing_year").n.sum(); ext = d[d.kind == "external"]123 top = ext.groupby("cited_field_id").n.sum().nlargest(8).index124 fig, ax = plt.subplots(figsize=(6.8, 3.4))125 for i, f in enumerate(top):126 s = ext[ext.cited_field_id == f].groupby("citing_year").n.sum() / tot * 100127 ax.plot(s.index, s.values, marker="o", color=style.CATEGORICAL[i % 8], label=names.get(int(f), f"field {int(f)}"), lw=1.2)128 ax.annotate(names.get(int(f), str(int(f))), (s.index[-1], s.values[-1]), xytext=(3, 0), textcoords="offset points", fontsize=6.5, color=style.INK2, va="center")129 ax.set_ylabel("Share of all references (%)"); ax.set_title("References from the CS corpus to works outside it, by cited field", loc="left"); ax.legend(ncol=2, fontsize=6.5)130 style.save(fig, f"{out}/fig_field_inflows.pdf"); reg("fig_field_inflows", "Share of references pointing outside the corpus, by cited OpenAlex field and citing year.", ["results/graph/field_flows_yearly.csv"])131132133def fig_cited_age(R, out):134 p = f"{R}/results/graph/cited_age_yearly.csv"135 if not os.path.exists(p):136 return137 d = pd.read_csv(p); d = d[(d.age >= 0) & (d.age <= 30)]138 fig, ax = plt.subplots(figsize=(6.8, 3.2))139 for i, y in enumerate([2018, 2020, 2022, 2024, 2026]):140 g = d[d.citing_year == y]; s = g.n / g.n.sum()141 ax.plot(g.age, s * 100, color=style.SEQUENTIAL[2 + 2 * i], label=str(y), lw=1.3)142 ax.set_xlabel("Age of cited work (years)"); ax.set_ylabel("Share of references (%)"); ax.set_title("Reference age distribution by citing year", loc="left"); ax.legend(title="Citing year")143 style.save(fig, f"{out}/fig_cited_age.pdf"); reg("fig_cited_age", "Distribution of the age of cited works for selected citing years.", ["results/graph/cited_age_yearly.csv"])144145146def fig_venue_hhi(R, out):147 p = f"{R}/results/bibliometrics/venue_hhi_yearly.csv"148 if not os.path.exists(p):149 return150 d = pd.read_csv(p); d = d[d.year <= 2026]151 fig, axes = plt.subplots(1, 2, figsize=(7.2, 3.0))152 axes[0].plot(d.year, d.n_sources, marker="o", color=style.CATEGORICAL[0]); axes[0].set_title("Distinct sources (venues) per year", loc="left")153 axes[1].plot(d.year, d.hhi * 1e4, marker="o", color=style.CATEGORICAL[1], label="HHI × 10⁴"); axes[1].plot(d.year, d.top1_share * 100, marker="o", color=style.CATEGORICAL[2], label="Top source share (%)"); axes[1].set_title("Venue concentration", loc="left"); axes[1].legend()154 style.save(fig, f"{out}/fig_venue_concentration.pdf"); reg("fig_venue_concentration", "Number of distinct venues and venue concentration (HHI, top-source share) by year.", ["results/bibliometrics/venue_hhi_yearly.csv"])155156157def fig_countries(R, out):158 p = f"{R}/results/bibliometrics/countries_yearly.csv"159 if not os.path.exists(p):160 return161 d = pd.read_csv(p); d = d[d.year <= 2026]; tot = d.groupby("year").n_frac.sum(); top = d.groupby("country").n_frac.sum().nlargest(8).index162 fig, ax = plt.subplots(figsize=(6.8, 3.4))163 for i, c in enumerate(top):164 s = d[d.country == c].set_index("year").n_frac / tot * 100165 ax.plot(s.index, s.values, marker="o", color=style.CATEGORICAL[i], label=c, lw=1.2); ax.annotate(c, (s.index[-1], s.values[-1]), xytext=(3, 0), textcoords="offset points", fontsize=7, color=style.INK2, va="center")166 ax.set_ylabel("Share of fractional output (%)"); ax.set_title("Country shares of eligible CS output (fractional counting, top 8)", loc="left"); ax.legend(ncol=4, fontsize=7)167 style.save(fig, f"{out}/fig_country_shares.pdf"); reg("fig_country_shares", "Country shares of output with fractional counting, top eight countries.", ["results/bibliometrics/countries_yearly.csv"])168169170_BATCH2 = (fig_citation_inequality, fig_field_inflows, fig_cited_age, fig_venue_hhi, fig_countries)171172173if __name__ == "__main__":174 main()175