| 89 | |
89 | |
| 90 | def main(): |
90 | def 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) |
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 + _BATCH3: |
92 | + for f in (fig_prisma, fig_yearly_volume, fig_monthly, fig_types_sources, fig_collab) + _BATCH2 + _BATCH3 + _BATCH4: |
| 93 | try: |
93 | try: |
| 94 | f(a.root, out); print("ok", f.__name__) |
94 | f(a.root, out); print("ok", f.__name__) |
| 95 | except Exception as e: |
95 | except Exception as e: |
| 212 | _BATCH3 = (fig_its_total, fig_placebo_total) |
212 | _BATCH3 = (fig_its_total, fig_placebo_total) |
| 213 | |
213 | |
| 214 | |
214 | |
| |
215 | + |
| |
216 | + |
| |
217 | +# ---------------- batch 4: categories (after classification) ---------------- |
| |
218 | +def _cat_names(R): |
| |
219 | + import yaml |
| |
220 | + o = yaml.safe_load(open(f"{R}/taxonomy/ontology.yaml")); return {c["id"]: c["name"] for c in o["categories"]}, {c["id"]: c["area"] for c in o["categories"]} |
| |
221 | + |
| |
222 | + |
| |
223 | +def fig_category_growth(R, out): |
| |
224 | + p = f"{R}/results/graph/category_counts_yearly.csv" |
| |
225 | + if not os.path.exists(p): |
| |
226 | + return |
| |
227 | + names, areas = _cat_names(R); d = pd.read_csv(p); d = d[(d.year >= 2018) & (d.year <= 2025)] |
| |
228 | + piv = d.pivot_table(index="year", columns="category_id", values="n_primary", fill_value=0); base = piv.loc[2018] |
| |
229 | + idx = piv / base.replace(0, np.nan) * 100; cats = sorted(idx.columns, key=lambda c: -idx.loc[2025, c] if 2025 in idx.index else 0) |
| |
230 | + fig, axes = plt.subplots(1, 2, figsize=(7.4, 3.6), sharey=False) |
| |
231 | + for ax, sel, title in [(axes[0], cats[:8], "Eight fastest-growing categories"), (axes[1], cats[-8:], "Eight slowest-growing categories")]: |
| |
232 | + for i, c in enumerate(sel): |
| |
233 | + ax.plot(idx.index, idx[c], marker="o", ms=3, color=style.CATEGORICAL[i % 8], label=names.get(c, c), lw=1.1) |
| |
234 | + ax.axhline(100, color=style.AXIS, lw=0.6); ax.set_title(title, loc="left"); ax.set_ylabel("Primary works, index 2018 = 100"); ax.legend(fontsize=6) |
| |
235 | + style.save(fig, f"{out}/fig_category_growth.pdf"); reg("fig_category_growth", "Yearly primary-category output indexed to 2018, fastest- and slowest-growing categories.", [p]) |
| |
236 | + |
| |
237 | + |
| |
238 | +def fig_category_shares(R, out): |
| |
239 | + p = f"{R}/results/graph/category_counts_yearly.csv" |
| |
240 | + if not os.path.exists(p): |
| |
241 | + return |
| |
242 | + names, areas = _cat_names(R); d = pd.read_csv(p); d = d[(d.year >= 2018) & (d.year <= 2025)]; d["area"] = d.category_id.map(areas) |
| |
243 | + piv = d.pivot_table(index="year", columns="area", values="n_primary", aggfunc="sum", fill_value=0); sh = piv.div(piv.sum(1), axis=0) * 100 |
| |
244 | + fig, ax = plt.subplots(figsize=(6.8, 3.4)) |
| |
245 | + for i, a in enumerate(sh.columns): |
| |
246 | + ax.plot(sh.index, sh[a], marker="o", ms=3, color=style.CATEGORICAL[i % 8], label=a, lw=1.2) |
| |
247 | + ax.annotate(a, (sh.index[-1], sh[a].iloc[-1]), xytext=(3, 0), textcoords="offset points", fontsize=6.5, color=style.INK2, va="center") |
| |
248 | + ax.set_ylabel("Share of primary assignments (%)"); ax.set_title("Composition of CS output by area (primary category)", loc="left"); ax.legend(ncol=2, fontsize=6.5) |
| |
249 | + style.save(fig, f"{out}/fig_area_shares.pdf"); reg("fig_area_shares", "Share of eligible works by top-level area (primary category) per year.", [p]) |
| |
250 | + |
| |
251 | + |
| |
252 | +def fig_breaks_heatmap(R, out): |
| |
253 | + p = f"{R}/results/econometrics/breaks.csv" |
| |
254 | + if not os.path.exists(p): |
| |
255 | + return |
| |
256 | + names, areas = _cat_names(R); d = pd.read_csv(p); d = d[d.series.str.startswith("cat_")].copy(); d["cat"] = d.series.str[4:] |
| |
257 | + if d.empty: |
| |
258 | + return |
| |
259 | + d = d.sort_values("chatgpt_slope", ascending=False) |
| |
260 | + fig, ax = plt.subplots(figsize=(6.8, 7.2)) |
| |
261 | + y = np.arange(len(d)); cols = [style.CATEGORICAL[0] if p_ < 0.05 else style.GRID for p_ in d.placebo_slope_p.fillna(1)] |
| |
262 | + ax.barh(y, 100 * d.chatgpt_slope, color=cols, height=0.7); ax.set_yticks(y); ax.set_yticklabels([names.get(c, c) for c in d.cat], fontsize=6.5); ax.invert_yaxis() |
| |
263 | + ax.axvline(0, color=style.AXIS, lw=0.6); ax.set_xlabel("Change in monthly growth rate after 2022-12 (percentage points per month)") |
| |
264 | + ax.set_title("ITS slope change by category (blue: placebo p < 0.05)", loc="left") |
| |
265 | + for yi, (s, lp) in enumerate(zip(d.chatgpt_slope, d.chatgpt_level)): |
| |
266 | + ax.text(100 * s + (0.03 if s >= 0 else -0.03), yi, f"level {100*lp:+.0f}%", fontsize=5.5, va="center", ha="left" if s >= 0 else "right", color=style.INK2) |
| |
267 | + style.save(fig, f"{out}/fig_breaks_by_category.pdf"); reg("fig_breaks_by_category", "ITS slope change at 2022-12 by category with level change annotated; bars in colour pass the placebo test.", [p]) |
| |
268 | + |
| |
269 | + |
| |
270 | +def fig_flow_matrix(R, out): |
| |
271 | + p = f"{R}/results/graph/area_flows_yearly.csv" |
| |
272 | + if not os.path.exists(p): |
| |
273 | + return |
| |
274 | + d = pd.read_csv(p); fig, axes = plt.subplots(1, 2, figsize=(7.4, 3.6)) |
| |
275 | + for ax, (lo, hi), title in [(axes[0], (2018, 2022), "Baseline 2018–2022"), (axes[1], (2023, 2026), "Post 2023–2026")]: |
| |
276 | + g = d[(d.citing_year >= lo) & (d.citing_year <= hi)].groupby(["citing_area", "cited_area"]).n.sum().unstack(fill_value=0) |
| |
277 | + g = g.reindex(index=sorted(set(g.index) | set(g.columns)), columns=sorted(set(g.index) | set(g.columns)), fill_value=0); rs = g.div(g.sum(1), axis=0) * 100 |
| |
278 | + im = ax.imshow(rs.values, cmap=style.seq_cmap(), vmin=0, vmax=max(rs.values.max(), 1)); ax.set_xticks(range(len(rs.columns))); ax.set_xticklabels(rs.columns, rotation=60, ha="right", fontsize=6); ax.set_yticks(range(len(rs.index))); ax.set_yticklabels(rs.index, fontsize=6) |
| |
279 | + ax.set_title(title, loc="left"); ax.grid(False) |
| |
280 | + for i in range(len(rs.index)): |
| |
281 | + for j in range(len(rs.columns)): |
| |
282 | + v = rs.values[i, j] |
| |
283 | + if v >= 5: ax.text(j, i, f"{v:.0f}", ha="center", va="center", fontsize=5.5, color="white" if v > 0.6 * rs.values.max() else style.INK) |
| |
284 | + fig.text(0.5, -0.02, "Row shares (%): references from the citing area (rows) to the cited area (columns), internal citations only", ha="center", fontsize=7, color=style.INK2) |
| |
285 | + style.save(fig, f"{out}/fig_area_flow_matrix.pdf"); reg("fig_area_flow_matrix", "Citation-flow matrices between areas (row shares) for the baseline and post periods.", [p]) |
| |
286 | + |
| |
287 | + |
| |
288 | +def fig_cross_area(R, out): |
| |
289 | + p = f"{R}/results/graph/cross_area_share_yearly.csv"; q = f"{R}/results/graph/modularity_yearly.csv" |
| |
290 | + if not os.path.exists(p): |
| |
291 | + return |
| |
292 | + d = pd.read_csv(p); fig, axes = plt.subplots(1, 2, figsize=(7.4, 3.2)) |
| |
293 | + for i, a in enumerate(sorted(d.citing_area.unique())): |
| |
294 | + g = d[d.citing_area == a]; axes[0].plot(g.citing_year, 100 * g.share_cross, marker="o", ms=3, color=style.CATEGORICAL[i % 8], label=a, lw=1.1) |
| |
295 | + axes[0].set_title("Share of references leaving the citing area (%)", loc="left"); axes[0].legend(fontsize=6, ncol=2) |
| |
296 | + if os.path.exists(q): |
| |
297 | + m = pd.read_csv(q); axes[1].plot(m.year, m.Q_areas, marker="o", color=style.CATEGORICAL[0], label="Modularity Q (areas)"); axes[1].plot(m.year, m.Q_categories, marker="o", color=style.CATEGORICAL[1], label="Modularity Q (categories)"); axes[1].plot(m.year, m.self_share, marker="o", color=style.CATEGORICAL[2], label="Within-category share") |
| |
298 | + axes[1].set_title("Modularity of the yearly category citation network", loc="left"); axes[1].legend(fontsize=6) |
| |
299 | + style.save(fig, f"{out}/fig_cross_area.pdf"); reg("fig_cross_area", "Cross-area reference shares by citing area and modularity of the category citation network by year.", [p, q]) |
| |
300 | + |
| |
301 | + |
| |
302 | +def fig_emerging(R, out): |
| |
303 | + import glob as _g |
| |
304 | + files = sorted(_g.glob(f"{R}/results/emerging/metrics_h*.json")) |
| |
305 | + if not files: |
| |
306 | + return |
| |
307 | + fig, axes = plt.subplots(1, len(files), figsize=(3.7 * len(files), 3.0), squeeze=False) |
| |
308 | + for ax, f in zip(axes[0], files): |
| |
309 | + M = json.load(open(f)); models = [m for m in ("zscore_baseline", "logistic", "gbm") if m in M] |
| |
310 | + x = np.arange(len(models)); vals = [[M[m]["auc"], M[m]["precision_at_20"], M[m]["precision_at_50"]] for m in models] |
| |
311 | + for k, (lab, col) in enumerate([("AUC", style.CATEGORICAL[0]), ("P@20", style.CATEGORICAL[1]), ("P@50", style.CATEGORICAL[2])]): |
| |
312 | + ax.bar(x + (k - 1) * 0.26, [v[k] for v in vals], width=0.24, color=col, label=lab) |
| |
313 | + for xi, v in zip(x, vals): ax.text(xi + (k - 1) * 0.26, v[k] + 0.01, f"{v[k]:.2f}", ha="center", fontsize=5.5, color=style.INK2) |
| |
314 | + ax.set_xticks(x); ax.set_xticklabels(["z-score", "logistic", "GBM"]); ax.set_ylim(0, 1.05); ax.set_title(f"Horizon {M['horizon']} months (test cutoffs {M['test_cutoffs'][0]}–{M['test_cutoffs'][-1]})", loc="left", fontsize=8); ax.legend(fontsize=6) |
| |
315 | + style.save(fig, f"{out}/fig_emerging_metrics.pdf"); reg("fig_emerging_metrics", "Out-of-time detection performance of the emerging-topic models by horizon.", files) |
| |
316 | + |
| |
317 | + |
| |
318 | +def fig_known_set(R, out): |
| |
319 | + p = f"{R}/results/emerging/known_set_monthly.csv" |
| |
320 | + if not os.path.exists(p): |
| |
321 | + return |
| |
322 | + d = pd.read_csv(p, index_col=0); x = pd.to_datetime(d.index + "-01"); fig, axes = plt.subplots(3, 4, figsize=(7.6, 5.4), sharex=True) |
| |
323 | + lt = pd.read_csv(f"{R}/results/emerging/known_set_lead_times.csv").set_index("concept") |
| |
324 | + for ax, c in zip(axes.ravel(), d.columns): |
| |
325 | + ax.plot(x, d[c], color=style.CATEGORICAL[0], lw=1.0); ax.set_title(c, fontsize=7, loc="left"); ax.grid(True) |
| |
326 | + det = lt.loc[c, "detection_month"] if c in lt.index else None |
| |
327 | + if isinstance(det, str): ax.axvline(pd.Timestamp(det + "-01"), color=style.CATEGORICAL[1], lw=0.9, ls="--") |
| |
328 | + ax.tick_params(labelsize=6) |
| |
329 | + fig.suptitle("Preregistered known-emergence concepts: monthly works (keyword series) and detection month (dashed)", fontsize=8, x=0.02, ha="left") |
| |
330 | + style.save(fig, f"{out}/fig_known_set.pdf"); reg("fig_known_set", "Monthly keyword series for the twelve preregistered concepts with the detector's first alarm.", [p]) |
| |
331 | + |
| |
332 | + |
| |
333 | +_BATCH4 = (fig_category_growth, fig_category_shares, fig_breaks_heatmap, fig_flow_matrix, fig_cross_area, fig_emerging, fig_known_set) |
| |
334 | + |
| |
335 | + |
| 215 | if __name__ == "__main__": |
336 | if __name__ == "__main__": |
| 216 | main() |
337 | main() |