citegraph 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
citegraph/graph.py ADDED
@@ -0,0 +1,660 @@
1
+ """A queryable view over the pipeline's output CSVs.
2
+
3
+ The pipeline writes everything you need to disk, but the package is called
4
+ ``citegraph`` — so a small, polished class lives here to deliver on the
5
+ name. Construct from a finished pipeline run::
6
+
7
+ from citegraph import CitationGraph
8
+ g = CitationGraph.from_out_dir("./out")
9
+ g.n_core_works, g.n_works, g.n_edges
10
+ g.core # your own papers (ring 0)
11
+ g.core["Tema"] # a hand-curated annotation, when present
12
+ g.top_cited(n=10)
13
+ g.top_authors(n=10, ring=0) # most prominent authors of YOUR papers
14
+ g.core_citations() # who among your papers cites whom
15
+ g.cited_by("w-doe-2020-some-paper")
16
+ g.citers_of("w-smith-1968-tragedy-of-the-commons")
17
+
18
+ Or directly from a :class:`~citegraph.PipelineResult`::
19
+
20
+ result = pipe.run()
21
+ g = CitationGraph.from_pipeline_result(result)
22
+
23
+ Every bibliographic record is a *work* (``w-`` id), indexed by ``id`` in
24
+ ``works``. Two stored facts distinguish roles: ``ring`` (0 = seeded from
25
+ your PDFs — the *core*; n = first discovered in a ring n-1 bibliography)
26
+ and ``source_file`` (non-empty when we processed a PDF for it).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import itertools
32
+ from collections.abc import Iterable
33
+ from pathlib import Path
34
+ from typing import TYPE_CHECKING, Any
35
+
36
+ import pandas as pd
37
+
38
+ from citegraph.annotations import load_annotations
39
+ from citegraph.io import AUTHOR_CITATION_COLUMNS, AUTHOR_COLUMNS, OutLayout
40
+ from citegraph.schemas import PipelineResult
41
+
42
+ if TYPE_CHECKING: # pragma: no cover
43
+ import networkx as nx
44
+
45
+
46
+ def author_mentions(
47
+ edges: pd.DataFrame,
48
+ author_citations: pd.DataFrame,
49
+ *,
50
+ min_papers: int = 1,
51
+ citing_ids: Iterable[str] | None = None,
52
+ ) -> pd.DataFrame:
53
+ """Return the ``(citing_id, author_id)`` mentions behind a co-citation view.
54
+
55
+ One row per *bibliography that cites an author*, which is the atom both
56
+ :meth:`CitationGraph.author_cocitation_network` and the tie test in
57
+ :mod:`citegraph.cocitation_stats` are built from. A citing work that cites
58
+ several works by one author still contributes a single mention, so the
59
+ author's count is bibliographies-that-cite-them, never citations-received.
60
+
61
+ ``min_papers`` drops authors below that many citing bibliographies — the
62
+ same long-tail pruning the network applies — and ``citing_ids`` restricts
63
+ the citing side. It lives here, at module level, because two callers derive
64
+ different structures from the same three operations and a second
65
+ implementation of them would drift from this one.
66
+ """
67
+ pairs = edges[["citing_id", "cited_id"]].drop_duplicates()
68
+ if citing_ids is not None:
69
+ pairs = pairs[pairs["citing_id"].isin(set(citing_ids))]
70
+
71
+ work_authors = author_citations[["author_id", "record_id"]].drop_duplicates()
72
+ mentions = pairs.merge(work_authors, left_on="cited_id", right_on="record_id")[
73
+ ["citing_id", "author_id"]
74
+ ].drop_duplicates()
75
+
76
+ if min_papers > 1:
77
+ n_citing = mentions.groupby("author_id")["citing_id"].nunique()
78
+ keep = set(n_citing[n_citing >= min_papers].index)
79
+ mentions = mentions[mentions["author_id"].isin(keep)]
80
+ return mentions
81
+
82
+
83
+ def _no_annotations() -> pd.DataFrame:
84
+ return pd.DataFrame(index=pd.Index([], name="id"))
85
+
86
+
87
+ class CitationGraph:
88
+ """A read-only view over canonical works and citation edges."""
89
+
90
+ def __init__(
91
+ self,
92
+ works: pd.DataFrame,
93
+ edges: pd.DataFrame,
94
+ authors: pd.DataFrame | None = None,
95
+ author_citations: pd.DataFrame | None = None,
96
+ annotations: pd.DataFrame | None = None,
97
+ ) -> None:
98
+ # Accept both index-by-id (canonical) and id-as-column frames.
99
+ if "id" in works.columns:
100
+ works = works.set_index("id")
101
+ # Hand-curated columns join onto every ring view at once, so `core`
102
+ # and `ring(n)` carry them without a second frame to keep in step.
103
+ # Works nobody annotated keep a missing value; none are dropped.
104
+ self.annotations = annotations if annotations is not None else _no_annotations()
105
+ if not self.annotations.empty:
106
+ works = works.join(self.annotations)
107
+ self.works = works
108
+ self.edges = edges
109
+ # Author tables are optional — the citegraph authors stage may not
110
+ # have been run yet. Methods that need them check `has_authors`.
111
+ self._author_tables_loaded = authors is not None and author_citations is not None
112
+ self.authors = authors if authors is not None else pd.DataFrame()
113
+ self.author_citations = (
114
+ author_citations if author_citations is not None else pd.DataFrame()
115
+ )
116
+
117
+ # ------------------------------------------------------------------
118
+ # Constructors
119
+ # ------------------------------------------------------------------
120
+ @classmethod
121
+ def from_out_dir(cls, out_dir: str | Path) -> CitationGraph:
122
+ """Load the output CSVs from a finished pipeline's ``out_dir``.
123
+
124
+ ``authors.csv`` and ``author_citations.csv`` are loaded too when
125
+ present (i.e. after the ``citegraph authors`` stage has run).
126
+ Their absence is silent — the rest of the API still works. So is
127
+ ``work_annotations.csv``, whose hand-curated columns join onto
128
+ ``works``; a value it declares but does not satisfy raises.
129
+ """
130
+ layout = OutLayout(Path(out_dir))
131
+ if not layout.works_csv.exists() and (layout.out_dir / "papers.csv").exists():
132
+ raise FileNotFoundError(
133
+ f"{layout.works_csv} not found, but legacy papers.csv exists. "
134
+ "This out_dir predates the works model. Re-run the cheap "
135
+ "downstream stages (per-paper caches are reused): "
136
+ f"citegraph metadata --out {out_dir} && "
137
+ f"citegraph references --out {out_dir} && "
138
+ f"citegraph dedup --out {out_dir} && "
139
+ f"citegraph authors --out {out_dir}"
140
+ )
141
+ missing = [
142
+ p for p in (layout.works_csv, layout.graph_csv) if not p.exists()
143
+ ]
144
+ if missing:
145
+ raise FileNotFoundError(
146
+ "Missing output(s): "
147
+ + ", ".join(str(p) for p in missing)
148
+ + ". Run the pipeline first (`citegraph run ...`)."
149
+ )
150
+ def read_optional(path: Path, columns: list[str], index_col: str | None = None):
151
+ if not path.exists():
152
+ return None
153
+ try:
154
+ return pd.read_csv(path, index_col=index_col)
155
+ except pd.errors.EmptyDataError:
156
+ empty = pd.DataFrame(columns=columns)
157
+ return empty.set_index(index_col) if index_col else empty
158
+
159
+ authors_df = read_optional(layout.authors_csv, AUTHOR_COLUMNS, "id")
160
+ author_citations_df = read_optional(layout.author_citations_csv, AUTHOR_CITATION_COLUMNS)
161
+ works_df = pd.read_csv(layout.works_csv, index_col="id")
162
+ return cls(
163
+ works=works_df,
164
+ edges=pd.read_csv(layout.graph_csv),
165
+ authors=authors_df,
166
+ author_citations=author_citations_df,
167
+ annotations=load_annotations(
168
+ layout.work_annotations_csv,
169
+ works=works_df,
170
+ schema_path=layout.annotation_schema_csv,
171
+ ),
172
+ )
173
+
174
+ @classmethod
175
+ def from_pipeline_result(cls, result: PipelineResult) -> CitationGraph:
176
+ """Wrap the DataFrames returned by :meth:`Pipeline.run`."""
177
+ return cls(
178
+ works=result.works,
179
+ edges=result.graph,
180
+ authors=result.authors,
181
+ author_citations=result.author_citations,
182
+ )
183
+
184
+ # ------------------------------------------------------------------
185
+ # Counts
186
+ # ------------------------------------------------------------------
187
+ @property
188
+ def has_annotations(self) -> bool:
189
+ """Whether hand-curated ``work_annotations.csv`` columns are loaded."""
190
+ return not self.annotations.empty
191
+
192
+ @property
193
+ def n_works(self) -> int:
194
+ return len(self.works)
195
+
196
+ @property
197
+ def n_core_works(self) -> int:
198
+ return len(self.core)
199
+
200
+ @property
201
+ def n_edges(self) -> int:
202
+ return len(self.edges)
203
+
204
+ # ------------------------------------------------------------------
205
+ # Ring views
206
+ # ------------------------------------------------------------------
207
+ @property
208
+ def core(self) -> pd.DataFrame:
209
+ """The user's own papers: works at ring 0."""
210
+ if "ring" not in self.works.columns:
211
+ return self.works.iloc[0:0]
212
+ return self.works[self.works["ring"] == 0]
213
+
214
+ def ring(self, n: int) -> pd.DataFrame:
215
+ """Works at discovery depth ``n`` (0 = core)."""
216
+ if "ring" not in self.works.columns:
217
+ return self.works.iloc[0:0]
218
+ return self.works[self.works["ring"] == n]
219
+
220
+ def core_citations(self) -> pd.DataFrame:
221
+ """Edges where a core work cites another core work."""
222
+ ids = set(self.core.index)
223
+ return self.edges[
224
+ self.edges["citing_id"].isin(ids) & self.edges["cited_id"].isin(ids)
225
+ ].reset_index(drop=True)
226
+
227
+ # ------------------------------------------------------------------
228
+ # Queries
229
+ # ------------------------------------------------------------------
230
+ def cited_by(self, work_id: str) -> pd.DataFrame:
231
+ """Return the works cited by a given work.
232
+
233
+ Unknown ids return an empty DataFrame rather than raising —
234
+ calling code can branch on ``len(...)``.
235
+ """
236
+ cited_ids = self.edges.loc[self.edges["citing_id"] == work_id, "cited_id"]
237
+ return self.works.loc[self.works.index.isin(cited_ids)]
238
+
239
+ def citers_of(self, work_id: str) -> pd.DataFrame:
240
+ """Return the works that cite a given work."""
241
+ citing_ids = self.edges.loc[self.edges["cited_id"] == work_id, "citing_id"]
242
+ return self.works.loc[self.works.index.isin(citing_ids)]
243
+
244
+ def top_cited(self, n: int = 20) -> pd.DataFrame:
245
+ """Return the ``n`` most-cited works in this corpus.
246
+
247
+ The returned DataFrame is the works table filtered to the top
248
+ ``n`` rows and sorted by citation count (descending), with an
249
+ extra ``citation_count`` column. Counts the number of *distinct
250
+ citing works* (the edge list is already deduplicated by the
251
+ pipeline, so each ``(citing_id, cited_id)`` pair is one vote).
252
+ Core works cited within the corpus rank here too.
253
+ """
254
+ if self.edges.empty:
255
+ empty = self.works.iloc[0:0].copy()
256
+ empty["citation_count"] = pd.Series(dtype=int)
257
+ return empty
258
+ counts = self.edges.groupby("cited_id").size().sort_values(ascending=False)
259
+ top_ids = counts.head(n).index
260
+ out = self.works.loc[self.works.index.isin(top_ids)].copy()
261
+ out["citation_count"] = out.index.map(counts).astype(int)
262
+ return out.sort_values("citation_count", ascending=False)
263
+
264
+ # ------------------------------------------------------------------
265
+ # Authors
266
+ # ------------------------------------------------------------------
267
+ @property
268
+ def has_authors(self) -> bool:
269
+ """True when the author-normalization stage has been run."""
270
+ return self._author_tables_loaded
271
+
272
+ def _require_authors(self) -> None:
273
+ if not self.has_authors:
274
+ raise RuntimeError(
275
+ "Author tables are not loaded. Run `citegraph authors --out <dir>` "
276
+ "to produce authors.csv / author_citations.csv first."
277
+ )
278
+
279
+ def top_authors(self, n: int = 20, ring: int | None = None) -> pd.DataFrame:
280
+ """Authors ranked by distinct works authored, optionally one ring only.
281
+
282
+ ``ring=0`` answers "who are the most prominent authors of *my*
283
+ papers?". ``ring=None`` (default) spans the whole corpus. The
284
+ result carries an ``n_works_in_selection`` column with the count
285
+ that produced the ranking.
286
+ """
287
+ self._require_authors()
288
+ ac = self.author_citations
289
+ if ring is not None:
290
+ if "ring" not in self.works.columns:
291
+ ring_ids: set[str] = set()
292
+ else:
293
+ ring_ids = set(self.works.index[self.works["ring"] == ring])
294
+ ac = ac[ac["record_id"].isin(ring_ids)]
295
+ if ac.empty:
296
+ out = self.authors.iloc[0:0].copy()
297
+ out["n_works_in_selection"] = pd.Series(dtype=int)
298
+ return out
299
+ counts = ac.groupby("author_id")["record_id"].nunique().sort_values(ascending=False)
300
+ head = counts.head(n)
301
+ out = self.authors.loc[self.authors.index.isin(head.index)].copy()
302
+ out["n_works_in_selection"] = out.index.map(head).astype(int)
303
+ return out.sort_values("n_works_in_selection", ascending=False, kind="mergesort")
304
+
305
+ def top_cited_authors(self, n: int = 20) -> pd.DataFrame:
306
+ """Return the ``n`` authors receiving the most citations.
307
+
308
+ Ranked by ``n_citations_received`` — the number of citation
309
+ edges into any work the author appears on. This is the metric
310
+ users typically mean by "most cited author", and since core
311
+ works are citable it credits corpus-internal citations too.
312
+ """
313
+ self._require_authors()
314
+ return self.authors.sort_values("n_citations_received", ascending=False).head(n)
315
+
316
+ def find_author(self, query: str) -> pd.DataFrame:
317
+ """Return canonical authors whose surname or display name matches ``query``.
318
+
319
+ Matching is diacritic-insensitive and case-insensitive. Substring
320
+ match, anchored to nothing — "card" matches both "Cárdenas" and
321
+ "Cardinale". Useful for the user-facing "is this person in my
322
+ corpus?" lookup.
323
+ """
324
+ self._require_authors()
325
+ if not query:
326
+ return self.authors.iloc[0:0]
327
+ import unicodedata
328
+ def _fold(s: object) -> str:
329
+ if not isinstance(s, str):
330
+ return ""
331
+ return "".join(
332
+ c for c in unicodedata.normalize("NFD", s)
333
+ if unicodedata.category(c) != "Mn"
334
+ ).lower()
335
+ needle = _fold(query)
336
+ surnames = self.authors["surname_norm"].fillna("").map(_fold)
337
+ names = self.authors["display_name"].fillna("").map(_fold)
338
+ mask = surnames.str.contains(needle, regex=False) | names.str.contains(needle, regex=False)
339
+ return self.authors[mask].sort_values("n_citations_received", ascending=False)
340
+
341
+ def citations_of(self, author_id: str) -> pd.DataFrame:
342
+ """Return every cited work in which ``author_id`` appears.
343
+
344
+ Joins ``author_citations`` against ``works`` and includes the
345
+ citing-work ids so the caller can trace back to the source.
346
+ """
347
+ context = self.citation_context_for_author(author_id)
348
+ if context.empty:
349
+ out = self.works.iloc[0:0].copy()
350
+ out["citing_paper_ids"] = pd.Series(dtype=object)
351
+ out["n_citing_papers"] = pd.Series(dtype=int)
352
+ return out
353
+
354
+ out = self.works.loc[
355
+ self.works.index.isin(context["cited_reference_id"])
356
+ ].copy()
357
+ citing_ids = context.groupby("cited_reference_id")["citing_paper_id"].agg(
358
+ lambda s: sorted(set(s))
359
+ )
360
+ out["citing_paper_ids"] = out.index.map(citing_ids)
361
+ out["n_citing_papers"] = out["citing_paper_ids"].map(len)
362
+ return out
363
+
364
+ def papers_citing_author(self, author_id: str) -> pd.DataFrame:
365
+ """Return works that cite at least one work by ``author_id``."""
366
+ context = self.citation_context_for_author(author_id)
367
+ citing_ids = set(context["citing_paper_id"].dropna().unique())
368
+ return self.works.loc[self.works.index.isin(citing_ids)]
369
+
370
+ def citation_context_for_author(self, author_id: str) -> pd.DataFrame:
371
+ """Return citing-work context for every citation of works by ``author_id``.
372
+
373
+ The result is one row per ``citing work -> cited work`` edge where
374
+ the cited work has ``author_id`` among its canonical authors. This
375
+ is the audit table behind claims like "100 papers cited Juan Camilo
376
+ Cardenas"; citing-side journal fields come from ``works.csv``.
377
+ Cited core works count too — corpus-internal citations of an
378
+ author are part of their context.
379
+ """
380
+ self._require_authors()
381
+ columns = [
382
+ "author_id",
383
+ "author_display_name",
384
+ "raw_author",
385
+ "position",
386
+ "citing_paper_id",
387
+ "source_paper_title",
388
+ "source_paper_journal",
389
+ "source_paper_year",
390
+ "cited_reference_id",
391
+ "cited_reference_title",
392
+ "cited_reference_journal",
393
+ "cited_reference_year",
394
+ ]
395
+ ac = self.author_citations
396
+ author_refs = ac[ac["author_id"] == author_id].copy()
397
+ if author_refs.empty or self.edges.empty:
398
+ return pd.DataFrame(columns=columns)
399
+
400
+ author_refs = author_refs.rename(columns={"record_id": "cited_reference_id"})
401
+ edges = self.edges.rename(
402
+ columns={"citing_id": "citing_paper_id", "cited_id": "cited_reference_id"}
403
+ )
404
+ context = edges.merge(
405
+ author_refs[["author_id", "cited_reference_id", "position", "raw_author"]],
406
+ on="cited_reference_id",
407
+ how="inner",
408
+ )
409
+ if context.empty:
410
+ return pd.DataFrame(columns=columns)
411
+
412
+ citing_side = self.works.reset_index()
413
+ citing_side = citing_side.rename(
414
+ columns={
415
+ citing_side.columns[0]: "citing_paper_id",
416
+ "Title": "source_paper_title",
417
+ "Journal": "source_paper_journal",
418
+ "Year": "source_paper_year",
419
+ }
420
+ )
421
+ for col in ("source_paper_title", "source_paper_journal", "source_paper_year"):
422
+ if col not in citing_side.columns:
423
+ citing_side[col] = pd.NA
424
+
425
+ cited_side = self.works.reset_index()
426
+ cited_side = cited_side.rename(
427
+ columns={
428
+ cited_side.columns[0]: "cited_reference_id",
429
+ "Title": "cited_reference_title",
430
+ "Journal": "cited_reference_journal",
431
+ "Year": "cited_reference_year",
432
+ }
433
+ )
434
+ for col in (
435
+ "cited_reference_title",
436
+ "cited_reference_journal",
437
+ "cited_reference_year",
438
+ ):
439
+ if col not in cited_side.columns:
440
+ cited_side[col] = pd.NA
441
+
442
+ author_display = (
443
+ self.authors.loc[author_id, "display_name"]
444
+ if author_id in self.authors.index and "display_name" in self.authors.columns
445
+ else ""
446
+ )
447
+ context["author_display_name"] = author_display
448
+ context = context.merge(
449
+ citing_side[
450
+ [
451
+ "citing_paper_id",
452
+ "source_paper_title",
453
+ "source_paper_journal",
454
+ "source_paper_year",
455
+ ]
456
+ ],
457
+ on="citing_paper_id",
458
+ how="left",
459
+ ).merge(
460
+ cited_side[
461
+ [
462
+ "cited_reference_id",
463
+ "cited_reference_title",
464
+ "cited_reference_journal",
465
+ "cited_reference_year",
466
+ ]
467
+ ],
468
+ on="cited_reference_id",
469
+ how="left",
470
+ )
471
+ return context[columns].sort_values(
472
+ ["citing_paper_id", "cited_reference_id", "position"]
473
+ ).reset_index(drop=True)
474
+
475
+ def citing_papers_by_author(self, author_id: str) -> pd.DataFrame:
476
+ """Return distinct works that cite at least one work by ``author_id``.
477
+
478
+ One citing work can cite several works by the same author; it
479
+ still appears once here, with ``n_cited_references_by_author`` and the
480
+ cited work ids/titles preserving the evidence.
481
+ """
482
+ context = self.citation_context_for_author(author_id)
483
+ columns = [
484
+ "paper_id",
485
+ "source_paper_title",
486
+ "source_paper_journal",
487
+ "source_paper_year",
488
+ "author_id",
489
+ "author_display_name",
490
+ "n_cited_references_by_author",
491
+ "cited_reference_ids",
492
+ "cited_reference_titles",
493
+ ]
494
+ if context.empty:
495
+ return pd.DataFrame(columns=columns)
496
+
497
+ grouped = (
498
+ context.groupby(
499
+ [
500
+ "citing_paper_id",
501
+ "source_paper_title",
502
+ "source_paper_journal",
503
+ "source_paper_year",
504
+ "author_id",
505
+ "author_display_name",
506
+ ],
507
+ dropna=False,
508
+ )
509
+ .agg(
510
+ n_cited_references_by_author=("cited_reference_id", "nunique"),
511
+ cited_reference_ids=(
512
+ "cited_reference_id",
513
+ lambda s: sorted(set(s)),
514
+ ),
515
+ cited_reference_titles=(
516
+ "cited_reference_title",
517
+ lambda s: list(dict.fromkeys(s)),
518
+ ),
519
+ )
520
+ .reset_index()
521
+ .rename(columns={"citing_paper_id": "paper_id"})
522
+ )
523
+ return grouped[columns].sort_values(
524
+ ["n_cited_references_by_author", "paper_id"],
525
+ ascending=[False, True],
526
+ ).reset_index(drop=True)
527
+
528
+ def source_journals_citing_author(self, author_id: str) -> pd.DataFrame:
529
+ """Count citing-work journals among works that cite ``author_id``."""
530
+ papers = self.citing_papers_by_author(author_id)
531
+ columns = ["source_paper_journal", "n_papers", "share_of_papers"]
532
+ if papers.empty:
533
+ return pd.DataFrame(columns=columns)
534
+
535
+ journal_series = (
536
+ papers["source_paper_journal"]
537
+ .replace("", pd.NA)
538
+ .fillna("(unknown)")
539
+ )
540
+ counts = (
541
+ journal_series.value_counts()
542
+ .rename_axis("source_paper_journal")
543
+ .reset_index(name="n_papers")
544
+ )
545
+ counts["share_of_papers"] = counts["n_papers"] / len(papers)
546
+ return counts[columns].sort_values(
547
+ ["n_papers", "source_paper_journal"],
548
+ ascending=[False, True],
549
+ ).reset_index(drop=True)
550
+
551
+ # ------------------------------------------------------------------
552
+ # Export
553
+ # ------------------------------------------------------------------
554
+ # ------------------------------------------------------------------
555
+ # Author co-citation network
556
+ # ------------------------------------------------------------------
557
+ def author_cocitation_network(
558
+ self,
559
+ min_papers: int = 3,
560
+ citing_ids: Iterable[str] | None = None,
561
+ ) -> nx.Graph:
562
+ """Return the author co-citation network as a weighted ``networkx.Graph``.
563
+
564
+ Two authors are joined when the same bibliography cites both of
565
+ them, and the edge ``weight`` counts how many bibliographies do
566
+ so. This is the standard author co-citation projection of
567
+ bibliometrics (White & Griffith 1981; White & McCain 1998).
568
+
569
+ It exists because the raw work-level graph is one hop deep: only
570
+ works we processed a PDF for have outgoing edges, so path-based
571
+ centrality over :meth:`to_networkx` would measure the sampling
572
+ design rather than the literature. The co-citation projection is
573
+ a genuine one-mode network, so degree, eigenvector and
574
+ betweenness centrality are all well defined on it.
575
+
576
+ A citing work that cites several works by one author still
577
+ contributes a single mention of that author, and never a
578
+ self-loop.
579
+
580
+ ``min_papers`` drops authors cited by fewer than that many
581
+ citing works, which prunes the long tail of once-cited names.
582
+ ``citing_ids`` restricts the citing side, which is how you build
583
+ a variant that excludes a given author's own papers.
584
+
585
+ Requires ``networkx`` (not a default dependency) and the author
586
+ tables (run ``citegraph authors``). Nodes carry ``display_name``
587
+ and ``n_citing_papers``; nodes are inserted in sorted order so
588
+ that seeded layouts are reproducible across runs.
589
+ """
590
+ self._require_authors()
591
+ try:
592
+ import networkx as nx
593
+ except ImportError as exc: # pragma: no cover - import guard
594
+ raise ImportError(
595
+ "author_cocitation_network() requires networkx. "
596
+ "Install with: pip install networkx"
597
+ ) from exc
598
+
599
+ # One mention per (citing work, cited author): citing three works
600
+ # by the same author is one appearance of that author, not three.
601
+ mentions = author_mentions(
602
+ self.edges,
603
+ self.author_citations,
604
+ min_papers=min_papers,
605
+ citing_ids=citing_ids,
606
+ )
607
+ keep = mentions.groupby("author_id")["citing_id"].nunique()
608
+
609
+ names = (
610
+ self.authors["display_name"]
611
+ if "display_name" in self.authors.columns
612
+ else pd.Series(dtype=object)
613
+ )
614
+
615
+ g: nx.Graph = nx.Graph()
616
+ for author_id in sorted(keep.index):
617
+ g.add_node(
618
+ author_id,
619
+ display_name=names.get(author_id, author_id),
620
+ n_citing_papers=int(keep[author_id]),
621
+ )
622
+ for _, group in mentions.groupby("citing_id"):
623
+ for a, b in itertools.combinations(sorted(group["author_id"]), 2):
624
+ if g.has_edge(a, b):
625
+ g[a][b]["weight"] += 1
626
+ else:
627
+ g.add_edge(a, b, weight=1)
628
+ return g
629
+
630
+ def to_networkx(self) -> nx.DiGraph:
631
+ """Return a ``networkx.DiGraph``: nodes are works, edges go citing → cited.
632
+
633
+ Requires ``networkx`` (not a default dependency). Each node
634
+ carries the work's row metadata as attributes — including
635
+ ``ring`` and ``source_file`` where present.
636
+ """
637
+ try:
638
+ import networkx as nx
639
+ except ImportError as exc: # pragma: no cover - import guard
640
+ raise ImportError(
641
+ "to_networkx() requires networkx. Install with: pip install networkx"
642
+ ) from exc
643
+
644
+ g: nx.DiGraph = nx.DiGraph()
645
+ for work_id, row in self.works.iterrows():
646
+ attrs: dict[str, Any] = dict(row.items())
647
+ g.add_node(work_id, **attrs)
648
+ for _, edge in self.edges.iterrows():
649
+ g.add_edge(edge["citing_id"], edge["cited_id"])
650
+ return g
651
+
652
+ # ------------------------------------------------------------------
653
+ # Display
654
+ # ------------------------------------------------------------------
655
+ def __repr__(self) -> str:
656
+ author_suffix = f", {len(self.authors)} authors" if self.has_authors else ""
657
+ return (
658
+ f"<CitationGraph: {self.n_core_works} core works, "
659
+ f"{self.n_works} works, {self.n_edges} edges{author_suffix}>"
660
+ )