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/dedup.py
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""Fuzzy canonicalization of bibliographic works.
|
|
2
|
+
|
|
3
|
+
Cleaned-up port of the algorithm originally in ``label_papers_old.py``.
|
|
4
|
+
We compute a weighted similarity over title / authors / journal and then
|
|
5
|
+
perform a single-pass clustering: each row is compared against the
|
|
6
|
+
representatives of existing clusters; the first cluster that scores above
|
|
7
|
+
the threshold (and has a year within ``year_window``) wins. Otherwise the
|
|
8
|
+
row starts a new cluster.
|
|
9
|
+
|
|
10
|
+
Usage
|
|
11
|
+
-----
|
|
12
|
+
|
|
13
|
+
>>> from citegraph.dedup import canonicalize_works, DedupConfig
|
|
14
|
+
>>> works, edges, stats = canonicalize_works(sources, citations_raw, DedupConfig())
|
|
15
|
+
|
|
16
|
+
``works`` is a canonical DataFrame indexed by stable ``w-`` work ids with
|
|
17
|
+
``ring``/``source_file`` provenance columns; ``edges`` is the deduplicated
|
|
18
|
+
``citing_id``/``cited_id`` table; ``stats`` reports merges, dropped
|
|
19
|
+
self-loops, and the per-citation-row cluster mapping.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import re
|
|
26
|
+
import unicodedata
|
|
27
|
+
from collections import defaultdict
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
import pandas as pd
|
|
32
|
+
from rapidfuzz.fuzz import ratio, token_set_ratio
|
|
33
|
+
|
|
34
|
+
from citegraph._progress import iter_with_progress
|
|
35
|
+
from citegraph.ids import _first_author_token, make_work_id
|
|
36
|
+
from citegraph.io import parse_authors_list, require_columns
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_NON_ALNUM = re.compile(r"[^\w\s]", re.UNICODE)
|
|
42
|
+
_WS = re.compile(r"\s+")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_text(text: object) -> str:
|
|
46
|
+
"""Lowercase, strip, and remove non-alphanumeric characters.
|
|
47
|
+
|
|
48
|
+
Lists of authors are joined with commas before normalisation.
|
|
49
|
+
"""
|
|
50
|
+
if isinstance(text, list):
|
|
51
|
+
text = ", ".join(str(t) for t in text)
|
|
52
|
+
if not isinstance(text, str):
|
|
53
|
+
return ""
|
|
54
|
+
text = unicodedata.normalize("NFKD", text)
|
|
55
|
+
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
|
56
|
+
text = _NON_ALNUM.sub(" ", text.casefold())
|
|
57
|
+
return _WS.sub(" ", text).strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class DedupConfig:
|
|
62
|
+
"""Tunable thresholds for fuzzy deduplication."""
|
|
63
|
+
|
|
64
|
+
title_weight: float = 0.65
|
|
65
|
+
authors_weight: float = 0.25
|
|
66
|
+
journal_weight: float = 0.10
|
|
67
|
+
year_window: int = 1
|
|
68
|
+
threshold: float = 85.0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _read_year(value: object) -> tuple[int | None, bool]:
|
|
72
|
+
"""Coerce ``value`` to a year for the dedup year predicate.
|
|
73
|
+
|
|
74
|
+
Returns ``(year, parsed_ok)``:
|
|
75
|
+
|
|
76
|
+
- ``(None, True)`` — the value is *missing* (None / "" / sentinel ``0`` /
|
|
77
|
+
non-positive). Callers should not use the year window to reject the
|
|
78
|
+
cluster in this case; the title/authors fuzzy score still has to clear
|
|
79
|
+
the threshold, which is what carries the matching decision.
|
|
80
|
+
- ``(int, True)`` — a usable year.
|
|
81
|
+
- ``(None, False)`` — the value was non-empty but couldn't be parsed
|
|
82
|
+
(e.g. ``"abc"``). Callers should fail closed in this case, preserving
|
|
83
|
+
the original conservative behaviour for corrupt data.
|
|
84
|
+
"""
|
|
85
|
+
if value is None or pd.isna(value) or value == "":
|
|
86
|
+
return None, True
|
|
87
|
+
try:
|
|
88
|
+
year = int(value)
|
|
89
|
+
except (TypeError, ValueError, OverflowError):
|
|
90
|
+
return None, False
|
|
91
|
+
if year <= 0:
|
|
92
|
+
return None, True
|
|
93
|
+
return year, True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def compare_papers(paper1: dict, paper2: dict, cfg: DedupConfig) -> bool:
|
|
97
|
+
"""Return ``True`` if two records are likely the same paper.
|
|
98
|
+
|
|
99
|
+
Each record is a dict with at least ``Title``, ``Authors``, ``Journal``
|
|
100
|
+
and ``Year`` keys (the same shape returned by Gemini extraction).
|
|
101
|
+
|
|
102
|
+
The year predicate is *tolerant* of missing data (the schema's sentinel
|
|
103
|
+
``Year=0``, ``None``, or empty string): if either side is missing, we
|
|
104
|
+
decline to reject on year and let title/authors carry the decision.
|
|
105
|
+
Genuinely unparseable values (e.g. a stray non-numeric string) still
|
|
106
|
+
fail closed — see :func:`_read_year`.
|
|
107
|
+
"""
|
|
108
|
+
return _assess_work_match(paper1, paper2, cfg)["decision"] == "match"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _normalize_work_record(record: dict) -> dict:
|
|
112
|
+
"""Normalize representations without changing bibliographic display values."""
|
|
113
|
+
out = dict(record)
|
|
114
|
+
authors = parse_authors_list(record.get("Authors_List"))
|
|
115
|
+
if authors and not isinstance(record.get("Authors"), str):
|
|
116
|
+
out["Authors"] = ", ".join(authors)
|
|
117
|
+
elif authors and not record.get("Authors", "").strip():
|
|
118
|
+
out["Authors"] = ", ".join(authors)
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _assess_work_match(paper1: dict, paper2: dict, cfg: DedupConfig) -> dict:
|
|
123
|
+
"""Score a pair, retaining reasons when strong fuzzy evidence is unsafe."""
|
|
124
|
+
paper1, paper2 = _normalize_work_record(paper1), _normalize_work_record(paper2)
|
|
125
|
+
title1 = normalize_text(paper1.get("Title"))
|
|
126
|
+
title2 = normalize_text(paper2.get("Title"))
|
|
127
|
+
raw_title1 = paper1.get("Title") if isinstance(paper1.get("Title"), str) else ""
|
|
128
|
+
raw_title2 = paper2.get("Title") if isinstance(paper2.get("Title"), str) else ""
|
|
129
|
+
subtitle_shortening = False
|
|
130
|
+
for raw_left, raw_right in ((raw_title1, raw_title2), (raw_title2, raw_title1)):
|
|
131
|
+
if ":" in raw_left or "—" in raw_left:
|
|
132
|
+
prefix = raw_left.replace("—", ":").split(":", 1)[0]
|
|
133
|
+
subtitle_shortening |= _title_without_leading_article(normalize_text(prefix)) == _title_without_leading_article(normalize_text(raw_right))
|
|
134
|
+
title_score = token_set_ratio(
|
|
135
|
+
_title_without_leading_article(title1), _title_without_leading_article(title2)
|
|
136
|
+
)
|
|
137
|
+
authors_score = token_set_ratio(
|
|
138
|
+
normalize_text(paper1.get("Authors")), normalize_text(paper2.get("Authors"))
|
|
139
|
+
)
|
|
140
|
+
journal_score = token_set_ratio(
|
|
141
|
+
normalize_text(paper1.get("Journal")), normalize_text(paper2.get("Journal"))
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
weighted = (
|
|
145
|
+
title_score * cfg.title_weight
|
|
146
|
+
+ authors_score * cfg.authors_weight
|
|
147
|
+
+ journal_score * cfg.journal_weight
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
y1, ok1 = _read_year(paper1.get("Year"))
|
|
151
|
+
y2, ok2 = _read_year(paper2.get("Year"))
|
|
152
|
+
if not ok1 or not ok2:
|
|
153
|
+
year_ok = False # fail closed on unparseable data
|
|
154
|
+
elif y1 is None or y2 is None:
|
|
155
|
+
year_ok = True # at least one side missing -> rely on title/authors
|
|
156
|
+
else:
|
|
157
|
+
year_ok = abs(y1 - y2) <= cfg.year_window
|
|
158
|
+
|
|
159
|
+
reasons = _title_conflict_reasons(title1, title2, subtitle_shortening=subtitle_shortening)
|
|
160
|
+
if not year_ok:
|
|
161
|
+
decision, reasons = "reject", ["invalid_year" if not ok1 or not ok2 else "year_window"]
|
|
162
|
+
elif weighted < cfg.threshold:
|
|
163
|
+
decision, reasons = "reject", ["below_threshold"]
|
|
164
|
+
elif reasons:
|
|
165
|
+
decision = "review"
|
|
166
|
+
else:
|
|
167
|
+
decision, reasons = "match", ["compatible_metadata"]
|
|
168
|
+
return dict(decision=decision, reason_codes=reasons, title_score=title_score,
|
|
169
|
+
title_full_score=ratio(title1, title2), authors_score=authors_score,
|
|
170
|
+
journal_score=journal_score, weighted_score=weighted, year_ok=year_ok)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
_LEADING_ARTICLES = {"a", "an", "the"}
|
|
174
|
+
_NEGATION_WORDS = {"no", "not", "without", "non"}
|
|
175
|
+
_PART_MARKER = re.compile(r"^(part|volume|vol|book)$")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _title_without_leading_article(title: str) -> str:
|
|
179
|
+
words = title.split()
|
|
180
|
+
return " ".join(words[1:] if words and words[0] in _LEADING_ARTICLES else words)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _title_conflict_reasons(left: str, right: str, *, subtitle_shortening: bool = False) -> list[str]:
|
|
184
|
+
"""Guard high-scoring containment matches that change work identity."""
|
|
185
|
+
lw = _title_without_leading_article(left).split()
|
|
186
|
+
rw = _title_without_leading_article(right).split()
|
|
187
|
+
reasons = []
|
|
188
|
+
if (set(lw) & _NEGATION_WORDS) != (set(rw) & _NEGATION_WORDS):
|
|
189
|
+
reasons.append("negation_conflict")
|
|
190
|
+
def marker(words: list[str]) -> tuple[str, str] | None:
|
|
191
|
+
for i, word in enumerate(words[:-1]):
|
|
192
|
+
if _PART_MARKER.match(word) and re.fullmatch(r"[ivxlcdm]+|\d+", words[i + 1]):
|
|
193
|
+
return word, words[i + 1]
|
|
194
|
+
return None
|
|
195
|
+
lm, rm = marker(lw), marker(rw)
|
|
196
|
+
if lm != rm and (lm or rm):
|
|
197
|
+
reasons.append("part_conflict")
|
|
198
|
+
# Explicit subtitle omission may explain containment, but cannot explain
|
|
199
|
+
# away the protected conflicts above. No semantic equivalence is inferred.
|
|
200
|
+
if not subtitle_shortening and (set(lw) < set(rw) or set(rw) < set(lw)):
|
|
201
|
+
reasons.append("title_expansion")
|
|
202
|
+
return reasons
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _row_to_dict(row: pd.Series) -> dict:
|
|
206
|
+
# Authors_List is preserved alongside the comma-joined Authors so that
|
|
207
|
+
# downstream stages (author normalization in particular) can recover
|
|
208
|
+
# individual author strings without having to split the joined form —
|
|
209
|
+
# comma-splitting "Smith, J., García, A." would mistake each initial
|
|
210
|
+
# for its own author.
|
|
211
|
+
authors_list = parse_authors_list(row.get("Authors_List"))
|
|
212
|
+
authors = row.get("Authors")
|
|
213
|
+
if (not isinstance(authors, str) or not authors.strip()) and isinstance(authors_list, list):
|
|
214
|
+
authors = "; ".join(str(a) for a in authors_list)
|
|
215
|
+
return {
|
|
216
|
+
"Authors": authors,
|
|
217
|
+
"Authors_List": row.get("Authors_List"),
|
|
218
|
+
"Journal": row.get("Journal"),
|
|
219
|
+
"Title": row.get("Title"),
|
|
220
|
+
"Year": row.get("Year"),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _title_block_key(title: object) -> str:
|
|
225
|
+
words = _title_without_leading_article(normalize_text(title)).split()
|
|
226
|
+
return " ".join(words[:6])
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _author_block_key(row: pd.Series) -> str:
|
|
230
|
+
authors_list = parse_authors_list(row.get("Authors_List"))
|
|
231
|
+
value = authors_list or row.get("Authors", "")
|
|
232
|
+
def strip_accents(text: str) -> str:
|
|
233
|
+
return "".join(c for c in unicodedata.normalize("NFKD", text) if not unicodedata.combining(c))
|
|
234
|
+
if isinstance(value, list):
|
|
235
|
+
value = [strip_accents(a) for a in value]
|
|
236
|
+
elif isinstance(value, str):
|
|
237
|
+
value = strip_accents(value)
|
|
238
|
+
return _first_author_token(value)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _title_block_keys(title: object) -> set[str]:
|
|
242
|
+
full = _title_without_leading_article(normalize_text(title))
|
|
243
|
+
if not full:
|
|
244
|
+
return set()
|
|
245
|
+
words = full.split()
|
|
246
|
+
return {"full:" + full, "prefix:" + " ".join(words[:6]),
|
|
247
|
+
"prefix4:" + " ".join(words[:4]),
|
|
248
|
+
"tokens:" + " ".join(sorted(set(full.split())))}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _years_can_match(a: object, b: object, cfg: DedupConfig) -> bool:
|
|
252
|
+
y1, ok1 = _read_year(a)
|
|
253
|
+
y2, ok2 = _read_year(b)
|
|
254
|
+
if not ok1 or not ok2:
|
|
255
|
+
return False
|
|
256
|
+
if y1 is None or y2 is None:
|
|
257
|
+
return True
|
|
258
|
+
return abs(y1 - y2) <= cfg.year_window
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _candidate_index_lookup(df: pd.DataFrame) -> tuple[dict[str, set[int]], dict[str, set[int]], set[int]]:
|
|
262
|
+
author_blocks: dict[str, set[int]] = defaultdict(set)
|
|
263
|
+
title_blocks: dict[str, set[int]] = defaultdict(set)
|
|
264
|
+
unknown_author: set[int] = set()
|
|
265
|
+
|
|
266
|
+
for idx, row in df.iterrows():
|
|
267
|
+
author_key = _author_block_key(row)
|
|
268
|
+
author_blocks[author_key].add(idx)
|
|
269
|
+
if author_key == "unknown":
|
|
270
|
+
unknown_author.add(idx)
|
|
271
|
+
for title_key in _title_block_keys(row.get("Title")):
|
|
272
|
+
title_blocks[title_key].add(idx)
|
|
273
|
+
return author_blocks, title_blocks, unknown_author
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _candidate_indices(
|
|
277
|
+
row: pd.Series,
|
|
278
|
+
*,
|
|
279
|
+
author_blocks: dict[str, set[int]],
|
|
280
|
+
title_blocks: dict[str, set[int]],
|
|
281
|
+
unknown_author: set[int],
|
|
282
|
+
) -> set[int]:
|
|
283
|
+
author_key = _author_block_key(row)
|
|
284
|
+
if author_key == "unknown":
|
|
285
|
+
# Symmetric with the known-author side's inclusion of unknown rows.
|
|
286
|
+
return set().union(*author_blocks.values()) if author_blocks else set()
|
|
287
|
+
candidates = set(author_blocks.get(author_key, set())) | unknown_author
|
|
288
|
+
for title_key in _title_block_keys(row.get("Title")):
|
|
289
|
+
candidates.update(title_blocks.get(title_key, set()))
|
|
290
|
+
return candidates
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _cluster_rows(
|
|
294
|
+
df: pd.DataFrame,
|
|
295
|
+
cfg: DedupConfig,
|
|
296
|
+
make_cluster_id: Callable[[int, pd.Series], str],
|
|
297
|
+
*,
|
|
298
|
+
show_progress: bool,
|
|
299
|
+
description: str,
|
|
300
|
+
decisions: list[dict] | None = None,
|
|
301
|
+
collisions: list[dict] | None = None,
|
|
302
|
+
) -> tuple[list[str], list[tuple[str, dict]]]:
|
|
303
|
+
"""Single-pass blocked clustering shared by dedup and canonicalization.
|
|
304
|
+
|
|
305
|
+
Returns the per-row cluster id list and ``(cluster_id, representative
|
|
306
|
+
dict)`` pairs in first-seen order. The representative is always the
|
|
307
|
+
*first* member of its cluster.
|
|
308
|
+
"""
|
|
309
|
+
cluster_ids: list[str | None] = [None] * len(df)
|
|
310
|
+
representatives: list[tuple[str, dict]] = []
|
|
311
|
+
used_ids: set[str] = set()
|
|
312
|
+
author_blocks, title_blocks, unknown_author = _candidate_index_lookup(df)
|
|
313
|
+
|
|
314
|
+
for i in iter_with_progress(
|
|
315
|
+
list(range(len(df))),
|
|
316
|
+
show_progress=show_progress,
|
|
317
|
+
description=description,
|
|
318
|
+
item_label=lambda idx: f"row {idx + 1}/{len(df)}",
|
|
319
|
+
):
|
|
320
|
+
if cluster_ids[i] is not None:
|
|
321
|
+
continue
|
|
322
|
+
paper_i = _row_to_dict(df.iloc[i])
|
|
323
|
+
cluster_id = make_cluster_id(i, df.iloc[i])
|
|
324
|
+
# Distinct works can slug to the same id (e.g. two untitled citations
|
|
325
|
+
# by the same first author and year); downstream tables join by id,
|
|
326
|
+
# so collisions get a deterministic -2/-3… suffix like author ids do.
|
|
327
|
+
if cluster_id in used_ids:
|
|
328
|
+
n = 2
|
|
329
|
+
while f"{cluster_id}-{n}" in used_ids:
|
|
330
|
+
n += 1
|
|
331
|
+
new_id = f"{cluster_id}-{n}"
|
|
332
|
+
if collisions is not None:
|
|
333
|
+
collisions.append({"row_index": i, "base_id": cluster_id, "assigned_id": new_id})
|
|
334
|
+
cluster_id = new_id
|
|
335
|
+
used_ids.add(cluster_id)
|
|
336
|
+
cluster_ids[i] = cluster_id
|
|
337
|
+
representatives.append((cluster_id, paper_i))
|
|
338
|
+
|
|
339
|
+
candidate_js = _candidate_indices(
|
|
340
|
+
df.iloc[i],
|
|
341
|
+
author_blocks=author_blocks,
|
|
342
|
+
title_blocks=title_blocks,
|
|
343
|
+
unknown_author=unknown_author,
|
|
344
|
+
)
|
|
345
|
+
for j in sorted(idx for idx in candidate_js if idx > i):
|
|
346
|
+
if cluster_ids[j] is not None:
|
|
347
|
+
continue
|
|
348
|
+
if not _years_can_match(df.iloc[i].get("Year"), df.iloc[j].get("Year"), cfg):
|
|
349
|
+
continue
|
|
350
|
+
assessment = _assess_work_match(paper_i, _row_to_dict(df.iloc[j]), cfg)
|
|
351
|
+
if decisions is not None and assessment["decision"] != "reject":
|
|
352
|
+
decisions.append({"left_index": i, "right_index": j,
|
|
353
|
+
"representative_id": cluster_id, **assessment})
|
|
354
|
+
if assessment["decision"] == "match":
|
|
355
|
+
cluster_ids[j] = cluster_id
|
|
356
|
+
# Every entry is filled: each row either joined a cluster or started one.
|
|
357
|
+
return cluster_ids, representatives # type: ignore[return-value]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _compute_rings(ring0_ids: set[str], edges: pd.DataFrame) -> dict[str, int]:
|
|
361
|
+
"""BFS discovery depth from the ring-0 seed set over citation edges."""
|
|
362
|
+
rings = {wid: 0 for wid in ring0_ids}
|
|
363
|
+
out_edges: dict[str, set[str]] = defaultdict(set)
|
|
364
|
+
for citing, cited in zip(edges["citing_id"], edges["cited_id"], strict=False):
|
|
365
|
+
out_edges[str(citing)].add(str(cited))
|
|
366
|
+
frontier = set(ring0_ids)
|
|
367
|
+
depth = 0
|
|
368
|
+
while frontier:
|
|
369
|
+
depth += 1
|
|
370
|
+
nxt = {c for w in frontier for c in out_edges.get(w, ()) if c not in rings}
|
|
371
|
+
for c in nxt:
|
|
372
|
+
rings[c] = depth
|
|
373
|
+
frontier = nxt
|
|
374
|
+
return rings
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
_WORK_COLUMNS = ["ring", "source_file", "Title", "Authors", "Authors_List", "Journal", "Year"]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def canonicalize_works(
|
|
381
|
+
sources: pd.DataFrame,
|
|
382
|
+
citations_raw: pd.DataFrame,
|
|
383
|
+
cfg: DedupConfig | None = None,
|
|
384
|
+
*,
|
|
385
|
+
show_progress: bool = True,
|
|
386
|
+
) -> tuple[pd.DataFrame, pd.DataFrame, dict]:
|
|
387
|
+
"""Cluster sources + raw citations into canonical works.
|
|
388
|
+
|
|
389
|
+
Sources occupy the head of the combined frame, so the single-pass
|
|
390
|
+
first-match-wins loop guarantees: (a) two matching sources merge
|
|
391
|
+
(duplicate PDFs), (b) a citation matching a source becomes that
|
|
392
|
+
work, and (c) every cluster representative — hence its metadata —
|
|
393
|
+
is the full-text side whenever one exists.
|
|
394
|
+
|
|
395
|
+
Returns ``(works, edges, stats)``: ``works`` is indexed by ``id``
|
|
396
|
+
with ``ring``/``source_file`` ahead of the bibliographic columns;
|
|
397
|
+
``edges`` is the deduplicated ``citing_id``/``cited_id`` table with
|
|
398
|
+
self-loops removed; ``stats`` counts what was dropped or merged.
|
|
399
|
+
"""
|
|
400
|
+
cfg = cfg or DedupConfig()
|
|
401
|
+
require_columns(sources, ["id", "source_file", "Title", "Year"], artifact="sources")
|
|
402
|
+
require_columns(citations_raw, ["Title", "Year", "citing_id"], artifact="citations_raw")
|
|
403
|
+
for frame, name in ((sources, "sources"), (citations_raw, "citations_raw")):
|
|
404
|
+
if "Authors" not in frame.columns and "Authors_List" not in frame.columns:
|
|
405
|
+
raise ValueError(f"{name} is missing required column: Authors or Authors_List")
|
|
406
|
+
|
|
407
|
+
src = sources.reset_index(drop=True)
|
|
408
|
+
cit = citations_raw.reset_index(drop=True)
|
|
409
|
+
n_src = len(src)
|
|
410
|
+
|
|
411
|
+
if not n_src and cit.empty:
|
|
412
|
+
empty_works = pd.DataFrame(columns=_WORK_COLUMNS)
|
|
413
|
+
empty_works.index.name = "id"
|
|
414
|
+
empty_edges = pd.DataFrame(columns=["citing_id", "cited_id"])
|
|
415
|
+
return empty_works, empty_edges, {
|
|
416
|
+
"n_self_loops_dropped": 0,
|
|
417
|
+
"n_source_duplicates_merged": 0,
|
|
418
|
+
"source_cluster_ids": [],
|
|
419
|
+
"citation_cluster_ids": [],
|
|
420
|
+
"decisions": [],
|
|
421
|
+
"id_collisions": [],
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
combined = pd.concat([src, cit], ignore_index=True, sort=False)
|
|
425
|
+
|
|
426
|
+
def _cluster_id(i: int, row: pd.Series) -> str:
|
|
427
|
+
if i < n_src:
|
|
428
|
+
return str(row["id"])
|
|
429
|
+
return make_work_id(
|
|
430
|
+
row.get("Authors_List") or row.get("Authors", ""),
|
|
431
|
+
row.get("Year"),
|
|
432
|
+
row.get("Title", ""),
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
decisions: list[dict] = []
|
|
436
|
+
collisions: list[dict] = []
|
|
437
|
+
cluster_ids, representatives = _cluster_rows(
|
|
438
|
+
combined,
|
|
439
|
+
cfg,
|
|
440
|
+
_cluster_id,
|
|
441
|
+
show_progress=show_progress,
|
|
442
|
+
description="Canonicalizing works",
|
|
443
|
+
decisions=decisions,
|
|
444
|
+
collisions=collisions,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
source_canonical = {str(src.iloc[i]["id"]): cluster_ids[i] for i in range(n_src)}
|
|
448
|
+
ring0_ids = set(cluster_ids[:n_src])
|
|
449
|
+
|
|
450
|
+
edges = pd.DataFrame(
|
|
451
|
+
{
|
|
452
|
+
"citing_id": [
|
|
453
|
+
source_canonical.get(str(c), str(c)) for c in cit["citing_id"]
|
|
454
|
+
],
|
|
455
|
+
"cited_id": cluster_ids[n_src:],
|
|
456
|
+
}
|
|
457
|
+
)
|
|
458
|
+
n_before = len(edges)
|
|
459
|
+
edges = edges[edges["citing_id"] != edges["cited_id"]]
|
|
460
|
+
n_self_loops = n_before - len(edges)
|
|
461
|
+
edges = edges.drop_duplicates().reset_index(drop=True)
|
|
462
|
+
|
|
463
|
+
source_file_by_cluster: dict[str, str] = {}
|
|
464
|
+
for i in range(n_src):
|
|
465
|
+
source_file_by_cluster.setdefault(cluster_ids[i], str(src.iloc[i]["source_file"]))
|
|
466
|
+
|
|
467
|
+
rings = _compute_rings(ring0_ids, edges)
|
|
468
|
+
records = []
|
|
469
|
+
for cluster_id, rep in representatives:
|
|
470
|
+
record = dict(rep)
|
|
471
|
+
record["id"] = cluster_id
|
|
472
|
+
# A work absent from `rings` can only be an orphan whose sole
|
|
473
|
+
# inbound edge was a dropped self-loop variant; ring 1 is the
|
|
474
|
+
# defensively correct depth for it.
|
|
475
|
+
record["ring"] = rings.get(cluster_id, 1)
|
|
476
|
+
record["source_file"] = source_file_by_cluster.get(cluster_id, "")
|
|
477
|
+
records.append(record)
|
|
478
|
+
works = pd.DataFrame(records).set_index("id")[_WORK_COLUMNS]
|
|
479
|
+
|
|
480
|
+
stats = {
|
|
481
|
+
"n_self_loops_dropped": int(n_self_loops),
|
|
482
|
+
"n_source_duplicates_merged": int(n_src - len(ring0_ids)),
|
|
483
|
+
# Per-citation-row canonical id, aligned with citations_raw order.
|
|
484
|
+
# Lets callers (e.g. the report's merge audit) reconstruct which
|
|
485
|
+
# raw citation events merged into which work.
|
|
486
|
+
"citation_cluster_ids": [str(c) for c in cluster_ids[n_src:]],
|
|
487
|
+
"source_cluster_ids": [str(c) for c in cluster_ids[:n_src]],
|
|
488
|
+
"decisions": decisions,
|
|
489
|
+
"id_collisions": collisions,
|
|
490
|
+
}
|
|
491
|
+
logger.info(
|
|
492
|
+
"Canonicalized %d sources + %d citations into %d works (%d edges)",
|
|
493
|
+
n_src,
|
|
494
|
+
len(cit),
|
|
495
|
+
len(works),
|
|
496
|
+
len(edges),
|
|
497
|
+
)
|
|
498
|
+
return works, edges, stats
|