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/__init__.py +44 -0
- citegraph/_progress.py +74 -0
- citegraph/annotations.py +183 -0
- citegraph/author_export.py +294 -0
- citegraph/author_overrides.py +143 -0
- citegraph/authors.py +1997 -0
- citegraph/cli.py +926 -0
- citegraph/cocitation_stats.py +370 -0
- citegraph/config.py +73 -0
- citegraph/cost_estimation.py +259 -0
- citegraph/dedup.py +498 -0
- citegraph/enrich.py +972 -0
- citegraph/extract_metadata.py +81 -0
- citegraph/extract_references.py +376 -0
- citegraph/graph.py +660 -0
- citegraph/html_report.py +1787 -0
- citegraph/identity.py +199 -0
- citegraph/ids.py +106 -0
- citegraph/io.py +566 -0
- citegraph/jel.py +167 -0
- citegraph/journals.py +182 -0
- citegraph/llm.py +186 -0
- citegraph/pdf_to_markdown.py +394 -0
- citegraph/pipeline.py +997 -0
- citegraph/plotting.py +1214 -0
- citegraph/reports.py +214 -0
- citegraph/schemas.py +89 -0
- citegraph/webui.py +624 -0
- citegraph-0.2.0.dist-info/METADATA +870 -0
- citegraph-0.2.0.dist-info/RECORD +33 -0
- citegraph-0.2.0.dist-info/WHEEL +4 -0
- citegraph-0.2.0.dist-info/entry_points.txt +2 -0
- citegraph-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
+
)
|